config.rs
raw
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<Category, MinAge>,
/// The projects grunk manages, maintained by `add`, `remove` and `scan`.
#[serde(default)]
projects: Vec<PathBuf>,
}
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<PathBuf> {
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> {
Self::load_from(&Self::path()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
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<PathBuf> {
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<bool> {
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<Vec<PathBuf>> {
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<PathBuf> = 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<bool> {
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<PathBuf> {
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<DocumentMut> {
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"
);
}
}