use std::fmt::Write as _;
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_dcg_batch(input: &str) -> std::process::Output {
run_dcg_batch_with_args(input, &[])
}
fn run_dcg_batch_with_args(input: &str, extra_args: &[&str]) -> std::process::Output {
let temp = tempfile::tempdir().expect("failed to create temp dir");
std::fs::create_dir_all(temp.path().join(".git")).expect("failed to create .git dir");
let home_dir = temp.path().join("home");
let xdg_config_dir = temp.path().join("xdg_config");
std::fs::create_dir_all(&home_dir).expect("failed to create HOME dir");
std::fs::create_dir_all(&xdg_config_dir).expect("failed to create XDG_CONFIG_HOME dir");
let mut args = vec!["hook", "--batch"];
args.extend(extra_args);
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("HOME", &home_dir)
.env("XDG_CONFIG_HOME", &xdg_config_dir)
.env("DCG_ALLOWLIST_SYSTEM_PATH", "")
.env("DCG_PACKS", "core.git,core.filesystem")
.current_dir(temp.path())
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("failed to spawn dcg batch mode");
{
let stdin = child.stdin.as_mut().expect("failed to open stdin");
stdin
.write_all(input.as_bytes())
.expect("failed to write batch input");
}
child.wait_with_output().expect("failed to wait for dcg")
}
fn parse_jsonl_output(output: &str) -> Vec<serde_json::Value> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|line| serde_json::from_str(line).expect("failed to parse JSONL line"))
.collect()
}
#[test]
fn test_batch_processes_multiple_commands() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}
{"tool_name":"Bash","tool_input":{"command":"git status"}}
{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}
{"tool_name":"Bash","tool_input":{"command":"ls -la"}}
"#;
let output = run_dcg_batch(input);
assert!(
output.status.success(),
"Batch mode should exit successfully"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 4, "Should have 4 results");
assert_eq!(results[0]["decision"], "deny");
assert_eq!(results[0]["index"], 0);
assert_eq!(results[1]["decision"], "allow");
assert_eq!(results[1]["index"], 1);
assert_eq!(results[2]["decision"], "deny");
assert_eq!(results[2]["index"], 2);
assert!(
results[2]["rule_id"]
.as_str()
.unwrap_or("")
.contains("reset-hard")
);
assert_eq!(results[3]["decision"], "allow");
assert_eq!(results[3]["index"], 3);
}
#[test]
fn test_batch_maintains_order() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo 1"}}
{"tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~5"}}
{"tool_name":"Bash","tool_input":{"command":"echo 2"}}
{"tool_name":"Bash","tool_input":{"command":"rm -rf /home"}}
{"tool_name":"Bash","tool_input":{"command":"echo 3"}}
"#;
let output = run_dcg_batch(input);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 5);
for (i, result) in results.iter().enumerate() {
assert_eq!(
result["index"], i,
"Result at position {i} should have index {i}"
);
}
assert_eq!(results[0]["decision"], "allow"); assert_eq!(results[1]["decision"], "deny"); assert_eq!(results[2]["decision"], "allow"); assert_eq!(results[3]["decision"], "deny"); assert_eq!(results[4]["decision"], "allow"); }
#[test]
fn test_batch_handles_malformed_lines_with_continue() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo before"}}
not valid json at all
{"tool_name":"Bash","tool_input":{"command":"echo after"}}
{"malformed": "missing tool_name and tool_input"}
{"tool_name":"Bash","tool_input":{"command":"echo final"}}
"#;
let output = run_dcg_batch_with_args(input, &["--continue-on-error"]);
assert!(
output.status.success(),
"Batch mode with --continue-on-error should exit successfully"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 5, "Should have 5 results (including errors)");
assert_eq!(results[0]["decision"], "allow");
assert_eq!(results[0]["index"], 0);
assert_eq!(results[1]["decision"], "error");
assert_eq!(results[1]["index"], 1);
assert!(results[1]["error"].is_string());
assert_eq!(results[2]["decision"], "allow");
assert_eq!(results[2]["index"], 2);
assert_eq!(results[3]["decision"], "skip");
assert_eq!(results[3]["index"], 3);
assert_eq!(results[4]["decision"], "allow");
assert_eq!(results[4]["index"], 4);
}
#[test]
fn test_batch_fails_on_malformed_without_continue() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo before"}}
not valid json
{"tool_name":"Bash","tool_input":{"command":"echo after"}}
"#;
let output = run_dcg_batch(input);
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert!(!results.is_empty());
assert_eq!(results[0]["decision"], "allow");
if results.len() > 1 {
assert_eq!(results[1]["decision"], "error");
}
}
#[test]
fn test_batch_handles_empty_commands() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}
{"tool_name":"Bash","tool_input":{"command":" "}}
{"tool_name":"Bash","tool_input":{"command":"echo hello"}}
"#;
let output = run_dcg_batch_with_args(input, &["--continue-on-error"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 3);
assert_eq!(
results[0]["decision"], "skip",
"Empty command should be skipped"
);
assert_eq!(
results[1]["decision"], "allow",
"Whitespace command should be allowed"
);
assert_eq!(results[2]["decision"], "allow");
}
#[test]
fn test_batch_handles_non_bash_tools() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo bash"}}
{"tool_name":"Read","tool_input":{"path":"/etc/passwd"}}
{"tool_name":"Write","tool_input":{"path":"/tmp/test","content":"hello"}}
{"tool_name":"Bash","tool_input":{"command":"echo bash again"}}
"#;
let output = run_dcg_batch_with_args(input, &["--continue-on-error"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 4);
assert_eq!(results[0]["decision"], "allow");
assert_eq!(results[3]["decision"], "allow");
assert_eq!(results[1]["decision"], "skip");
assert_eq!(results[2]["decision"], "skip");
}
#[test]
fn test_batch_includes_rule_metadata_for_denials() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}
{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}
"#;
let output = run_dcg_batch(input);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 2);
assert_eq!(results[0]["decision"], "deny");
assert!(results[0]["rule_id"].is_string());
assert!(results[0]["pack_id"].is_string());
assert!(results[0]["rule_id"].as_str().unwrap().contains("core.git"));
assert_eq!(results[1]["decision"], "deny");
assert!(results[1]["rule_id"].is_string());
assert!(results[1]["pack_id"].is_string());
}
#[test]
fn test_batch_performance_at_scale() {
let mut input = String::new();
for i in 0..100 {
if i % 10 == 0 {
let _ = write!(
input,
r#"{{"tool_name":"Bash","tool_input":{{"command":"git reset --hard HEAD~{i}"}}}}"#
);
} else {
let _ = write!(
input,
r#"{{"tool_name":"Bash","tool_input":{{"command":"echo {i}"}}}}"#
);
}
input.push('\n');
}
let start = std::time::Instant::now();
let output = run_dcg_batch(&input);
let duration = start.elapsed();
assert!(
output.status.success(),
"Batch should complete successfully"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 100, "Should have 100 results");
for (i, result) in results.iter().enumerate() {
assert_eq!(result["index"], i, "Index should match position");
}
assert_eq!(
results.iter().filter(|r| r["decision"] == "deny").count(),
10,
"Should have 10 denials"
);
println!("Batch processing 100 commands took {duration:?}");
assert!(
duration.as_secs() < 30,
"Batch should complete within 30 seconds"
);
}
#[test]
fn test_batch_handles_unicode() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo '你好世界'"}}
{"tool_name":"Bash","tool_input":{"command":"echo 'Привет мир'"}}
{"tool_name":"Bash","tool_input":{"command":"echo '🚀 launch'"}}
"#;
let output = run_dcg_batch(input);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 3);
for result in &results {
assert_eq!(result["decision"], "allow");
}
}
#[test]
fn test_batch_handles_long_commands() {
let long_arg = "x".repeat(10_000);
let input = format!(
r#"{{"tool_name":"Bash","tool_input":{{"command":"echo {long_arg}"}}}}
{{"tool_name":"Bash","tool_input":{{"command":"echo short"}}}}
"#
);
let output = run_dcg_batch_with_args(&input, &["--continue-on-error"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 2);
assert!(
results[0]["decision"] == "allow" || results[0]["decision"] == "error",
"Long command should be handled"
);
assert_eq!(results[1]["decision"], "allow");
}
#[test]
fn test_batch_parallel_maintains_order() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"echo 0"}}
{"tool_name":"Bash","tool_input":{"command":"echo 1"}}
{"tool_name":"Bash","tool_input":{"command":"echo 2"}}
{"tool_name":"Bash","tool_input":{"command":"echo 3"}}
{"tool_name":"Bash","tool_input":{"command":"echo 4"}}
{"tool_name":"Bash","tool_input":{"command":"echo 5"}}
{"tool_name":"Bash","tool_input":{"command":"echo 6"}}
{"tool_name":"Bash","tool_input":{"command":"echo 7"}}
{"tool_name":"Bash","tool_input":{"command":"echo 8"}}
{"tool_name":"Bash","tool_input":{"command":"echo 9"}}
"#;
let output = run_dcg_batch_with_args(input, &["--parallel"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let results = parse_jsonl_output(&stdout);
assert_eq!(results.len(), 10);
for (i, result) in results.iter().enumerate() {
assert_eq!(
result["index"], i,
"Parallel results should maintain input order"
);
}
}