use super::*;
use crate::core::anchor::line_hash;
use std::io::Write;
use tempfile::NamedTempFile;
fn make_temp(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
fn make_temp_rs(content: &str) -> NamedTempFile {
let mut f = tempfile::Builder::new().suffix(".rs").tempfile().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
fn params(path: &std::path::Path, ops: Vec<AnchorOp>) -> PatchParams {
PatchParams {
path: path.to_string_lossy().to_string(),
ops,
expected_md5: None,
backup: false,
backup_path: None,
evidence: false,
diff_max_lines: 200,
allow_lossy_utf8: false,
validate_syntax: true,
}
}
#[test]
fn set_line_applies_and_invalidates() {
let f = make_temp("fn main() {\n let x = 1;\n}\n");
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 2,
hash: line_hash(" let x = 1;"),
new_text: " let x = 2;".to_string(),
}],
),
"",
);
assert!(text.contains('✓'), "expected success: {text}");
assert!(matches!(effect, CacheEffect::Invalidate));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"fn main() {\n let x = 2;\n}\n"
);
}
#[test]
fn bom_file_line_one_edit_matches_read_side_hash_and_keeps_bom() {
let f = make_temp("\u{feff}hello\nworld\n");
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 1,
hash: line_hash("hello"),
new_text: "HELLO".to_string(),
}],
),
"",
);
assert!(text.contains('✓'), "expected success: {text}");
assert!(matches!(effect, CacheEffect::Invalidate));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"\u{feff}HELLO\nworld\n",
"BOM must survive the edit"
);
}
#[test]
fn stale_anchor_rejects_without_writing() {
let original = "alpha\nbeta\ngamma\n";
let f = make_temp(original);
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 2,
hash: "dead".to_string(), new_text: "BETA".to_string(),
}],
),
"",
);
assert!(text.starts_with("CONFLICT:"), "expected conflict: {text}");
assert!(text.contains("fresh anchors:"));
assert!(
text.contains(&format!("2:{}|beta", line_hash("beta"))),
"fresh anchor for the drifted line must be returned: {text}"
);
assert!(matches!(effect, CacheEffect::None));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
original,
"a stale anchor must never write"
);
}
#[test]
fn delete_line_via_empty_new_text() {
let f = make_temp("keep1\ndrop\nkeep2\n");
let (text, _) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 2,
hash: line_hash("drop"),
new_text: String::new(),
}],
),
"",
);
assert!(text.contains('✓'), "{text}");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "keep1\nkeep2\n");
}
#[test]
fn insert_after_top_prepends() {
let f = make_temp("first\nsecond\n");
let (text, _) = run_io(
¶ms(
f.path(),
vec![AnchorOp::InsertAfter {
line: 0,
hash: None,
new_text: "// header".to_string(),
}],
),
"",
);
assert!(text.contains('✓'), "{text}");
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"// header\nfirst\nsecond\n"
);
}
#[test]
fn replace_lines_range() {
let f = make_temp("a\nb\nc\nd\n");
let (text, _) = run_io(
¶ms(
f.path(),
vec![AnchorOp::ReplaceLines {
start_line: 2,
start_hash: line_hash("b"),
end_line: 3,
end_hash: line_hash("c"),
new_text: "X\nY".to_string(),
}],
),
"",
);
assert!(text.contains('✓'), "{text}");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "a\nX\nY\nd\n");
}
#[test]
fn batch_atomic_all_or_nothing_on_one_stale_anchor() {
let original = "one\ntwo\nthree\n";
let f = make_temp(original);
let (text, effect) = run_io(
¶ms(
f.path(),
vec![
AnchorOp::SetLine {
line: 1,
hash: line_hash("one"),
new_text: "ONE".to_string(),
},
AnchorOp::SetLine {
line: 3,
hash: "badd".to_string(), new_text: "THREE".to_string(),
},
],
),
"",
);
assert!(text.starts_with("CONFLICT:"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
original,
"a single stale anchor must abort the entire batch"
);
}
#[test]
fn batch_atomic_applies_all_valid_edits_bottom_up() {
let f = make_temp("1\n2\n3\n4\n5\n");
let (text, _) = run_io(
¶ms(
f.path(),
vec![
AnchorOp::ReplaceLines {
start_line: 1,
start_hash: line_hash("1"),
end_line: 2,
end_hash: line_hash("2"),
new_text: "A".to_string(),
},
AnchorOp::SetLine {
line: 5,
hash: line_hash("5"),
new_text: "E".to_string(),
},
],
),
"",
);
assert!(text.contains('✓'), "{text}");
assert!(text.contains("2 anchored edits"), "{text}");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "A\n3\n4\nE\n");
}
#[test]
fn batch_application_is_byte_deterministic() {
let original = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
let make_ops = || {
vec![
AnchorOp::SetLine {
line: 1,
hash: line_hash("fn a() {}"),
new_text: "fn a() { /* x */ }".to_string(),
},
AnchorOp::ReplaceLines {
start_line: 3,
start_hash: line_hash("fn c() {}"),
end_line: 4,
end_hash: line_hash("fn d() {}"),
new_text: "fn cd() {}".to_string(),
},
]
};
let f1 = make_temp(original);
let f2 = make_temp(original);
let (t1, _) = run_io(¶ms(f1.path(), make_ops()), "");
let (t2, _) = run_io(¶ms(f2.path(), make_ops()), "");
assert!(t1.contains('✓') && t2.contains('✓'));
let out1 = std::fs::read(f1.path()).unwrap();
let out2 = std::fs::read(f2.path()).unwrap();
assert_eq!(out1, out2, "identical batch must yield identical bytes");
assert_eq!(
String::from_utf8(out1).unwrap(),
"fn a() { /* x */ }\nfn b() {}\nfn cd() {}\n"
);
}
#[test]
fn overlapping_batch_is_rejected() {
let f = make_temp("a\nb\nc\n");
let (text, effect) = run_io(
¶ms(
f.path(),
vec![
AnchorOp::SetLine {
line: 2,
hash: line_hash("b"),
new_text: "B".to_string(),
},
AnchorOp::ReplaceLines {
start_line: 1,
start_hash: line_hash("a"),
end_line: 2,
end_hash: line_hash("b"),
new_text: "X".to_string(),
},
],
),
"",
);
assert!(text.contains("overlapping"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "a\nb\nc\n");
}
#[test]
fn expected_md5_guard_blocks_mismatch() {
let f = make_temp("aaa\n");
let mut p = params(
f.path(),
vec![AnchorOp::SetLine {
line: 1,
hash: line_hash("aaa"),
new_text: "bbb".to_string(),
}],
);
p.expected_md5 = Some("deadbeef".to_string());
let (text, effect) = run_io(&p, "");
assert!(text.contains("preimage mismatch"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "aaa\n");
}
#[test]
fn crlf_file_keeps_crlf() {
let f = make_temp("a\r\nb\r\nc\r\n");
let (text, _) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 2,
hash: line_hash("b"),
new_text: "B".to_string(),
}],
),
"",
);
assert!(text.contains('✓'), "{text}");
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"a\r\nB\r\nc\r\n",
"CRLF endings must be preserved"
);
}
#[test]
fn no_change_edit_is_rejected() {
let f = make_temp("same\n");
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 1,
hash: line_hash("same"),
new_text: "same".to_string(),
}],
),
"",
);
assert!(text.contains("no change"), "{text}");
assert!(matches!(effect, CacheEffect::None));
}
#[test]
fn backup_is_written_when_enabled() {
let f = make_temp("orig\n");
let mut p = params(
f.path(),
vec![AnchorOp::SetLine {
line: 1,
hash: line_hash("orig"),
new_text: "new".to_string(),
}],
);
p.backup = true;
let (text, _) = run_io(&p, "");
let bp = text
.lines()
.find_map(|l| l.strip_prefix("backup: "))
.expect("backup line");
assert_eq!(std::fs::read_to_string(bp).unwrap(), "orig\n");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "new\n");
}
#[test]
fn evidence_diff_emitted_when_enabled() {
let f = make_temp("line1\nline2\n");
let mut p = params(
f.path(),
vec![AnchorOp::SetLine {
line: 2,
hash: line_hash("line2"),
new_text: "changed2".to_string(),
}],
);
p.evidence = true;
let (text, _) = run_io(&p, "");
assert!(text.contains("```diff"), "{text}");
assert!(text.contains("postimage:"), "{text}");
}
#[test]
fn handle_applies_cache_effect() {
let f = make_temp("v1\n");
let mut cache = SessionCache::new();
cache.store(&f.path().to_string_lossy(), "v1\n");
let out = handle(
&mut cache,
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 1,
hash: line_hash("v1"),
new_text: "v2".to_string(),
}],
),
);
assert!(out.contains('✓'), "{out}");
assert!(
cache.get(&f.path().to_string_lossy()).is_none(),
"a successful patch must invalidate the cache entry"
);
}
#[test]
fn missing_file_reports_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.rs");
let (text, effect) = run_io(
¶ms(
&missing,
vec![AnchorOp::SetLine {
line: 1,
hash: "aa".to_string(),
new_text: "x".to_string(),
}],
),
"",
);
assert!(text.contains("ERROR"), "{text}");
assert!(matches!(effect, CacheEffect::None));
}
#[cfg(feature = "tree-sitter")]
#[test]
fn syntax_gate_rejects_edit_that_breaks_a_clean_file() {
let original = "fn main() {\n let x = 1;\n}\n";
let f = make_temp_rs(original);
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 3,
hash: line_hash("}"),
new_text: String::new(),
}],
),
"",
);
assert!(text.contains("syntax error"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
original,
"a syntax-breaking edit must not write"
);
}
#[cfg(feature = "tree-sitter")]
#[test]
fn validate_syntax_false_overrides_the_gate() {
let original = "fn main() {\n let x = 1;\n}\n";
let f = make_temp_rs(original);
let mut p = params(
f.path(),
vec![AnchorOp::SetLine {
line: 3,
hash: line_hash("}"),
new_text: String::new(),
}],
);
p.validate_syntax = false;
let (text, effect) = run_io(&p, "");
assert!(text.contains('✓'), "override must allow the write: {text}");
assert!(matches!(effect, CacheEffect::Invalidate));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"fn main() {\n let x = 1;\n"
);
}
#[cfg(feature = "tree-sitter")]
#[test]
fn syntax_gate_allows_valid_edit() {
let f = make_temp_rs("fn main() {\n let x = 1;\n}\n");
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::SetLine {
line: 2,
hash: line_hash(" let x = 1;"),
new_text: " let x = 2;".to_string(),
}],
),
"",
);
assert!(text.contains('✓'), "{text}");
assert!(matches!(effect, CacheEffect::Invalidate));
}
#[test]
fn create_writes_new_file_and_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("nested/sub/new_file.rs");
let (text, effect) = run_io(
¶ms(
&target,
vec![AnchorOp::Create {
new_text: "fn hello() {}\n".to_string(),
}],
),
"",
);
assert!(text.contains("✓ created"), "{text}");
assert!(text.contains("postimage:"), "{text}");
assert!(matches!(effect, CacheEffect::Invalidate));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "fn hello() {}\n");
}
#[test]
fn create_rejects_existing_file() {
let f = make_temp("already here\n");
let (text, effect) = run_io(
¶ms(
f.path(),
vec![AnchorOp::Create {
new_text: "overwrite attempt".to_string(),
}],
),
"",
);
assert!(text.contains("already exists"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"already here\n",
"create must never overwrite"
);
}
#[test]
fn create_cannot_be_batched_with_anchored_ops() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("mixed.rs");
let (text, effect) = run_io(
¶ms(
&target,
vec![
AnchorOp::Create {
new_text: "content".to_string(),
},
AnchorOp::SetLine {
line: 1,
hash: "aaaa".to_string(),
new_text: "x".to_string(),
},
],
),
"",
);
assert!(text.contains("cannot be batched"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert!(!target.exists(), "no partial write on a rejected batch");
}
#[test]
fn create_empty_content_makes_empty_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("empty.txt");
let (text, effect) = run_io(
¶ms(
&target,
vec![AnchorOp::Create {
new_text: String::new(),
}],
),
"",
);
assert!(text.contains("✓ created"), "{text}");
assert!(matches!(effect, CacheEffect::Invalidate));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
}
#[cfg(unix)]
#[test]
fn editing_through_symlink_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.rs");
std::fs::write(&real, "fn old() {}\n").unwrap();
let link = dir.path().join("link.rs");
std::os::unix::fs::symlink(&real, &link).unwrap();
let (text, effect) = run_io(
¶ms(
&link,
vec![AnchorOp::SetLine {
line: 1,
hash: line_hash("fn old() {}"),
new_text: "fn new() {}".to_string(),
}],
),
"",
);
assert!(text.contains("symlink"), "{text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
}