sasurahime 0.1.8

macOS developer cache cleaner — scan and wipe stale caches from 40+ tools
use anyhow::Result;
use std::io::Read;
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use wait_timeout::ChildExt;

/// Default timeout for external subprocess calls.
/// Applies to every `run()` invocation unless overridden.
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);

pub trait CommandRunner: Send + Sync {
    fn run(&self, program: &str, args: &[&str]) -> Result<Output>;
    /// Returns true if `program` is available in PATH.
    fn exists(&self, program: &str) -> bool;
}

pub struct SystemCommandRunner;

impl CommandRunner for SystemCommandRunner {
    fn run(&self, program: &str, args: &[&str]) -> Result<Output> {
        let mut child = Command::new(program)
            .args(args)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .map_err(|e| anyhow::anyhow!("failed to spawn `{}`: {}", program, e))?;

        match child.wait_timeout(COMMAND_TIMEOUT)? {
            Some(status) => {
                // Read remaining piped output.
                let mut stdout = Vec::new();
                let mut stderr = Vec::new();
                if let Some(ref mut out) = child.stdout {
                    out.read_to_end(&mut stdout)?;
                }
                if let Some(ref mut err) = child.stderr {
                    err.read_to_end(&mut stderr)?;
                }
                Ok(Output {
                    status,
                    stdout,
                    stderr,
                })
            }
            None => {
                // Timed out — kill the child process and clean up.
                let _ = child.kill();
                let _ = child.wait();
                anyhow::bail!(
                    "command `{}` did not complete within {}s and was killed",
                    program,
                    COMMAND_TIMEOUT.as_secs()
                );
            }
        }
    }

    fn exists(&self, program: &str) -> bool {
        Command::new("which")
            .arg(program)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    }
}

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

    #[test]
    fn run_tool_not_found_returns_error() {
        let runner = SystemCommandRunner;
        let result = runner.run("this-tool-does-not-exist-12345", &[]);
        assert!(result.is_err(), "non-existent tool must error");
    }

    #[test]
    fn run_successful_command_returns_output() {
        let runner = SystemCommandRunner;
        let result = runner.run("echo", &["hello"]).unwrap();
        assert!(result.status.success());
        assert_eq!(result.stdout.as_slice(), b"hello\n");
    }

    #[test]
    fn run_captures_stderr() {
        let runner = SystemCommandRunner;
        let result = runner.run("sh", &["-c", "echo errmsg >&2"]).unwrap();
        assert!(result.status.success());
        assert_eq!(result.stderr.as_slice(), b"errmsg\n");
    }

    #[test]
    fn run_long_command_respects_timeout() {
        // Verify the timeout constant is finite and reasonable.
        assert!(
            COMMAND_TIMEOUT <= Duration::from_secs(60),
            "timeout should be at most 60s, got {}s",
            COMMAND_TIMEOUT.as_secs()
        );
        assert!(
            COMMAND_TIMEOUT >= Duration::from_secs(1),
            "timeout should be at least 1s, got {}s",
            COMMAND_TIMEOUT.as_secs()
        );
    }
}