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(&not_a_project).unwrap();

        let err = add_project(&config_path, &not_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"
        );
    }
}