silence-cli 0.1.0

Remove comments from source code using tree-sitter. Installs a post-edit hook into Claude Code, Codex, Opencode, and Pi so AI-written slop comments are stripped automatically. Provides the `silence` binary.
use serde::Deserialize;
use silence_core::{PreserveConfig, DEFAULT_PRESERVE_PATTERNS};
use std::path::{Path, PathBuf};

#[derive(Debug, Default, Deserialize)]
pub struct FileConfig {
    #[serde(default)]
    pub preserve: Vec<String>,
    #[serde(default)]
    pub use_default_preserve: Option<bool>,
}

#[derive(Debug)]
pub struct LoadedConfig {
    pub path: Option<PathBuf>,
    file: FileConfig,
}

impl LoadedConfig {
    #[must_use]
    pub fn discover(start: &Path) -> LoadedConfig {
        if let Some(p) = find_config(start) {
            match std::fs::read_to_string(&p) {
                Ok(text) => match toml::from_str::<FileConfig>(&text) {
                    Ok(file) => {
                        return LoadedConfig {
                            path: Some(p),
                            file,
                        }
                    }
                    Err(e) => {
                        eprintln!("warning: ignoring malformed config {}: {e}", p.display());
                    }
                },
                Err(e) => {
                    eprintln!("warning: cannot read config {}: {e}", p.display());
                }
            }
        }
        LoadedConfig {
            path: None,
            file: FileConfig::default(),
        }
    }

    #[must_use]
    pub fn user_patterns(&self) -> &[String] {
        &self.file.preserve
    }

    #[must_use]
    pub fn uses_defaults(&self, no_default_preserve: bool) -> bool {
        !no_default_preserve && self.file.use_default_preserve.unwrap_or(true)
    }

    #[must_use]
    pub fn preserve(&self, no_default_preserve: bool) -> PreserveConfig {
        let use_defaults = !no_default_preserve && self.file.use_default_preserve.unwrap_or(true);

        let mut patterns = self.file.preserve.clone();
        if use_defaults {
            patterns.extend(DEFAULT_PRESERVE_PATTERNS.iter().map(|&s| s.to_string()));
        }
        PreserveConfig::with_patterns(patterns).with_directives(use_defaults)
    }
}

fn find_config(start: &Path) -> Option<PathBuf> {
    let local = start.join(".silence.toml");
    if local.is_file() {
        return Some(local);
    }
    if let Some(root) = git_root(start) {
        let at_root = root.join(".silence.toml");
        if at_root.is_file() && at_root != local {
            return Some(at_root);
        }
    }
    if let Some(home) = home_dir() {
        let global = home.join(".config").join(".silence.toml");
        if global.is_file() {
            return Some(global);
        }
    }
    None
}

fn git_root(start: &Path) -> Option<PathBuf> {
    let mut dir = start;
    loop {
        if dir.join(".git").exists() {
            return Some(dir.to_path_buf());
        }
        dir = dir.parent()?;
    }
}

fn home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn loaded(preserve: Vec<&str>, use_default_preserve: Option<bool>) -> LoadedConfig {
        LoadedConfig {
            path: None,
            file: FileConfig {
                preserve: preserve.into_iter().map(String::from).collect(),
                use_default_preserve,
            },
        }
    }

    #[test]
    fn no_config_uses_built_in_defaults() {
        let p = loaded(vec![], None).preserve(false);
        assert!(p.should_preserve("// TODO x"));
    }

    #[test]
    fn explicit_opt_out_with_empty_list_preserves_nothing() {
        let p = loaded(vec![], Some(false)).preserve(false);
        assert!(!p.should_preserve("// TODO x"));
        assert!(!p.should_preserve("// @ts-ignore"));
    }

    #[test]
    fn user_list_merges_with_defaults_by_default() {
        let p = loaded(vec!["KEEPME"], None).preserve(false);
        assert!(p.should_preserve("// KEEPME"));
        assert!(p.should_preserve("// TODO x"));
    }

    #[test]
    fn user_list_can_replace_defaults() {
        let p = loaded(vec!["KEEPME"], Some(false)).preserve(false);
        assert!(p.should_preserve("// KEEPME"));
        assert!(!p.should_preserve("// TODO x"));
    }

    #[test]
    fn no_default_preserve_flag_keeps_user_list() {
        let p = loaded(vec!["KEEPME"], None).preserve(true);
        assert!(p.should_preserve("// KEEPME"));
        assert!(!p.should_preserve("// TODO x"));
    }
}