silence-cli 0.5.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 std::path::PathBuf;

#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct HookStdin {
    args: Option<HookArgs>,
    tool_input: Option<HookArgs>,
}

#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct HookArgs {
    #[serde(alias = "filePath", alias = "path", alias = "filename", alias = "file")]
    file_path: Option<String>,
    #[serde(alias = "patchText")]
    patch: Option<String>,
    diff: Option<String>,
    input: Option<String>,
}

pub fn paths_from_stdin(input: &str) -> Result<Vec<PathBuf>, String> {
    if input.trim().is_empty() {
        return Ok(Vec::new());
    }
    let event: HookStdin = serde_json::from_str(input).map_err(|e| e.to_string())?;
    Ok(event.into_paths())
}

impl HookStdin {
    fn into_paths(self) -> Vec<PathBuf> {
        let mut out = Vec::new();
        if let Some(args) = self.args.or(self.tool_input) {
            args.push_paths(&mut out);
        }
        out.sort();
        out.dedup();
        out
    }
}

impl HookArgs {
    fn push_paths(&self, out: &mut Vec<PathBuf>) {
        if let Some(path) = &self.file_path {
            out.push(PathBuf::from(path));
        }
        if let Some(patch) = self.patch_text() {
            paths_from_patch(patch, out);
        }
    }

    fn patch_text(&self) -> Option<&str> {
        self.patch
            .as_deref()
            .or(self.diff.as_deref())
            .or(self.input.as_deref())
    }
}

fn paths_from_patch(patch: &str, out: &mut Vec<PathBuf>) {
    for raw in patch.lines() {
        let line = raw.trim_start();
        for prefix in ["*** Update File: ", "*** Add File: ", "*** Move to: "] {
            if let Some(rest) = line.strip_prefix(prefix) {
                out.push(PathBuf::from(rest.trim()));
            }
        }
        if let Some(rest) = raw.strip_prefix("+++ b/") {
            out.push(PathBuf::from(rest.trim()));
        }
    }
}

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

    type TestResult = Result<(), Box<dyn std::error::Error>>;

    #[test]
    fn claude_tool_input_file_path() -> TestResult {
        let paths = paths_from_stdin(
            r#"{"hook_event_name":"PostToolUse","tool_input":{"file_path":"/tmp/a.rs"}}"#,
        )?;
        assert_eq!(paths, vec![PathBuf::from("/tmp/a.rs")]);
        Ok(())
    }

    #[test]
    fn opencode_args_patch_text() -> TestResult {
        let paths =
            paths_from_stdin(r#"{"args":{"patchText":"*** Update File: src/a.rs\n+// x\n"}}"#)?;
        assert_eq!(paths, vec![PathBuf::from("src/a.rs")]);
        Ok(())
    }

    #[test]
    fn codex_tool_input_patch_in_input_field() -> TestResult {
        let paths = paths_from_stdin(
            r#"{"tool_input":{"input":"*** Update File: b.rs\n*** End Patch\n"}}"#,
        )?;
        assert_eq!(paths, vec![PathBuf::from("b.rs")]);
        Ok(())
    }

    #[test]
    fn empty_stdin_is_ok() -> TestResult {
        assert!(paths_from_stdin("  ")?.is_empty());
        Ok(())
    }

    #[test]
    fn invalid_json_fails() {
        assert!(paths_from_stdin("{").is_err());
    }
}