use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, bail}; use serde::Deserialize; use toml_edit::{Array, DocumentMut, Item, Value}; use crate::age::MinAge; use crate::fsutil::expand_tilde; use crate::reclaim::Category; /// The threshold applied when the config file says nothing. /// /// Three days is long enough that a project you were working on last week is /// still cheap to pick back up, and short enough to be worth running. const DEFAULT_MIN_AGE: MinAge = MinAge::new(Duration::from_secs(3 * 24 * 60 * 60)); /// The key under which `add` and `remove` maintain their project list. const PROJECTS_KEY: &str = "projects"; /// Everything `~/.config/grunk.toml` can say. /// /// Unknown keys are rejected rather than ignored: a typo in the config of a /// tool that deletes files should be a loud error, not a silently different /// policy from the one you thought you wrote. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { /// How long data must go unused before `clean` will touch it. #[serde(default = "default_min_age")] min_age: MinAge, /// Per-category thresholds, for when one blanket number is too blunt. #[serde(default)] min_age_overrides: BTreeMap, /// The projects grunk manages, maintained by `add`, `remove` and `scan`. #[serde(default)] projects: Vec, } impl Default for Config { fn default() -> Self { Self { min_age: DEFAULT_MIN_AGE, min_age_overrides: BTreeMap::new(), projects: Vec::new(), } } } fn default_min_age() -> MinAge { DEFAULT_MIN_AGE } impl Config { /// Where the config lives: `$GRUNK_CONFIG`, else `$XDG_CONFIG_HOME/grunk.toml`. pub fn path() -> Result { if let Some(override_path) = std::env::var_os("GRUNK_CONFIG") { return Ok(PathBuf::from(override_path)); } let dir = dirs::config_dir().context("locating the user config directory")?; Ok(dir.join("grunk.toml")) } /// Load the config, treating a missing file as an all-defaults config. pub fn load() -> Result { Self::load_from(&Self::path()?) } pub fn load_from(path: &Path) -> Result { let text = match fs::read_to_string(path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())), }; toml::from_str(&text).with_context(|| format!("parsing {}", path.display())) } /// The threshold that applies to a given category. pub fn min_age(&self, category: Category) -> MinAge { self.min_age_overrides .get(&category) .copied() .unwrap_or(self.min_age) } pub fn projects(&self) -> Vec { self.projects.iter().map(|p| expand_tilde(p)).collect() } } /// Add a project to the config's `projects`, in place. /// /// Returns whether the path was actually new. pub fn add_project(config_path: &Path, project: &Path) -> Result { Ok(!add_projects(config_path, std::slice::from_ref(&project.to_path_buf()))?.is_empty()) } /// Add several projects to the config's `projects`, in one edit. /// /// The file is edited rather than rewritten so that comments and hand-chosen /// formatting survive: this is a file the user writes too, not just a store. /// Returns the projects that were actually new, in the order given. /// /// Taking the whole batch at once means `scan` rewrites the file once rather /// than once per project, and either adds everything it found or — if one entry /// turns out not to be a project at all — nothing. pub fn add_projects(config_path: &Path, projects: &[PathBuf]) -> Result> { let mut resolved = Vec::with_capacity(projects.len()); for project in projects { let project = absolutize(project)?; if !project.join("Cargo.toml").is_file() { bail!( "{} is not a cargo project (no Cargo.toml)", project.display() ); } resolved.push(project); } let mut doc = read_document(config_path)?; let array = projects_array(&mut doc, config_path)?; let mut present: Vec = array .iter() .filter_map(Value::as_str) .map(|existing| expand_tilde(Path::new(existing))) .collect(); let mut added = Vec::new(); for project in resolved { // Checked against what is already there *and* what this batch has // added, so naming the same project twice adds it once. if present.contains(&project) { continue; } array.push(project.to_string_lossy().as_ref()); present.push(project.clone()); added.push(project); } if added.is_empty() { return Ok(added); } format_one_per_line(array); write_document(config_path, &doc)?; Ok(added) } /// Drop a project from the config's `projects`, in place. /// /// Returns whether anything was actually removed. pub fn remove_project(config_path: &Path, project: &Path) -> Result { let project = absolutize(project)?; let mut doc = read_document(config_path)?; let array = projects_array(&mut doc, config_path)?; let before = array.len(); array.retain(|entry| { entry .as_str() .is_none_or(|existing| expand_tilde(Path::new(existing)) != project) }); if array.len() == before { return Ok(false); } format_one_per_line(array); write_document(config_path, &doc)?; Ok(true) } /// Resolve a user-supplied path to an absolute one, without requiring it to exist. fn absolutize(path: &Path) -> Result { let expanded = expand_tilde(path); let absolute = if expanded.is_absolute() { expanded } else { std::env::current_dir() .context("resolving the current directory")? .join(expanded) }; // Canonicalize when we can, so the same project named two ways compares // equal; fall back to the plain absolute path when it does not exist yet. Ok(fs::canonicalize(&absolute).unwrap_or(absolute)) } fn read_document(path: &Path) -> Result { let text = match fs::read_to_string(path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())), }; text.parse() .with_context(|| format!("parsing {}", path.display())) } fn write_document(path: &Path, doc: &DocumentMut) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } fs::write(path, doc.to_string()).with_context(|| format!("writing {}", path.display())) } fn projects_array<'doc>(doc: &'doc mut DocumentMut, path: &Path) -> Result<&'doc mut Array> { let entry = doc .as_table_mut() .entry(PROJECTS_KEY) .or_insert_with(|| Item::Value(Value::Array(Array::new()))); entry.as_array_mut().with_context(|| { format!( "{} has a `{PROJECTS_KEY}` that is not an array", path.display() ) }) } /// Lay the array out one entry per line, so the file stays readable as it grows. fn format_one_per_line(array: &mut Array) { for entry in array.iter_mut() { entry.decor_mut().set_prefix("\n "); entry.decor_mut().set_suffix(""); } if array.is_empty() { array.set_trailing(""); } else { array.set_trailing(",\n"); } array.set_trailing_comma(false); } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; const DAY: Duration = Duration::from_secs(24 * 60 * 60); fn project_in(dir: &Path, name: &str) -> PathBuf { let project = dir.join(name); fs::create_dir_all(&project).unwrap(); fs::write(project.join("Cargo.toml"), "[package]\n").unwrap(); project } #[test] fn a_missing_file_is_the_default_config() { let tmp = TempDir::new().unwrap(); let config = Config::load_from(&tmp.path().join("absent.toml")).unwrap(); assert_eq!(config.min_age(Category::Target), DEFAULT_MIN_AGE); assert!(config.projects().is_empty()); } #[test] fn overrides_win_over_the_blanket_threshold() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("grunk.toml"); fs::write( &path, r#" min_age = "3d" [min_age_overrides] registry-crate = "30d" "#, ) .unwrap(); let config = Config::load_from(&path).unwrap(); assert_eq!(config.min_age(Category::Target), MinAge::new(3 * DAY)); assert_eq!( config.min_age(Category::RegistryCrate), MinAge::new(30 * DAY) ); assert_eq!(config.min_age(Category::GitDb), MinAge::new(3 * DAY)); } #[test] fn unknown_keys_are_rejected() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("grunk.toml"); fs::write(&path, "min_agee = \"3d\"\n").unwrap(); let err = Config::load_from(&path).unwrap_err(); assert!( format!("{err:#}").contains("min_agee"), "error should name the offending key: {err:#}" ); } #[test] fn an_unparseable_age_is_rejected() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("grunk.toml"); fs::write(&path, "min_age = \"whenever\"\n").unwrap(); assert!(Config::load_from(&path).is_err()); } #[test] fn add_creates_the_file_and_is_idempotent() { let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); let project = project_in(tmp.path(), "proj"); assert!(add_project(&config_path, &project).unwrap()); assert!(!add_project(&config_path, &project).unwrap()); let config = Config::load_from(&config_path).unwrap(); assert_eq!(config.projects(), vec![fs::canonicalize(&project).unwrap()]); } #[test] fn add_preserves_comments_and_other_keys() { let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); fs::write( &config_path, "# keep me\nmin_age = \"5d\"\n\n[min_age_overrides]\ntarget = \"9d\"\n", ) .unwrap(); let project = project_in(tmp.path(), "proj"); add_project(&config_path, &project).unwrap(); let text = fs::read_to_string(&config_path).unwrap(); assert!(text.contains("# keep me"), "comment was lost:\n{text}"); assert!( text.contains("min_age = \"5d\""), "min_age was lost:\n{text}" ); assert!( text.contains("target = \"9d\""), "overrides were lost:\n{text}" ); // Both the blanket threshold and the override still mean what they did // before the edit — an `add` writes one key and leaves the rest alone. let config = Config::load_from(&config_path).unwrap(); assert_eq!(config.min_age(Category::Target), MinAge::new(9 * DAY)); assert_eq!(config.min_age(Category::GitDb), MinAge::new(5 * DAY)); assert_eq!(config.projects().len(), 1); } #[test] fn adding_a_batch_writes_them_all_and_skips_repeats() { let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); let one = project_in(tmp.path(), "one"); let two = project_in(tmp.path(), "two"); let added = add_projects(&config_path, &[one.clone(), two.clone(), one.clone()]).unwrap(); assert_eq!(added.len(), 2, "the repeat should not be added twice"); assert_eq!(Config::load_from(&config_path).unwrap().projects().len(), 2); // A second batch of the same projects adds nothing. assert!(add_projects(&config_path, &[one, two]).unwrap().is_empty()); assert_eq!(Config::load_from(&config_path).unwrap().projects().len(), 2); } #[test] fn a_batch_with_one_bad_entry_adds_nothing() { // `scan` hands over a whole tree's worth at once; a single bad path // should not leave the config half-written. let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); let good = project_in(tmp.path(), "good"); let bad = tmp.path().join("not-a-project"); fs::create_dir_all(&bad).unwrap(); assert!(add_projects(&config_path, &[good, bad]).is_err()); assert!( !config_path.exists(), "nothing should have been written at all" ); } #[test] fn remove_reports_whether_it_removed_anything() { let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); let project = project_in(tmp.path(), "proj"); let other = project_in(tmp.path(), "other"); add_project(&config_path, &project).unwrap(); assert!(!remove_project(&config_path, &other).unwrap()); assert!(remove_project(&config_path, &project).unwrap()); assert!( Config::load_from(&config_path) .unwrap() .projects() .is_empty() ); } #[test] fn remove_matches_a_tilde_written_entry() { // Entries the user typed by hand may be `~`-relative while the path // handed to `remove` is absolute; they still name the same project. let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); let home = dirs::home_dir().unwrap(); fs::write(&config_path, "projects = [\"~/some-project\"]\n").unwrap(); assert!(remove_project(&config_path, &home.join("some-project")).unwrap()); assert!( Config::load_from(&config_path) .unwrap() .projects() .is_empty() ); } #[test] fn add_rejects_a_directory_that_is_not_a_project() { let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); let not_a_project = tmp.path().join("empty"); fs::create_dir_all(¬_a_project).unwrap(); let err = add_project(&config_path, ¬_a_project).unwrap_err(); assert!(format!("{err:#}").contains("Cargo.toml")); } #[test] fn a_non_array_projects_is_an_error_not_a_clobber() { let tmp = TempDir::new().unwrap(); let config_path = tmp.path().join("grunk.toml"); fs::write(&config_path, "projects = \"oops\"\n").unwrap(); let project = project_in(tmp.path(), "proj"); assert!(add_project(&config_path, &project).is_err()); assert_eq!( fs::read_to_string(&config_path).unwrap(), "projects = \"oops\"\n", "the file must be left untouched" ); } }