use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use serde_json::json;
use super::helpers::AftProcess;
fn configure_with_backup_disabled(aft: &mut AftProcess, root: &std::path::Path) {
let resp = aft.send(
&json!({
"id": "cfg",
"command": "configure",
"harness": "opencode",
"project_root": root,
"config": [{
"tier": "user",
"source": "test",
"doc": r#"{ "backup": { "enabled": false } }"#,
}],
})
.to_string(),
);
assert_eq!(resp["success"], true, "configure should succeed: {resp:?}");
}
#[test]
fn edit_match_glob_rolls_back_prior_files_when_later_write_fails() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let a = root.join("a.ts");
let b = root.join("b.ts");
let c = root.join("c.ts");
fs::write(&a, "const a = \"OLD\";\n").unwrap();
fs::write(&b, "const b = \"OLD\";\n").unwrap();
fs::write(&c, "const c = \"OLD\";\n").unwrap();
let mut readonly = fs::metadata(&b).unwrap().permissions();
readonly.set_readonly(true);
fs::set_permissions(&b, readonly).unwrap();
let mut aft = AftProcess::spawn();
configure_with_backup_disabled(&mut aft, root);
let req = json!({
"id": "glob-rollback-write-failure",
"command": "edit_match",
"file": format!("{}/*.ts", root.display()),
"match": "OLD",
"replacement": "NEW"
});
let resp = aft.send(&req.to_string());
make_writable(&b);
assert_eq!(resp["success"], false, "glob edit should fail: {resp:?}");
assert_eq!(resp["code"], "write_error");
assert_eq!(fs::read_to_string(&a).unwrap(), "const a = \"OLD\";\n");
assert_eq!(fs::read_to_string(&b).unwrap(), "const b = \"OLD\";\n");
assert_eq!(fs::read_to_string(&c).unwrap(), "const c = \"OLD\";\n");
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_glob_rolls_back_when_any_file_becomes_syntax_invalid() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let a = root.join("a.ts");
let b = root.join("b.ts");
let c = root.join("c.ts");
let original_a = "const a = 1; // TARGET\n";
let original_b = "const b = \"TARGET\";\n";
let original_c = "const c = TARGET;\n";
fs::write(&a, original_a).unwrap();
fs::write(&b, original_b).unwrap();
fs::write(&c, original_c).unwrap();
let mut aft = AftProcess::spawn();
configure_with_backup_disabled(&mut aft, root);
let req = json!({
"id": "glob-rollback-syntax-failure",
"command": "edit_match",
"file": format!("{}/*.ts", root.display()),
"match": "TARGET",
"replacement": "{;"
});
let resp = aft.send(&req.to_string());
assert_eq!(
resp["success"], false,
"glob edit must fail when any file becomes syntax-invalid: {resp:?}"
);
assert_eq!(resp["code"], "syntax_invalid", "wrong error code: {resp:?}");
let msg = resp["message"].as_str().expect("message");
assert!(
msg.contains("rolled back"),
"error message should mention rollback: {msg}"
);
assert_eq!(fs::read_to_string(&a).unwrap(), original_a);
assert_eq!(fs::read_to_string(&b).unwrap(), original_b);
assert_eq!(fs::read_to_string(&c).unwrap(), original_c);
let undo = aft.send(&json!({"id": "undo-after-glob-rollback", "command": "undo"}).to_string());
assert_eq!(
undo["success"], false,
"glob rollback should discard operation undo entries: {undo:?}"
);
assert_eq!(undo["code"], "backups_disabled");
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_glob_succeeds_when_all_files_remain_syntax_valid() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let a = root.join("a.ts");
let b = root.join("b.ts");
fs::write(&a, "const a = TARGET;\n").unwrap();
fs::write(&b, "const b = TARGET;\n").unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "glob-syntax-clean",
"command": "edit_match",
"file": format!("{}/*.ts", root.display()),
"match": "TARGET",
"replacement": "42"
});
let resp = aft.send(&req.to_string());
assert_eq!(resp["success"], true, "expected success: {resp:?}");
let files = resp["files"].as_array().expect("files array");
assert_eq!(files.len(), 2);
for file in files {
assert_eq!(file["syntax_valid"], true, "file should be valid: {file:?}");
}
assert_eq!(fs::read_to_string(&a).unwrap(), "const a = 42;\n");
assert_eq!(fs::read_to_string(&b).unwrap(), "const b = 42;\n");
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_omits_syntax_valid_when_language_unsupported() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("notes.unusual_extension");
fs::write(&file, "hello old world\n").unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "unsupported-syntax-valid",
"command": "edit_match",
"file": file,
"match": "old",
"replacement": "new"
});
let resp = aft.send(&req.to_string());
assert_eq!(resp["success"], true, "expected edit success: {resp:?}");
assert!(
resp.get("syntax_valid").is_none(),
"syntax_valid must be absent when validation could not run: {resp:?}"
);
assert_eq!(
fs::read_to_string(dir.path().join("notes.unusual_extension")).unwrap(),
"hello new world\n"
);
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_fuzzy_preserves_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("greet.js");
fs::write(
&file,
"function greet(name) {\n const message = \"Hello, \" + name; \n console.log(message); \n return message;\n}\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "fuzzy-trailing-newline",
"command": "edit_match",
"file": file,
"match": " const message = \"Hello, \" + name;\n console.log(message);\n return message;",
"replacement": " const msg = \"Hello, \" + name;\n console.log(msg);\n return msg;"
});
let resp = aft.send(&req.to_string());
assert_eq!(resp["success"], true, "expected edit success: {resp:?}");
let after = fs::read_to_string(&file).unwrap();
assert!(
after.contains(" return msg;\n}"),
"trailing newline before closing brace was dropped: {after:?}"
);
assert!(
!after.contains("return msg;}"),
"last replaced line merged with the next line: {after:?}"
);
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_replace_all_rejects_overlapping_fuzzy_matches() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("over.js");
let original = "row \nrow \nrow \n";
fs::write(&file, original).unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "replace-all-overlap",
"command": "edit_match",
"file": file,
"match": "row\nrow",
"replacement": "X\nX",
"replace_all": true
});
let resp = aft.send(&req.to_string());
assert_eq!(
resp["success"], false,
"overlapping replace_all must fail cleanly: {resp:?}"
);
assert_eq!(
resp["code"], "overlapping_edits",
"wrong error code: {resp:?}"
);
assert_eq!(
fs::read_to_string(&file).unwrap(),
original,
"file was modified despite the overlap rejection"
);
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_reflow_replaces_formatter_split() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("split.js");
let original =
"function demo() {\n const value = alpha +\n beta +\n gamma;\n return value;\n}\n";
fs::write(&file, original).unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "reflow-split",
"command": "edit_match",
"file": file,
"match": " const value = alpha + beta + gamma;",
"replacement": " const value = alpha + beta + delta;"
});
let resp = aft.send(&req.to_string());
assert_eq!(resp["success"], true, "expected edit success: {resp:?}");
assert_eq!(
fs::read_to_string(dir.path().join("split.js")).unwrap(),
"function demo() {\n const value = alpha + beta + delta;\n return value;\n}\n"
);
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_reflow_replaces_formatter_join() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("join.js");
let original = "function demo() {\n const value = alpha + beta + gamma;\n}\n";
fs::write(&file, original).unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "reflow-join",
"command": "edit_match",
"file": file,
"match": " const value = alpha +\n beta +\n gamma;",
"replacement": " const value = alpha +\n beta +\n delta;"
});
let resp = aft.send(&req.to_string());
assert_eq!(resp["success"], true, "expected edit success: {resp:?}");
assert_eq!(
fs::read_to_string(dir.path().join("join.js")).unwrap(),
"function demo() {\n const value = alpha +\n beta +\n delta;\n}\n"
);
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_reflow_ambiguous_does_not_edit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("ambiguous.js");
let original =
"const value = alpha +\n beta +\n gamma;\n\nconst value = alpha +\n beta +\n gamma;\n";
fs::write(&file, original).unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "reflow-ambiguous",
"command": "edit_match",
"file": file,
"match": "const value = alpha + beta + gamma;",
"replacement": "const value = alpha + beta + delta;"
});
let resp = aft.send(&req.to_string());
assert_eq!(
resp["success"], false,
"ambiguous edit should fail: {resp:?}"
);
assert_eq!(
resp["code"], "ambiguous_match",
"wrong error code: {resp:?}"
);
assert_eq!(
fs::read_to_string(dir.path().join("ambiguous.js")).unwrap(),
original
);
let status = aft.shutdown();
assert!(status.success());
}
#[test]
fn edit_match_reflow_near_miss_does_not_edit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("near_miss.js");
let original = "const value = alpha +\n beta +\n gamma;\n";
fs::write(&file, original).unwrap();
let mut aft = AftProcess::spawn();
let req = json!({
"id": "reflow-near-miss",
"command": "edit_match",
"file": file,
"match": "const value = alpha + beta + delta;",
"replacement": "const value = alpha + beta + epsilon;"
});
let resp = aft.send(&req.to_string());
assert_eq!(resp["success"], false, "near miss should fail: {resp:?}");
assert_eq!(
resp["code"], "match_not_found",
"wrong error code: {resp:?}"
);
assert_eq!(
fs::read_to_string(dir.path().join("near_miss.js")).unwrap(),
original
);
let status = aft.shutdown();
assert!(status.success());
}
#[cfg(unix)]
fn make_writable(path: &std::path::Path) {
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o644);
fs::set_permissions(path, permissions).unwrap();
}
#[cfg(windows)]
fn make_writable(path: &std::path::Path) {
let mut permissions = fs::metadata(path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
fs::set_permissions(path, permissions).unwrap();
}