use std::io::Write;
use std::process::{Command, Stdio};
fn dcg_binary() -> std::path::PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("dcg");
path
}
fn run_hook_mode_raw(input: &str) -> (String, String, i32) {
let mut child = Command::new(dcg_binary())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn dcg process");
{
let stdin = child.stdin.as_mut().expect("failed to get stdin");
stdin
.write_all(input.as_bytes())
.expect("failed to write to stdin");
}
let output = child.wait_with_output().expect("failed to wait for dcg");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
(stdout, stderr, exit_code)
}
fn make_hook_input(command: &str) -> String {
format!(
r#"{{"tool_name":"Bash","tool_input":{{"command":"{}"}}}}"#,
command.replace('\\', "\\\\").replace('"', "\\\"")
)
}
#[test]
fn test_exit_0_on_allow_safe_command() {
let input = make_hook_input("ls -la");
let (stdout, stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(
exit_code, 0,
"allowed command should exit 0\nstderr: {stderr}"
);
assert!(
stdout.is_empty() || stdout.trim().is_empty(),
"allowed command should produce no stdout\nstdout: {stdout}"
);
}
#[test]
fn test_exit_0_on_allow_git_status() {
let input = make_hook_input("git status");
let (stdout, _stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(exit_code, 0, "git status should exit 0");
assert!(
stdout.trim().is_empty(),
"git status should produce no stdout"
);
}
#[test]
fn test_exit_0_on_deny_with_json() {
let input = make_hook_input("git reset --hard");
let (stdout, stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(
exit_code, 0,
"denied command should still exit 0 (decision in JSON)\nstderr: {stderr}"
);
assert!(
!stdout.is_empty(),
"denied command should produce JSON stdout"
);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout should be valid JSON");
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"], "deny",
"JSON should indicate deny decision"
);
}
#[test]
fn test_exit_0_on_deny_rm_rf() {
let input = make_hook_input("rm -rf /important");
let (stdout, _stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(exit_code, 0, "rm -rf denial should exit 0");
assert!(!stdout.is_empty(), "denied rm -rf should produce JSON");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout should be valid JSON");
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"], "deny",
"rm -rf should be denied"
);
}
#[test]
fn test_exit_0_on_deny_force_push() {
let input = make_hook_input("git push --force origin main");
let (stdout, _stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(exit_code, 0, "force push denial should exit 0");
if !stdout.is_empty() {
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout should be valid JSON");
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"], "deny",
"force push should be denied"
);
}
}
#[test]
fn test_exit_0_for_non_bash_tool() {
let input = r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd"}}"#;
let (stdout, _stderr, exit_code) = run_hook_mode_raw(input);
assert_eq!(exit_code, 0, "non-Bash tool should exit 0");
assert!(
stdout.is_empty() || stdout.trim().is_empty(),
"non-Bash tool should produce no output"
);
}
#[test]
fn test_exit_0_for_write_tool() {
let input =
r#"{"tool_name":"Write","tool_input":{"file_path":"/tmp/test.txt","content":"hello"}}"#;
let (stdout, _stderr, exit_code) = run_hook_mode_raw(input);
assert_eq!(exit_code, 0, "Write tool should exit 0 (skip)");
assert!(
stdout.trim().is_empty(),
"Write tool should produce no output"
);
}
#[test]
fn test_exit_nonzero_on_invalid_json() {
let (_stdout, stderr, exit_code) = run_hook_mode_raw("this is not json at all");
if exit_code != 0 {
assert!(
stderr.contains("error")
|| stderr.contains("Error")
|| stderr.contains("JSON")
|| stderr.contains("parse")
|| stderr.contains("invalid"),
"stderr should explain the error\nstderr: {stderr}"
);
}
}
#[test]
fn test_exit_on_empty_input() {
let (stdout, _stderr, exit_code) = run_hook_mode_raw("");
assert!(
exit_code == 0 || exit_code == 1 || exit_code == 2,
"empty input should exit with defined code, got: {exit_code}"
);
if exit_code == 0 {
assert!(
stdout.is_empty() || stdout.trim().is_empty(),
"empty input with exit 0 should produce no output"
);
}
}
#[test]
fn test_exit_on_missing_tool_name() {
let input = r#"{"tool_input":{"command":"echo hello"}}"#;
let (stdout, _stderr, exit_code) = run_hook_mode_raw(input);
assert_eq!(exit_code, 0, "missing tool_name should not error");
assert!(
stdout.trim().is_empty(),
"missing tool_name should skip (no output)"
);
}
#[test]
fn test_exit_on_missing_command() {
let input = r#"{"tool_name":"Bash","tool_input":{}}"#;
let (stdout, _stderr, exit_code) = run_hook_mode_raw(input);
assert_eq!(exit_code, 0, "missing command should not error");
assert!(
stdout.trim().is_empty(),
"missing command should skip (no output)"
);
}
#[test]
fn test_test_command_exit_0() {
let output = Command::new(dcg_binary())
.args(["test", "git status"])
.output()
.expect("failed to run dcg test");
assert!(output.status.success(), "dcg test <safe> should exit 0");
}
#[test]
fn test_test_command_deny_exit_1() {
let output = Command::new(dcg_binary())
.args(["test", "git reset --hard"])
.output()
.expect("failed to run dcg test");
assert_eq!(
output.status.code(),
Some(1),
"dcg test <dangerous> should exit 1"
);
}
#[test]
fn test_explain_command_exit_0() {
let output = Command::new(dcg_binary())
.args(["explain", "git reset --hard"])
.output()
.expect("failed to run dcg explain");
assert!(output.status.success(), "dcg explain should exit 0");
}
#[test]
fn test_packs_command_exit_0() {
let output = Command::new(dcg_binary())
.args(["packs"])
.output()
.expect("failed to run dcg packs");
assert!(output.status.success(), "dcg packs should exit 0");
}
#[test]
fn test_version_exit_0() {
let output = Command::new(dcg_binary())
.args(["--version"])
.output()
.expect("failed to run dcg --version");
assert!(output.status.success(), "dcg --version should exit 0");
}
#[test]
fn test_help_exit_0() {
let output = Command::new(dcg_binary())
.args(["--help"])
.output()
.expect("failed to run dcg --help");
assert!(output.status.success(), "dcg --help should exit 0");
}
#[test]
fn test_consistent_exit_codes_across_commands() {
let safe_commands = [
"ls",
"echo hello",
"git status",
"git log --oneline",
"cat /etc/passwd",
"grep pattern file.txt",
];
for cmd in safe_commands {
let input = make_hook_input(cmd);
let (stdout, stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(
exit_code, 0,
"safe command '{cmd}' should exit 0\nstderr: {stderr}"
);
assert!(
stdout.trim().is_empty(),
"safe command '{cmd}' should have empty stdout\nstdout: {stdout}"
);
}
}
#[test]
fn test_consistent_exit_codes_denied_commands() {
let dangerous_commands = [
"git reset --hard",
"git clean -fd",
"rm -rf /",
"git push --force",
];
for cmd in dangerous_commands {
let input = make_hook_input(cmd);
let (stdout, stderr, exit_code) = run_hook_mode_raw(&input);
assert_eq!(
exit_code, 0,
"dangerous command '{cmd}' should exit 0\nstderr: {stderr}"
);
if !stdout.is_empty() {
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON for '{cmd}': {e}\nstdout: {stdout}"));
assert!(
json.get("hookSpecificOutput").is_some(),
"denied '{cmd}' should have hookSpecificOutput"
);
}
}
}