From f5513557717852cf88c6b57c9106e593adaa1aac Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 19 Nov 2024 22:13:39 +0300 Subject: [PATCH] feat(cache): add `cache remove` command - Replaced `cache clear` with `cache remove --all`. - Slightly optimised cache manifest structure. - Fixed issue with non-existing cache root. --- Cargo.lock | 16 +++++ Cargo.toml | 1 + src/app.rs | 30 ++++++--- src/cache.rs | 182 ++++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 182 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78b5f84..9f7e454 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,7 @@ dependencies = [ "home", "indicatif", "inquire", + "itertools", "kdl", "miette", "reqwest", @@ -346,6 +347,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -777,6 +784,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 1b1858f..7825896 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ glob-match = { version = "0.2.1" } home = "0.5.9" indicatif = "0.17.8" inquire = { version = "0.7.0", features = ["editor"] } +itertools = "0.13.0" kdl = "=4.6.0" miette = { version = "=5.10.0", features = ["fancy"] } reqwest = { version = "0.11.22", features = ["json"] } diff --git a/src/app.rs b/src/app.rs index dd633ec..5358fa6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -78,8 +78,17 @@ pub struct RepositoryArgs { pub enum CacheCommand { /// List cache entries. List, - /// Remove all cache entries. - Clear, + /// Remove cache entries. + Remove { + /// List of cache entries to remove. + entries: Vec, + /// Interactive mode. + #[arg(short, long)] + interactive: bool, + /// Remove all cache entries. + #[arg(short, long, conflicts_with_all = ["entries", "interactive"])] + all: bool, + }, } #[derive(Debug)] @@ -91,6 +100,7 @@ pub struct App { } impl App { + #[allow(clippy::new_without_default)] pub fn new() -> Self { Self { cli: Cli::parse(), @@ -296,7 +306,15 @@ impl App { match command { | CacheCommand::List => Ok(cache.list()?), - | CacheCommand::Clear => Ok(cache.clear()?), + | CacheCommand::Remove { entries, interactive, all } => { + if all { + cache.remove_all() + } else if interactive { + cache.remove_interactive() + } else { + cache.remove(entries) + } + }, } } @@ -316,9 +334,3 @@ impl App { Ok(()) } } - -impl Default for App { - fn default() -> Self { - Self::new() - } -} diff --git a/src/cache.rs b/src/cache.rs index 68c4865..f5a08c2 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -8,6 +8,7 @@ use std::str::FromStr; use base32::Alphabet; use chrono::{DateTime, Utc}; use crossterm::style::Stylize; +use itertools::Itertools; use miette::{Diagnostic, Report}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -59,7 +60,7 @@ type Entry = String; /// # Structure /// /// ```toml -/// [[templates..items]] +/// [templates.] /// name = "" /// hash = "" /// timestamp = @@ -69,22 +70,23 @@ type Entry = String; /// /// - `` - Base 32 encoded source string in the form of: `:/`. /// - `` - Ref name or commit hash. -/// - `` - Ref/commit hash, either short of full. Used in filenames. +/// - `` - Ref/commit hash, either short or full. Used in filenames. /// - `` - Unix timestamp in milliseconds. #[derive(Debug, Default, Serialize, Deserialize)] pub struct Manifest { - templates: HashMap, + templates: HashMap>, } -/// Represents a template table. -#[derive(Debug, Serialize, Deserialize)] -pub struct Template { - /// List of linked items in the template table. - items: Vec, +impl Manifest { + /// Normalizes manifest be performing some cleanups. + pub fn normalize(&mut self) { + // Remove templates that are empty. + self.templates.retain(|_, items| !items.is_empty()); + } } /// Represents a linked item in the template table. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Item { /// Ref name or commit hash. name: String, @@ -118,7 +120,18 @@ impl Cache { .ok_or(miette::miette!("Failed to resolve home directory.")) } - /// Checks if two hashes match. + /// Parses a string into a [RemoteRepository]. + fn parse_repository(input: &str) -> Result { + RemoteRepository::from_str(input).map_err(|_| { + CacheError::Diagnostic(miette::miette!( + code = "arx::cache::malformed_entry", + help = "Manifest may be malformed, clear the cache and try again.", + "Couldn't parse entry: `{input}`." + )) + }) + } + + /// Checks if two hashes match. Custom check needed because hashes may differ in length. fn compare_hashes(left: &str, right: &str) -> bool { match left.len().cmp(&right.len()) { | Ordering::Less => right.starts_with(left), @@ -150,6 +163,15 @@ impl Cache { /// Writes manifest to disk. fn write_manifest(&mut self) -> miette::Result<()> { + // Create cache directory if it doesn't exist. + fs::create_dir_all(&self.root).map_err(|source| { + CacheError::Io { + message: "Failed to create the cache directory.".to_string(), + source, + } + })?; + + // Serialize and write manifest. let manifest = toml::to_string(&self.manifest).map_err(CacheError::TomlSerialize)?; fs::write(self.root.join(CACHE_MANIFEST), manifest).map_err(|source| { @@ -177,26 +199,23 @@ impl Cache { .manifest .templates .entry(entry) - .and_modify(|template| { + .and_modify(|items| { let hash = hash.to_string(); let name = name.to_string(); - if !template - .items + if !items .iter() .any(|item| Self::compare_hashes(&hash, &item.hash)) { - template.items.push(Item { name, hash, timestamp }); + items.push(Item { name, hash, timestamp }); } }) .or_insert_with(|| { - Template { - items: vec![Item { - name: name.to_string(), - hash: hash.to_string(), - timestamp, - }], - } + vec![Item { + name: name.to_string(), + hash: hash.to_string(), + timestamp, + }] }); self.write_manifest()?; @@ -221,13 +240,12 @@ impl Cache { Ok(()) } - /// Reads from cache. + /// Reads from cache and returns the cached tarball bytes if any. pub fn read(&self, source: &str, hash: &str) -> miette::Result>> { let entry = base32::encode(BASE32_ALPHABET, source.as_bytes()); - if let Some(template) = self.manifest.templates.get(&entry) { - let item = template - .items + if let Some(items) = self.manifest.templates.get(&entry) { + let item = items .iter() .find(|item| Self::compare_hashes(hash, &item.hash)); @@ -253,7 +271,7 @@ impl Cache { /// Lists cache entries. pub fn list(&self) -> Result<(), CacheError> { - for (key, template) in &self.manifest.templates { + for (key, items) in &self.manifest.templates { if let Some(bytes) = base32::decode(BASE32_ALPHABET, key) { let entry = String::from_utf8(bytes).map_err(|_| { CacheError::Diagnostic(miette::miette!( @@ -263,20 +281,16 @@ impl Cache { )) })?; - let repo = RemoteRepository::from_str(&entry).map_err(|_| { - CacheError::Diagnostic(miette::miette!( - code = "arx::cache::malformed_entry", - help = "Manifest may be malformed, clear the cache and try again.", - "Couldn't parse entry: `{key}`." - )) - })?; - + let repo = Self::parse_repository(&entry)?; let host = repo.host.to_string().cyan(); let name = format!("{}/{}", repo.user, repo.repo).green(); println!("⋅ {host}:{name}"); - for item in &template.items { + for item in items + .into_iter() + .sorted_by(|a, b| b.timestamp.cmp(&a.timestamp)) + { if let Some(date) = DateTime::from_timestamp_millis(item.timestamp) { let date = date.format("%d/%m/%Y %H:%M").to_string().dim(); let name = item.name.clone().cyan(); @@ -297,9 +311,98 @@ impl Cache { Ok(()) } - /// Clears cache. - pub fn clear(&mut self) -> miette::Result<()> { + /// Selects cache entries to remove based on the given search terms. + fn select_entries(&self, search: Vec) -> HashMap> { + let mut selection = HashMap::new(); + + for term in search { + let entry = base32::encode(BASE32_ALPHABET, term.as_bytes()); + + if let Some(items) = self.manifest.templates.get(&entry) { + selection.insert(entry, items.to_vec()); + } else { + for (entry, items) in &self.manifest.templates { + let droppable: Vec<_> = items + .into_iter() + .filter(|item| item.name == term || Self::compare_hashes(&item.hash, &term)) + .cloned() + .collect(); + + if !droppable.is_empty() { + selection.insert(entry.to_owned(), droppable); + } + } + } + } + + selection + } + + /// Removes cache entries _from the manifest only_ based on the given selections. + fn remove_entries(&mut self, selection: &HashMap>) -> miette::Result<()> { + for (entry, items) in selection { + self.manifest.templates.get_mut(entry).map(|source| { + source.retain(|item| !items.contains(item)); + }); + } + + Ok(()) + } + + /// Removes specified cache entries. We allow to remove by specifying: + /// + /// - entry name, e.g. github:foo/bar -- this will delete all cached entries under that name; + /// - entry hash, e.g. 4a5a56fd -- this will delete specific cached entry; + /// - ref name, e.g. feat/some-feature-name -- same as entry hash. + pub fn remove(&mut self, needles: Vec) -> miette::Result<()> { + let selection = self.select_entries(needles); + + // Actually remove the files and print their names (.tar.gz). + for (entry, items) in &selection { + let entry = base32::decode(BASE32_ALPHABET, entry.as_str()) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap(); + + let repo = Self::parse_repository(&entry)?; + let host = repo.host.to_string().cyan(); + let name = format!("{}/{}", repo.user, repo.repo).green(); + + println!("⋅ {host}:{name}"); + + for item in items + .into_iter() + .sorted_by(|a, b| b.timestamp.cmp(&a.timestamp)) + { + let tarball = self + .root + .join(CACHE_TARBALLS_DIR) + .join(format!("{}.tar.gz", &item.hash)); + + let name = item.name.clone().cyan(); + let hash = item.hash.clone().yellow(); + + print!("└─ {name} ╌╌ {hash} "); + + match fs::remove_file(&tarball) { + | Ok(..) => println!("{}", "✓".green()), + | Err(..) => println!("{}", "✗".red()), + } + } + } + + self.remove_entries(&selection)?; + + // Normalize and write manifest. + self.manifest.normalize(); + self.write_manifest()?; + + Ok(()) + } + + /// Removes all cache entries. + pub fn remove_all(&mut self) -> miette::Result<()> { self.manifest.templates.clear(); + self.manifest.normalize(); fs::remove_dir_all(self.root.join(CACHE_TARBALLS_DIR)).map_err(|source| { CacheError::Io { @@ -308,8 +411,11 @@ impl Cache { } })?; - self.write_manifest()?; + self.write_manifest() + } + /// Removes cache entries interactively. + pub fn remove_interactive(&mut self) -> miette::Result<()> { Ok(()) } }