use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
fn scratch(tag: &str) -> PathBuf {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target/test-tmp/it")
.join(tag);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn code(out: &Output) -> i32 {
out.status.code().expect("child exited via a signal")
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn ct_each() -> Command {
Command::new(env!("CARGO_BIN_EXE_ct-each"))
}
fn ct_test() -> Command {
Command::new(env!("CARGO_BIN_EXE_ct-test"))
}
#[test]
fn ct_each_dispatches_per_item_and_aggregates() {
let dir = scratch("ct-each-sweep");
let file = dir.join("haystack.txt");
std::fs::write(&file, "alpha lives here\n").unwrap();
let sweep = |expect: &str| -> Output {
ct_each()
.args(["--items", "alpha", "beta", "--expect", expect, "--quiet"])
.args(["--emit", "{OK}/{TOTAL} -> {RESULT}"])
.args(["--", "grep", "-q", "{ITEM}", file.to_str().unwrap()])
.output()
.unwrap()
};
let all = sweep("all");
assert_eq!(code(&all), 1, "one miss must fail --expect all");
assert!(stdout(&all).contains("1/2 -> ERROR"), "got {:?}", stdout(&all));
assert!(
stderr(&all).contains("'beta' -> ERROR"),
"the red item is named: {:?}",
stderr(&all)
);
let one = sweep("1");
assert_eq!(code(&one), 0, "one success satisfies --expect 1");
assert!(stdout(&one).contains("1/2 -> SUCCESS"));
let json = ct_each()
.args(["--items", "alpha", "beta", "--json"])
.args(["--", "grep", "-q", "{ITEM}", file.to_str().unwrap()])
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_str(stdout(&json).trim()).unwrap();
assert_eq!(v["tool"], "ct-each");
assert_eq!(v["verdict"], "ERROR");
assert_eq!(v["ok"], 1);
assert_eq!(v["items"][0]["result"], "SUCCESS");
assert_eq!(v["items"][1]["result"], "ERROR");
}
#[test]
fn ct_each_substitutes_item_and_index_in_argv_without_a_shell() {
let out = ct_each()
.args(["--items", "a; rm -rf /", "plain", "--quiet"])
.args(["--emit-each", "[{INDEX}] {STDOUT}"])
.args(["--", "echo", "got:{ITEM}"])
.output()
.unwrap();
assert_eq!(code(&out), 0, "stderr: {:?}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("[1] got:a; rm -rf /"), "got {text:?}");
assert!(text.contains("[2] got:plain"), "got {text:?}");
}
#[test]
fn ct_each_reads_items_from_stdin() {
use std::io::Write;
let mut child = ct_each()
.args(["--stdin", "--quiet", "--emit", "{OK}/{TOTAL}"])
.args(["--", "true"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"one\n\ntwo\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(code(&out), 0);
assert!(stdout(&out).contains("2/2"), "blank line skipped: {:?}", stdout(&out));
}
#[test]
fn ct_each_dry_run_previews_and_runs_nothing() {
let dir = scratch("ct-each-dry");
let file = dir.join("untouched.txt");
std::fs::write(&file, "old\n").unwrap();
let out = ct_each()
.args(["--items", "x", "--dry-run", "--mutating"])
.args([
"--",
"ct-edit",
"--base",
file.to_str().unwrap(),
"--find",
"old",
"--replace",
"{ITEM}",
])
.output()
.unwrap();
assert_eq!(code(&out), 0);
assert!(stdout(&out).contains("would run: ct-edit"), "got {:?}", stdout(&out));
assert_eq!(std::fs::read_to_string(&file).unwrap(), "old\n", "dry-run must not run");
}
#[test]
fn ct_each_gate_is_fixed_with_mutating_opt_in_for_suite_tools_only() {
let denied = ct_each()
.args(["--items", "x", "--", "rm", "{ITEM}"])
.output()
.unwrap();
assert_eq!(code(&denied), 2, "rm must be refused");
assert!(stderr(&denied).contains("not an allowed dispatch target"));
assert!(stderr(&denied).contains("immutable"));
let still_denied = ct_each()
.args(["--items", "x", "--mutating", "--", "rm", "{ITEM}"])
.output()
.unwrap();
assert_eq!(code(&still_denied), 2, "--mutating must not unlock externals");
let edit_denied = ct_each()
.args(["--items", "x", "--", "ct-edit", "--find", "a", "--replace", "b"])
.output()
.unwrap();
assert_eq!(code(&edit_denied), 2, "ct-edit without --mutating is refused");
let dir = scratch("ct-each-mutating");
let file = dir.join("subject.txt");
std::fs::write(&file, "alpha beta\n").unwrap();
let edit_ok = ct_each()
.args(["--items", "alpha", "--mutating", "--quiet"])
.args([
"--",
"ct-edit",
"--base",
file.to_str().unwrap(),
"--find",
"{ITEM}",
"--replace",
"renamed_{ITEM}",
"--expect",
"=1",
])
.output()
.unwrap();
assert_eq!(code(&edit_ok), 0, "stderr: {:?}", stderr(&edit_ok));
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"renamed_alpha beta\n"
);
}
#[test]
fn ct_test_timeout_is_a_decisive_error_verdict() {
let dir = scratch("ct-test-timeout");
let file = dir.join("forever.txt");
std::fs::write(&file, "line\n").unwrap();
let started = Instant::now();
let out = ct_test()
.args(["--quiet", "--timeout", "0.3", "--ok-match", "line"])
.args(["--emit", "{RESULT} code={CODE}"])
.args(["--cmd", "tail", "--", "-f", file.to_str().unwrap()])
.output()
.unwrap();
assert!(
started.elapsed() < Duration::from_secs(10),
"timeout must bound the run"
);
assert_eq!(code(&out), 1, "timeout => verdict ERROR");
assert!(stdout(&out).contains("ERROR code=timeout"), "got {:?}", stdout(&out));
assert!(
stderr(&out).contains("timed out after 0.3s"),
"reason names the bound: {:?}",
stderr(&out)
);
}
#[test]
fn ct_each_timeout_marks_the_item_not_the_tool() {
let dir = scratch("ct-each-timeout");
let file = dir.join("forever.txt");
std::fs::write(&file, "line\n").unwrap();
let out = ct_each()
.args(["--items", "x", "--timeout", "0.3", "--quiet", "--json"])
.args(["--", "tail", "-f", file.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(code(&out), 1, "timed-out item fails --expect all");
let v: serde_json::Value = serde_json::from_str(stdout(&out).trim()).unwrap();
assert_eq!(v["items"][0]["code"], "timeout");
assert_eq!(v["items"][0]["result"], "ERROR");
}
#[test]
fn ct_test_capture_tail_bounds_emit_tokens_but_not_matchers() {
let out = ct_test()
.args(["--quiet", "--cmd", "cat", "--capture-tail", "2"])
.args(["--stdin", "first\nsecond\nthird\nfourth\n"])
.args(["--ok-match", "first"]) .args(["--emit", "{RESULT}|{STDOUT}"])
.output()
.unwrap();
assert_eq!(code(&out), 0, "stderr: {:?}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("SUCCESS|"), "matcher saw full output: {text:?}");
assert!(text.contains("2 earlier line(s) omitted"), "got {text:?}");
assert!(text.contains("third\nfourth"), "got {text:?}");
assert!(!text.contains("|first"), "token must be truncated: {text:?}");
}
#[test]
fn heartbeat_pulses_while_a_child_runs() {
let dir = scratch("heartbeat");
let file = dir.join("forever.txt");
std::fs::write(&file, "line\n").unwrap();
let out = ct_test()
.args(["--quiet", "--timeout", "0.7", "--heartbeat", "0.2"])
.args(["--cmd", "tail", "--", "-f", file.to_str().unwrap()])
.output()
.unwrap();
assert!(stderr(&out).contains("[0s]"), "default pulse: {:?}", stderr(&out));
let out = ct_each()
.args(["--items", "thing", "--timeout", "0.7", "--quiet"])
.args(["--heartbeat", "0.2", "--heartbeat-to", "stdout"])
.args(["--heartbeat-emit", "tick {ELAPSED}s {TOOL} {ITEM} {DONE}/{TOTAL}"])
.args(["--", "tail", "-f", file.to_str().unwrap()])
.output()
.unwrap();
assert!(
stdout(&out).contains("tick 0s ct-each thing 0/1"),
"custom pulse on stdout: {:?}",
stdout(&out)
);
}
#[test]
fn there_is_no_shell_mode_anywhere() {
let test_shell = ct_test().args(["--shell", "--cmd", "echo hi"]).output().unwrap();
assert_eq!(code(&test_shell), 2, "--shell must not exist on ct-test");
let each_shell = ct_each()
.args(["--items", "x", "--shell", "--", "echo", "{ITEM}"])
.output()
.unwrap();
assert_eq!(code(&each_shell), 2, "--shell must not exist on ct-each");
let sh_denied = ct_test().args(["--cmd", "sh", "--", "-c", "true"]).output().unwrap();
assert_eq!(code(&sh_denied), 2, "sh is never runnable");
assert!(stderr(&sh_denied).contains("no shell mode"));
}
#[test]
fn self_bounded_tools_accept_timeout_and_finish_cleanly_under_it() {
let dir = scratch("watchdog-clean");
std::fs::write(dir.join("a.txt"), "needle\n").unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_ct-search"))
.args(["--base", dir.to_str().unwrap()])
.args(["--type", "f", "--grep", "needle", "--timeout", "30", "--quiet"])
.output()
.unwrap();
assert_eq!(code(&out), 0, "stderr: {:?}", stderr(&out));
let bad = Command::new(env!("CARGO_BIN_EXE_ct-view"))
.args(["--timeout", "0", "Cargo.toml"])
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output()
.unwrap();
assert_eq!(code(&bad), 2);
assert!(stderr(&bad).contains("positive number of seconds"));
}