use rch_common::classify_command;
use rch_common::testing::{TestLogger, TestPhase};
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const ANSI_ESC: &str = "\x1b[";
const MAX_COMPILATION_HOOK_TIME_MS: u64 = 5;
const RCH_RICH_PATTERNS: &[&str] = &["[rch]", "[RCH]", "╔═", "║", "╚═"];
fn rch_binary() -> String {
std::env::var("RCH_BINARY").unwrap_or_else(|_| env!("CARGO_BIN_EXE_rch").to_string())
}
fn run_hook(input: &str) -> (i32, String, String, Duration) {
let rch = rch_binary();
let start = Instant::now();
let mut child = Command::new(&rch)
.env("RCH_HOOK_STARTS_DAEMON", "0")
.env("RCH_AUTO_START_TIMEOUT_SECS", "0")
.env("RCH_SOCKET_PATH", "/tmp/rch-test-nonexistent.sock")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to spawn rch");
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(input.as_bytes()).ok();
}
drop(child.stdin.take());
let output = child.wait_with_output().expect("Failed to get output");
let duration = start.elapsed();
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(exit_code, stdout, stderr, duration)
}
fn contains_ansi_codes(s: &str) -> bool {
s.contains(ANSI_ESC)
}
fn contains_rch_rich_output(s: &str) -> bool {
RCH_RICH_PATTERNS.iter().any(|pattern| s.contains(pattern))
}
fn make_hook_input(command: &str) -> String {
format!(
r#"{{"tool_name":"Bash","tool_input":{{"command":"{}"}}}}"#,
command
)
}
#[test]
fn test_cargo_build_json_response_purity() {
let logger = TestLogger::for_test("test_cargo_build_json_response_purity");
let input = make_hook_input("cargo build --release");
logger.log(
TestPhase::Execute,
"Running hook with cargo build --release",
);
let (exit_code, stdout, _stderr, _duration) = run_hook(&input);
logger.log_with_data(
TestPhase::Verify,
"Checking JSON response purity",
serde_json::json!({
"exit_code": exit_code,
"stdout_len": stdout.len()
}),
);
if !stdout.is_empty() {
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
assert!(
parsed.is_ok(),
"stdout is not valid JSON: {}",
stdout.chars().take(200).collect::<String>()
);
}
assert!(
!contains_ansi_codes(&stdout),
"cargo build stdout contains ANSI codes!"
);
logger.pass();
}
#[test]
fn test_cargo_test_json_response_purity() {
let input = make_hook_input("cargo test");
let (_exit_code, stdout, _stderr, _duration) = run_hook(&input);
if !stdout.is_empty() {
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
assert!(
parsed.is_ok(),
"cargo test stdout is not valid JSON: {}",
stdout.chars().take(200).collect::<String>()
);
}
assert!(
!contains_ansi_codes(&stdout),
"cargo test stdout contains ANSI codes!"
);
}
#[test]
fn test_cargo_check_json_response_purity() {
let input = make_hook_input("cargo check");
let (_exit_code, stdout, _stderr, _duration) = run_hook(&input);
if !stdout.is_empty() {
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
assert!(
parsed.is_ok(),
"cargo check stdout is not valid JSON: {}",
stdout.chars().take(200).collect::<String>()
);
}
assert!(
!contains_ansi_codes(&stdout),
"cargo check stdout contains ANSI codes!"
);
}
#[test]
fn test_exit_code_preservation_allow() {
let input = make_hook_input("echo hello");
let (exit_code, _stdout, _stderr, _duration) = run_hook(&input);
assert_eq!(exit_code, 0, "Passthrough command should exit 0");
}
#[test]
fn test_no_rch_rich_output_leakage() {
let commands = [
"cargo build",
"cargo test",
"cargo check",
"cargo build --release",
];
for cmd in commands {
let input = make_hook_input(cmd);
let (_exit_code, stdout, _stderr, _duration) = run_hook(&input);
assert!(
!contains_rch_rich_output(&stdout),
"RCH rich output found in stdout for '{}': {}",
cmd,
stdout.chars().take(200).collect::<String>()
);
}
}
#[test]
fn test_compilation_classification_timing() {
let logger = TestLogger::for_test("test_compilation_classification_timing");
let commands = [
"cargo build",
"cargo test",
"cargo check",
"cargo build --release",
];
logger.log(
TestPhase::Setup,
format!(
"Testing classification timing for {} commands",
commands.len()
),
);
for cmd in commands {
let iterations = 10_000u32;
let warm = classify_command(cmd);
assert!(warm.is_compilation, "Expected compilation command: {cmd}");
let start = Instant::now();
for _ in 0..iterations {
let classification = classify_command(cmd);
assert!(
classification.is_compilation,
"Expected compilation command: {cmd}"
);
}
let total = start.elapsed();
logger.log_with_data(
TestPhase::Verify,
format!("Classification timing for '{}'", cmd),
serde_json::json!({
"command": cmd,
"iterations": iterations,
"total_ns": total.as_nanos() as u64,
"avg_ns": (total.as_nanos() / u128::from(iterations)) as u64,
"threshold_ms": MAX_COMPILATION_HOOK_TIME_MS
}),
);
let avg_ns = total.as_nanos() / u128::from(iterations);
let max_allowed_ns = u128::from(MAX_COMPILATION_HOOK_TIME_MS) * 1_000_000;
assert!(
avg_ns <= max_allowed_ns,
"Classification for '{}' too slow: {}ns avg (max: {}ms)",
cmd,
avg_ns,
MAX_COMPILATION_HOOK_TIME_MS
);
eprintln!(
"Timing for '{}': {}ns avg ({} iters)",
cmd, avg_ns, iterations
);
}
logger.pass();
}
#[test]
fn test_stdout_stderr_separation() {
let input = make_hook_input("cargo build --release");
let (_exit_code, stdout, _stderr, _duration) = run_hook(&input);
if !stdout.is_empty() {
let first_char = stdout.chars().next();
assert!(
first_char == Some('{') || first_char == Some('['),
"stdout should be JSON, not raw output. Got: {}",
stdout.chars().take(100).collect::<String>()
);
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
assert!(
parsed.is_ok(),
"stdout must be valid JSON: {}",
stdout.chars().take(200).collect::<String>()
);
}
}
#[test]
fn test_empty_input_handling() {
let (exit_code, _stdout, _stderr, _duration) = run_hook("");
assert_eq!(exit_code, 0, "Empty input should exit 0 (fail-open)");
}
#[test]
fn test_invalid_json_handling() {
let (exit_code, _stdout, _stderr, _duration) = run_hook("not valid json");
assert_eq!(exit_code, 0, "Invalid JSON should exit 0 (fail-open)");
}
#[test]
fn test_non_bash_tool_passthrough() {
let input = r#"{"tool_name":"Read","tool_input":{"path":"/some/file"}}"#;
let (exit_code, _stdout, _stderr, _duration) = run_hook(input);
assert_eq!(exit_code, 0, "Non-Bash tool should exit 0 (allow)");
}