use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn scratch(tag: &str) -> PathBuf {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target/test-tmp/blocks")
.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 tool(name: &str) -> Command {
let exe = match name {
"ct-search" => env!("CARGO_BIN_EXE_ct-search"),
"ct-view" => env!("CARGO_BIN_EXE_ct-view"),
"ct-edit" => env!("CARGO_BIN_EXE_ct-edit"),
"ct-patch" => env!("CARGO_BIN_EXE_ct-patch"),
"ct-test" => env!("CARGO_BIN_EXE_ct-test"),
"ct-each" => env!("CARGO_BIN_EXE_ct-each"),
other => panic!("unknown tool {other}"),
};
Command::new(exe)
}
const SAMPLE: &str = "enum Value {\n U64(u64),\n}\n\nfn show(v: &Value) -> String {\n match v {\n Value::U64(v) => v.to_string(),\n }\n}\n";
#[test]
fn schemes_resolve_file_text_and_leave_other_prefixes_alone() {
let dir = scratch("schemes");
std::fs::write(dir.join("hay.txt"), "a std::fmt line\nfile:not-a-read\n").unwrap();
std::fs::write(dir.join("pat.block"), "std::fmt\n").unwrap();
let out = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--grep",
&format!("file:{}", dir.join("pat.block").display()),
"--quiet",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
let out = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--grep",
"text:file:not-a-read",
"--quiet",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
let out = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--grep",
"std::fmt",
"--quiet",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
let out = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--grep",
"file:/no/such/payload",
"--quiet",
])
.output()
.unwrap();
assert_eq!(code(&out), 2, "{}", stderr(&out));
}
#[test]
fn mode_literal_matches_verbatim_code_that_promotion_would_break() {
let dir = scratch("mode");
std::fs::write(dir.join("a.rs"), " todo!(\"wire this\");\n").unwrap();
let promoted = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--grep",
"todo!(\"wire this\")",
"--quiet",
])
.output()
.unwrap();
assert_eq!(code(&promoted), 1, "promotion should miss: {}", stderr(&promoted));
let literal = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--grep",
"todo!(\"wire this\")",
"--mode",
"literal",
"--quiet",
])
.output()
.unwrap();
assert_eq!(code(&literal), 0, "{}", stderr(&literal));
}
#[test]
fn block_grep_counts_occurrences_and_reports_nearest_miss_on_detail() {
let dir = scratch("block-grep");
std::fs::write(dir.join("ast.rs"), SAMPLE).unwrap();
std::fs::write(dir.join("hit.block"), " match v {\n Value::U64(v) => v.to_string(),\n").unwrap();
std::fs::write(dir.join("miss.block"), " match v {\n Value::F64(v) => v.to_string(),\n").unwrap();
let hit = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--grep",
&format!("file:{}", dir.join("hit.block").display()),
"--detail",
])
.output()
.unwrap();
assert_eq!(code(&hit), 0, "{}", stderr(&hit));
assert!(stdout(&hit).contains("ast.rs:6:"), "block start line: {}", stdout(&hit));
let miss = tool("ct-search")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--grep",
&format!("file:{}", dir.join("miss.block").display()),
"--detail",
])
.output()
.unwrap();
assert_eq!(code(&miss), 1);
let diag = stderr(&miss);
assert!(diag.contains("nearest miss"), "{diag}");
assert!(diag.contains("diverges at its line 2"), "{diag}");
assert!(diag.contains("Value::U64(v) => v.to_string(),"), "{diag}");
}
#[test]
fn block_view_shows_the_region_and_misses_cleanly() {
let dir = scratch("block-view");
let file = dir.join("ast.rs");
std::fs::write(&file, SAMPLE).unwrap();
std::fs::write(dir.join("b.block"), "enum Value {\n U64(u64),\n}\n").unwrap();
let out = tool("ct-view")
.args([
file.to_str().unwrap(),
"--match",
&format!("file:{}", dir.join("b.block").display()),
"--context",
"0",
"--plain",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert_eq!(stdout(&out), "enum Value {\n U64(u64),\n}\n");
std::fs::write(dir.join("no.block"), "enum Value {\n I64(u64),\n}\n").unwrap();
let out = tool("ct-view")
.args([
file.to_str().unwrap(),
"--match",
&format!("file:{}", dir.join("no.block").display()),
])
.output()
.unwrap();
assert_eq!(code(&out), 1);
assert!(stderr(&out).contains("nearest miss"), "{}", stderr(&out));
}
#[test]
fn argv_block_edit_replaces_and_empty_replacement_deletes() {
let dir = scratch("block-edit");
let file = dir.join("ast.rs");
std::fs::write(&file, SAMPLE).unwrap();
std::fs::write(dir.join("find.block"), "enum Value {\n U64(u64),\n}\n").unwrap();
std::fs::write(
dir.join("repl.block"),
"enum Value {\n U64(u64),\n I64(i64),\n}\n",
)
.unwrap();
let out = tool("ct-edit")
.args([
"--base",
file.to_str().unwrap(),
"--find",
&format!("file:{}", dir.join("find.block").display()),
"--replace",
&format!("file:{}", dir.join("repl.block").display()),
"--expect",
"=1",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
let now = std::fs::read_to_string(&file).unwrap();
assert!(now.contains(" I64(i64),\n}"), "{now}");
std::fs::write(dir.join("kill.block"), " U64(u64),\n I64(i64),\n").unwrap();
std::fs::write(dir.join("empty.block"), "").unwrap();
let out = tool("ct-edit")
.args([
"--base",
file.to_str().unwrap(),
"--find",
&format!("file:{}", dir.join("kill.block").display()),
"--replace",
&format!("file:{}", dir.join("empty.block").display()),
"--expect",
"=1",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
SAMPLE.replace(" U64(u64),\n", "")
);
}
fn write_script(dir: &Path, second_find: &str) -> PathBuf {
let script = dir.join("edits.ctb");
std::fs::write(
&script,
format!(
"#% edit expect=\"=1\"\n#% find\n U64(u64),\n#% replace\n U64(u64),\n I64(i64),\n#% edit expect=\"=1\"\n#% find\n{second_find}\n#% replace\n Value::U64(v) => v.to_string(),\n Value::I64(v) => v.to_string(),\n#% end\n"
),
)
.unwrap();
script
}
#[test]
fn script_batch_applies_atomically_and_dry_run_writes_nothing() {
let dir = scratch("script-ok");
let file = dir.join("ast.rs");
std::fs::write(&file, SAMPLE).unwrap();
let script = write_script(&dir, " Value::U64(v) => v.to_string(),");
let dry = tool("ct-edit")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--script",
script.to_str().unwrap(),
"--dry-run",
])
.output()
.unwrap();
assert_eq!(code(&dry), 0, "{}", stderr(&dry));
assert!(stdout(&dry).contains("dry-run, not written"));
assert_eq!(std::fs::read_to_string(&file).unwrap(), SAMPLE);
let apply = tool("ct-edit")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--script",
script.to_str().unwrap(),
"--json",
])
.output()
.unwrap();
assert_eq!(code(&apply), 0, "{}", stderr(&apply));
let v: serde_json::Value = serde_json::from_str(&stdout(&apply)).unwrap();
assert_eq!(v["verdict"], "SUCCESS");
assert_eq!(v["applied"], true);
assert_eq!(v["edits"].as_array().unwrap().len(), 2);
assert_eq!(v["edits"][0]["expect"], "=1");
let now = std::fs::read_to_string(&file).unwrap();
assert!(now.contains(" I64(i64),"), "{now}");
assert!(now.contains("Value::I64(v) => v.to_string(),"), "{now}");
}
#[test]
fn script_failure_means_zero_writes_and_a_nearest_miss() {
let dir = scratch("script-fail");
let file = dir.join("ast.rs");
std::fs::write(&file, SAMPLE).unwrap();
let script = dir.join("bad.ctb");
std::fs::write(
&script,
"#% edit\n#% find\n U64(u64),\n#% replace\n U64(u64),\n I64(i64),\n#% edit\n#% find\n match v {\n Value::F64(v) => v.to_string(),\n#% replace\nx\n#% end\n",
)
.unwrap();
let out = tool("ct-edit")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--script",
script.to_str().unwrap(),
"--json",
])
.output()
.unwrap();
assert_eq!(code(&out), 1, "{}", stderr(&out));
let v: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(v["verdict"], "ERROR");
assert_eq!(v["applied"], false);
assert_eq!(v["edits"][0]["verdict"], "SUCCESS");
assert_eq!(v["edits"][1]["verdict"], "ERROR");
let miss = &v["edits"][1]["nearest_miss"];
assert_eq!(miss["first_diverging_line"], 2, "{miss}");
assert_eq!(std::fs::read_to_string(&file).unwrap(), SAMPLE);
}
#[test]
fn script_write_preflight_refuses_a_readonly_target_with_zero_writes() {
let dir = scratch("script-preflight");
let ok = dir.join("a.rs");
let ro = dir.join("b.rs");
std::fs::write(&ok, "alpha()\n").unwrap();
std::fs::write(&ro, "beta()\n").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&ro, perms.clone()).unwrap();
let script = dir.join("two.ctb");
std::fs::write(
&script,
"#% edit file=a.rs\n#% find\nalpha()\n#% replace\nALPHA()\n#% edit file=b.rs\n#% find\nbeta()\n#% replace\nBETA()\n#% end\n",
)
.unwrap();
let out = tool("ct-edit")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--script",
script.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(code(&out), 2, "{}", stderr(&out));
assert!(stderr(&out).contains("pre-flight"), "{}", stderr(&out));
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "alpha()\n");
assert_eq!(std::fs::read_to_string(&ro).unwrap(), "beta()\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o644)).unwrap();
}
}
#[test]
fn cascade_lets_later_edits_see_earlier_output_and_no_cascade_rejects_overlap() {
let dir = scratch("script-cascade");
let file = dir.join("c.rs");
std::fs::write(&file, "base()\n").unwrap();
let script = dir.join("chain.ctb");
std::fs::write(
&script,
"#% edit\n#% find\nbase()\n#% replace\nbase()\nadded()\n#% edit\n#% find\nadded()\n#% replace\nadded(1)\n#% end\n",
)
.unwrap();
let out = tool("ct-edit")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--script",
script.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert_eq!(std::fs::read_to_string(&file).unwrap(), "base()\nadded(1)\n");
std::fs::write(&file, "base()\n").unwrap();
let out = tool("ct-edit")
.args([
"--base",
dir.to_str().unwrap(),
"--name",
"*.rs",
"--script",
script.to_str().unwrap(),
"--no-cascade",
])
.output()
.unwrap();
assert_eq!(code(&out), 1, "{}", stderr(&out));
assert_eq!(std::fs::read_to_string(&file).unwrap(), "base()\n");
std::fs::write(&file, "a\nb\nc\n").unwrap();
let overlap = dir.join("overlap.ctb");
std::fs::write(
&overlap,
"#% edit\n#% find\na\nb\n#% replace\nA\n#% edit\n#% find\nb\nc\n#% replace\nC\n#% end\n",
)
.unwrap();
let out = tool("ct-edit")
.args([
"--base",
file.to_str().unwrap(),
"--script",
overlap.to_str().unwrap(),
"--no-cascade",
])
.output()
.unwrap();
assert_eq!(code(&out), 2, "{}", stderr(&out));
assert!(stderr(&out).contains("overlap"), "{}", stderr(&out));
assert_eq!(std::fs::read_to_string(&file).unwrap(), "a\nb\nc\n");
}
#[test]
fn patch_file_value_is_a_verbatim_string_and_each_expands_file_items() {
let dir = scratch("patch-each");
let cfg = dir.join("cfg.json");
std::fs::write(&cfg, "{\n \"notes\": \"old\"\n}\n").unwrap();
std::fs::write(dir.join("notes.txt"), "[1,2]\nline two\n").unwrap();
let out = tool("ct-patch")
.args([
"--base",
cfg.to_str().unwrap(),
"--set",
&format!("notes=file:{}", dir.join("notes.txt").display()),
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
assert_eq!(v["notes"], "[1,2]\nline two\n");
std::fs::write(dir.join("items.txt"), "alpha\n\nbeta\n").unwrap();
let out = tool("ct-each")
.args([
"--items",
&format!("file:{}", dir.join("items.txt").display()),
"--quiet",
"--emit",
"{TOTAL} item(s)",
"--",
"true",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert!(stdout(&out).contains("2 item(s)"), "{}", stdout(&out));
}
#[test]
fn ct_test_stdin_accepts_a_file_payload() {
let dir = scratch("test-stdin");
std::fs::write(dir.join("input.txt"), "first\nMARKER here\nlast\n").unwrap();
let out = tool("ct-test")
.args([
"--cmd",
"cat",
"--stdin",
&format!("file:{}", dir.join("input.txt").display()),
"--ok-match",
"MARKER",
"--quiet",
"--emit",
"{RESULT}",
])
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert!(stdout(&out).contains("SUCCESS"), "{}", stdout(&out));
}