ptuf 0.5.0

PreToolUseFilter: a generic guardrail layer for coding agents
Documentation
use std::io::{Read, Write};
use std::process::ExitCode;

use crate::cli;

/// Top-level CLI entry. Parses argv, dispatches to a [`crate::cli::Command`],
/// and returns an [`ExitCode`].
pub fn run<R, W1, W2, S>(args: &[S], stdin: R, stdout: &mut W1, stderr: &mut W2) -> ExitCode
where
    R: Read,
    W1: Write,
    W2: Write,
    S: AsRef<str>,
{
    let owned: Vec<String> = args.iter().map(|s| s.as_ref().to_string()).collect();
    let (globals, command) = match cli::parse(&owned) {
        Ok(c) => c,
        Err(err) => {
            let _ = writeln!(stderr, "ptuf: {err}");
            return ExitCode::from(1);
        },
    };
    ExitCode::from(cli::run(globals, command, stdin, stdout, stderr))
}

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

    #[test]
    fn run_dispatches_check_with_args() {
        let mut out = Vec::new();
        let mut err = Vec::new();
        let args: Vec<&str> = vec!["check", "--tool", "Bash", "ls"];
        let _exit = run(&args, b"" as &[u8], &mut out, &mut err);
        let out_s = String::from_utf8_lossy(&out);
        assert!(out_s.contains("Decision: allow"));
    }

    #[test]
    fn run_reports_unknown_command_to_stderr_with_exit_one() {
        let mut out = Vec::new();
        let mut err = Vec::new();
        let args: Vec<&str> = vec!["unknown-cmd"];
        let _exit = run(&args, b"" as &[u8], &mut out, &mut err);
        let err_s = String::from_utf8_lossy(&err);
        assert!(err_s.contains("unknown command"));
    }

    #[test]
    fn run_with_no_args_reports_missing_subcommand() {
        let mut out = Vec::new();
        let mut err = Vec::new();
        let args: Vec<&str> = vec![];
        let _exit = run(&args, b"" as &[u8], &mut out, &mut err);
        let err_s = String::from_utf8_lossy(&err);
        assert!(err_s.contains("missing value for subcommand"), "{err_s}");
    }

    #[test]
    fn io_runner_dispatches_hook_with_exit_code() {
        let mut out = Vec::new();
        let mut err = Vec::new();
        let args = vec!["hook", "codex"];
        let exit = run(&args, b"{not valid json" as &[u8], &mut out, &mut err);
        assert_eq!(exit, ExitCode::from(2));
        let out_s = String::from_utf8_lossy(&out);
        assert!(out_s.contains("\"permissionDecision\":\"deny\""));
    }

    #[test]
    fn io_runner_dispatches_plugin_check() {
        let dir = tempfile::TempDir::new().expect("tempdir");
        let bad_plugin = dir.path().join("bad.yaml");
        std::fs::write(&bad_plugin, "::not yaml::").expect("write plugin");
        let mut out = Vec::new();
        let mut err = Vec::new();
        let path = bad_plugin.to_str().expect("utf-8 path");
        let args = vec!["plugin", "check", path];
        let exit = run(&args, b"" as &[u8], &mut out, &mut err);
        assert_ne!(exit, ExitCode::from(0));
        let err_s = String::from_utf8_lossy(&err);
        assert!(!err_s.is_empty() || !String::from_utf8_lossy(&out).is_empty());
    }
}