#[cfg(test)]
mod tests {
use super::*;
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 mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
EditParams {
path: path.to_string_lossy().to_string(),
old_string: old.to_string(),
new_string: new.to_string(),
replace_all,
create,
expected_blake3: None,
expected_size: None,
expected_mtime_ms: None,
backup: false,
backup_path: None,
evidence: false,
diff_artifact: false,
diff_max_lines: 200,
allow_lossy_utf8: false,
}
}
#[test]
fn replace_single_occurrence() {
let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(f.path(), "hello", "world", false, false),
);
assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
}
#[test]
fn replace_all() {
let f = make_temp("aaa bbb aaa\n");
let mut cache = SessionCache::new();
let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
assert!(result.contains("2 replacements"));
let content = std::fs::read_to_string(f.path()).unwrap();
assert_eq!(content, "ccc bbb ccc\n");
}
#[test]
fn not_found_error() {
let f = make_temp("some content\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(f.path(), "nonexistent", "x", false, false),
);
assert!(result.contains("ERROR: old_string not found"));
}
#[test]
fn create_new_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub/new_file.txt");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(&path, "", "line1\nline2\nline3\n", false, true),
);
assert!(result.contains("created new_file.txt"));
assert!(result.contains("3 lines"));
assert!(path.exists());
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn create_denied_in_read_only_root() {
let _iso = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let ro = dir.path().join("refrepo");
std::fs::create_dir_all(&ro).unwrap();
let path = ro.join("sub/new_file.txt");
let ro_canon = lean_ctx::core::pathjail::canonicalize_or_self(&ro);
lean_ctx::test_env::set_var(
"LEAN_CTX_READ_ONLY_ROOTS",
ro_canon.to_string_lossy().as_ref(),
);
let mut cache = SessionCache::new();
let result = handle(&mut cache, &mk_params(&path, "", "x\n", false, true));
lean_ctx::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
assert!(
result.contains("read-only"),
"create in a read-only root must be refused: {result}"
);
assert!(!path.exists(), "no file may be created in a read-only root");
assert!(
!ro.join("sub").exists(),
"no directory may be created in a read-only root"
);
}
#[test]
fn empty_old_string_creates_a_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub/new_file.txt");
let (text, effect) = run_io(&mk_params(&path, "", "one\ntwo\n", false, false), "");
assert!(text.starts_with("✓ created new_file.txt: 2 lines"), "{text}");
assert!(matches!(effect, CacheEffect::Invalidate));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
}
#[test]
fn empty_old_string_fills_an_empty_file() {
let f = make_temp("");
let mut cache = SessionCache::new();
let text = handle(&mut cache, &mk_params(f.path(), "", "filled\n", false, false));
assert!(text.starts_with('✓'), "{text}");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "filled\n");
}
#[test]
fn empty_old_string_keeps_refusing_a_file_with_content() {
let f = make_temp("precious\n");
let (text, effect) = run_io(&mk_params(f.path(), "", "overwritten", false, false), "");
assert_eq!(
text,
"ERROR: old_string must not be empty for a non-empty file (an empty old_string \
only creates a missing or empty file; use create=true to replace the whole file)"
);
assert!(matches!(effect, CacheEffect::None));
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "precious\n");
let dir = tempfile::tempdir().unwrap();
let (text, _) = run_io(&mk_params(dir.path(), "", "x", false, false), "");
assert!(text.starts_with("ERROR: old_string must not be empty"), "{text}");
}
#[test]
fn empty_old_string_honours_the_preimage_guards() {
let f = make_temp("");
let mut params = mk_params(f.path(), "", "x", false, false);
params.expected_size = Some(7);
let (text, _) = run_io(¶ms, "");
assert!(text.contains("preimage mismatch"), "{text}");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "");
}
#[cfg(unix)]
#[test]
fn empty_old_string_never_writes_through_a_symlink() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("victim.txt");
std::fs::write(&real, "").unwrap();
let link = dir.path().join("innocent.txt");
std::os::unix::fs::symlink(&real, &link).unwrap();
let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, false), "");
assert!(text.starts_with("ERROR"), "{text}");
assert_eq!(std::fs::read_to_string(&real).unwrap(), "");
let target = dir.path().join("missing.txt");
let dangling = dir.path().join("dangling.txt");
std::os::unix::fs::symlink(&target, &dangling).unwrap();
let (text, _) = run_io(&mk_params(&dangling, "", "planted", false, false), "");
assert!(text.starts_with("ERROR"), "{text}");
assert!(!target.exists(), "no file may appear behind a dangling link");
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn empty_old_string_create_is_denied_in_read_only_root() {
let _iso = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let ro = dir.path().join("refrepo");
std::fs::create_dir_all(&ro).unwrap();
let path = ro.join("sub/new_file.txt");
let ro_canon = lean_ctx::core::pathjail::canonicalize_or_self(&ro);
lean_ctx::test_env::set_var(
"LEAN_CTX_READ_ONLY_ROOTS",
ro_canon.to_string_lossy().as_ref(),
);
let (text, _) = run_io(&mk_params(&path, "", "x\n", false, false), "");
lean_ctx::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
assert!(text.contains("read-only"), "{text}");
assert!(!path.exists());
assert!(!ro.join("sub").exists());
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn edit_denied_in_read_only_root() {
let _iso = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let ro = dir.path().join("refrepo");
std::fs::create_dir_all(&ro).unwrap();
let path = ro.join("a.txt");
std::fs::write(&path, "alpha beta\n").unwrap();
let ro_canon = lean_ctx::core::pathjail::canonicalize_or_self(&ro);
lean_ctx::test_env::set_var(
"LEAN_CTX_READ_ONLY_ROOTS",
ro_canon.to_string_lossy().as_ref(),
);
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(&path, "alpha", "OMEGA", false, false),
);
lean_ctx::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
assert!(
result.contains("read-only"),
"edit in a read-only root must be refused: {result}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"alpha beta\n",
"the file must be left untouched"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn backup_path_cannot_smuggle_writes_into_read_only_root() {
let _iso = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let ro = dir.path().join("refrepo");
let work = dir.path().join("work");
std::fs::create_dir_all(&ro).unwrap();
std::fs::create_dir_all(&work).unwrap();
let target = work.join("a.txt"); std::fs::write(&target, "alpha beta\n").unwrap();
let smuggled = ro.join("leak.bak");
let ro_canon = lean_ctx::core::pathjail::canonicalize_or_self(&ro);
lean_ctx::test_env::set_var(
"LEAN_CTX_READ_ONLY_ROOTS",
ro_canon.to_string_lossy().as_ref(),
);
let mut params = mk_params(&target, "alpha", "OMEGA", false, false);
params.backup = true;
params.backup_path = Some(smuggled.to_string_lossy().to_string());
let mut cache = SessionCache::new();
let result = handle(&mut cache, ¶ms);
lean_ctx::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
assert!(
result.contains("read-only"),
"a backup_path into a read-only root must be refused: {result}"
);
assert!(
!smuggled.exists(),
"no backup may be smuggled into a read-only root"
);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"alpha beta\n",
"fail-closed: the writable target must be untouched when the backup is denied"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn edit_denied_via_config_read_only_roots() {
let _iso = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let ro = dir.path().join("refrepo");
std::fs::create_dir_all(&ro).unwrap();
let path = ro.join("a.txt");
std::fs::write(&path, "alpha beta\n").unwrap();
let cfg_path = lean_ctx::core::config::Config::path().unwrap();
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(
&cfg_path,
format!("read_only_roots = ['{}']\n", ro.to_string_lossy()),
)
.unwrap();
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(&path, "alpha", "OMEGA", false, false),
);
assert!(
result.contains("read-only"),
"config-declared read_only_roots must deny the edit: {result}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"alpha beta\n",
"the file must be left untouched"
);
}
#[cfg(unix)]
#[test]
fn write_falls_back_on_readonly_parent_dir() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
std::fs::write(&path, b"hello").unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
let res = write_atomic_bytes_with_permissions(&path, b"world", None);
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(res.is_ok(), "in-place fallback should succeed: {res:?}");
assert_eq!(std::fs::read(&path).unwrap(), b"world");
}
#[cfg(unix)]
#[test]
fn handle_edit_succeeds_on_readonly_parent_dir() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
std::fs::write(&path, "hello world\n").unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(&path, "hello", "goodbye", false, false),
);
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
result.contains("patched "),
"edit should succeed via in-place fallback: {result}"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye world\n");
}
#[test]
fn unique_match_succeeds() {
let f = make_temp("fn main() {\n let x = 42;\n}\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(f.path(), "let x = 42", "let x = 99", false, false),
);
assert!(result.contains("patched "));
assert!(result.contains("(+1 -1)"), "{result}");
assert_eq!(result.lines().count(), 1, "{result}");
let content = std::fs::read_to_string(f.path()).unwrap();
assert!(content.contains("let x = 99"));
}
#[test]
fn receipt_counts_only_changed_lines_when_the_anchor_is_repeated() {
let f = make_temp("from .models import (\n Alpha,\n Beta,\n)\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(
f.path(),
"from .models import (\n Alpha,\n Beta,",
"from .models import (\n Alpha,\n Added,\n Beta,",
false,
false,
),
);
assert!(result.contains("(+1 -0)"), "{result}");
}
#[test]
fn crlf_file_with_lf_search() {
let f = make_temp("line1\r\nline2\r\nline3\r\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
);
assert!(result.contains("patched "), "CRLF fallback should work: {result}");
let content = std::fs::read_to_string(f.path()).unwrap();
assert!(
content.contains("changed1\r\nchanged2"),
"new_string should be adapted to CRLF: {content:?}"
);
assert!(
content.contains("\r\nline3\r\n"),
"rest of file should keep CRLF: {content:?}"
);
}
#[test]
fn lf_file_with_crlf_search() {
let f = make_temp("line1\nline2\nline3\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
);
assert!(result.contains("patched "), "LF fallback should work: {result}");
let content = std::fs::read_to_string(f.path()).unwrap();
assert!(
content.contains("a\nb"),
"new_string should be adapted to LF: {content:?}"
);
}
#[test]
fn trailing_whitespace_tolerance() {
let f = make_temp(" let x = 1; \n let y = 2;\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(
f.path(),
" let x = 1;\n let y = 2;",
" let x = 10;\n let y = 20;",
false,
false,
),
);
assert!(
result.contains("patched "),
"trailing whitespace tolerance should work: {result}"
);
let content = std::fs::read_to_string(f.path()).unwrap();
assert!(content.contains("let x = 10;"));
assert!(content.contains("let y = 20;"));
}
#[test]
fn crlf_with_trailing_whitespace() {
let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
let mut cache = SessionCache::new();
let result = handle(
&mut cache,
&mk_params(
f.path(),
" const a = 1;\n const b = 2;",
" const a = 10;\n const b = 20;",
false,
false,
),
);
assert!(
result.contains("patched "),
"CRLF + trailing whitespace should work: {result}"
);
let content = std::fs::read_to_string(f.path()).unwrap();
assert!(content.contains("const a = 10;"));
assert!(content.contains("const b = 20;"));
}
#[test]
fn rejects_invalid_utf8_by_default() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
let mut cache = SessionCache::new();
let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
assert!(
result.contains("not valid UTF-8"),
"expected utf8 rejection, got: {result}"
);
}
#[test]
fn allows_lossy_utf8_only_when_enabled() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
let mut cache = SessionCache::new();
let mut p = mk_params(f.path(), "a", "b", false, false);
p.allow_lossy_utf8 = true;
let result = handle(&mut cache, &p);
assert!(
!result.contains("not valid UTF-8"),
"lossy mode should avoid utf8 hard error, got: {result}"
);
}
#[test]
fn expected_blake3_mismatch_fails_without_writing() {
let f = make_temp("aaa\n");
let mut cache = SessionCache::new();
let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
p.expected_blake3 = Some("deadbeef".to_string());
let result = handle(&mut cache, &p);
assert!(
result.contains("preimage mismatch"),
"expected preimage mismatch, got: {result}"
);
let content = std::fs::read_to_string(f.path()).unwrap();
assert_eq!(content, "aaa\n");
}
#[test]
fn receipt_labels_the_digest_by_its_actual_algorithm() {
let f = make_temp("aaa\n");
let mut cache = SessionCache::new();
let mut with_evidence = mk_params(f.path(), "aaa", "bbb", false, false);
with_evidence.evidence = true;
let out = handle(&mut cache, &with_evidence);
assert!(out.contains("blake3="), "receipt must name BLAKE3: {out}");
assert!(
!out.contains("md5="),
"no `md5=` label may survive on a BLAKE3 digest: {out}"
);
let digest = out
.lines()
.find(|l| l.starts_with("postimage:"))
.and_then(|l| l.split("blake3=").nth(1))
.expect("receipt carries a postimage blake3 field");
assert_eq!(digest.len(), 64, "BLAKE3 is 64 hex chars, got {digest:?}");
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
digest,
lean_ctx::core::hasher::hash_hex(b"bbb\n"),
"the postimage digest must be BLAKE3 of the bytes on disk"
);
let mut p = mk_params(f.path(), "bbb", "ccc", false, false);
p.expected_blake3 = Some("deadbeef".to_string());
let err = handle(&mut cache, &p);
assert!(err.contains("expected_blake3="), "{err}");
assert!(err.contains("actual_blake3="), "{err}");
}
#[test]
fn backup_is_created_when_enabled() {
let f = make_temp("aaa\n");
let mut cache = SessionCache::new();
let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
p.backup = true;
let out = handle(&mut cache, &p);
assert!(out.contains("backup:"), "expected backup path, got: {out}");
let bp = out
.lines()
.find_map(|l| l.strip_prefix("backup: "))
.expect("backup line");
let backup_content = std::fs::read_to_string(bp).unwrap();
assert_eq!(backup_content, "aaa\n");
let content = std::fs::read_to_string(f.path()).unwrap();
assert_eq!(content, "bbb\n");
}
#[test]
fn evidence_diff_is_emitted_when_enabled() {
let f = make_temp("line1\nline2\n");
let mut cache = SessionCache::new();
let mut p = mk_params(f.path(), "line2", "changed2", false, false);
p.evidence = true;
p.diff_max_lines = 50;
let out = handle(&mut cache, &p);
assert!(out.contains("```diff"), "expected diff fence, got: {out}");
assert!(
out.contains("preimage:"),
"expected preimage metadata, got: {out}"
);
assert!(
out.contains("postimage:"),
"expected postimage metadata, got: {out}"
);
}
#[test]
fn run_io_success_reports_invalidate_effect() {
let f = make_temp("fn main() {\n let x = 42;\n}\n");
let (text, effect) = run_io(
&mk_params(f.path(), "let x = 42", "let x = 99", false, false),
"",
);
assert!(text.contains("patched "), "expected success: {text}");
assert!(
matches!(effect, CacheEffect::Invalidate),
"successful edit must invalidate the cache entry"
);
let content = std::fs::read_to_string(f.path()).unwrap();
assert!(content.contains("let x = 99"));
}
#[test]
fn run_io_failure_reports_no_cache_effect() {
let f = make_temp("some content\n");
let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
assert!(text.contains("ERROR: old_string not found"));
assert!(
matches!(effect, CacheEffect::None),
"a failed edit must not mutate the cache"
);
}
#[test]
fn run_io_concurrent_edits_to_different_files_all_succeed() {
use std::sync::Arc;
let dir = Arc::new(tempfile::tempdir().unwrap());
let n = 16;
let mut paths = Vec::new();
for i in 0..n {
let p = dir.path().join(format!("file_{i}.txt"));
std::fs::write(&p, format!("value = {i}\n")).unwrap();
paths.push(p);
}
let barrier = Arc::new(std::sync::Barrier::new(n));
let mut handles = Vec::new();
for (i, p) in paths.into_iter().enumerate() {
let barrier = Arc::clone(&barrier);
handles.push(std::thread::spawn(move || {
barrier.wait();
let (text, effect) = run_io(
&mk_params(
&p,
&format!("value = {i}"),
&format!("value = {}", i + 1000),
false,
false,
),
"",
);
assert!(text.contains("patched "), "edit {i} failed: {text}");
assert!(matches!(effect, CacheEffect::Invalidate));
(p, i)
}));
}
for h in handles {
let (p, i) = h.join().unwrap();
let content = std::fs::read_to_string(&p).unwrap();
assert_eq!(content, format!("value = {}\n", i + 1000));
}
}
#[test]
fn run_io_escalation_reports_store_full_effect() {
let f = make_temp("line a\nline b\nline c\n");
let (text, effect) = run_io(
&mk_params(f.path(), "definitely-not-present", "x", false, false),
"signatures",
);
assert!(
text.contains("[auto-escalation]"),
"expected escalation: {text}"
);
match effect {
CacheEffect::StoreFull(content) => {
assert!(content.contains("line a") && content.contains("line c"));
}
_ => panic!("escalation must report a StoreFull cache effect"),
}
}
#[test]
fn apply_cache_effect_invalidate_and_store() {
let f = make_temp("hello\n");
let mut cache = SessionCache::new();
cache.store(&f.path().to_string_lossy(), "hello\n");
apply_cache_effect(
&mut cache,
&f.path().to_string_lossy(),
CacheEffect::Invalidate,
);
assert!(
cache.get(&f.path().to_string_lossy()).is_none(),
"Invalidate must drop the entry"
);
apply_cache_effect(
&mut cache,
&f.path().to_string_lossy(),
CacheEffect::StoreFull("fresh\n".to_string()),
);
assert!(
cache.get(&f.path().to_string_lossy()).is_some(),
"StoreFull must re-populate the entry"
);
}
#[test]
fn identical_old_new_is_a_no_op_when_present_and_an_error_when_absent() {
let f = make_temp("fn main() {}\n");
let mut cache = SessionCache::new();
let present = handle(
&mut cache,
&mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
);
assert!(present.starts_with("No change:"), "{present}");
assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "fn main() {}\n");
let absent = handle(
&mut cache,
&mk_params(f.path(), "fn other() {}", "fn other() {}", false, false),
);
assert!(absent.starts_with("ERROR:") && absent.contains("identical"), "{absent}");
}
#[test]
fn present_new_string_is_not_reported_as_applied() {
let f = make_temp("fn updated() {}\n");
let (text, effect) = run_io(
&mk_params(
f.path(),
"fn original() {}",
"fn updated() {}",
false,
false,
),
"",
);
assert!(text.starts_with("ERROR: old_string not found"), "{text}");
assert!(text.contains("does not establish"), "{text}");
assert!(!text.contains("likely already applied"), "{text}");
assert!(matches!(effect, CacheEffect::None));
}
#[test]
fn mismatch_with_overlapping_new_string_points_at_the_real_line() {
let f = make_temp("// let retries = 3;\n// retry();\nconnect();\n");
let (text, _) = run_io(
&mk_params(
f.path(),
"let retries = 3;\nconnect();",
"connect();",
false,
false,
),
"",
);
assert!(text.starts_with("ERROR: old_string not found"), "{text}");
assert!(!text.contains("likely already applied"), "{text}");
assert!(text.contains("// let retries = 3;"), "{text}");
}
#[test]
fn ambiguous_match_names_the_lines_of_its_occurrences() {
let f = make_temp("x = 1;\nfoo();\n\nfoo();\nbar(); foo();\n");
let (text, effect) = run_io(&mk_params(f.path(), "foo();", "baz();", false, false), "");
assert_eq!(
text,
format!(
"ERROR: old_string found 3 times in {} (lines 2, 4, 5). Use replace_all=true \
to replace every occurrence, or add surrounding lines to old_string until it \
matches exactly one.",
f.path().display()
)
);
assert!(matches!(effect, CacheEffect::None));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"x = 1;\nfoo();\n\nfoo();\nbar(); foo();\n"
);
}
#[test]
fn ambiguous_match_lists_at_most_five_lines() {
let f = make_temp(&"hit\n".repeat(8));
let (text, _) = run_io(&mk_params(f.path(), "hit", "miss", false, false), "");
assert!(
text.contains("found 8 times") && text.contains("(lines 1, 2, 3, 4, 5 and 3 more)."),
"{text}"
);
let crlf = make_temp("a\r\nb\r\nc\r\na\r\nb\r\n");
let (text, _) = run_io(&mk_params(crlf.path(), "a\nb", "z", false, false), "");
assert!(text.contains("(lines 1, 4)."), "{text}");
}
#[test]
fn a_proven_repeat_of_an_applied_edit_is_a_no_op() {
let f = make_temp("fn alpha() {}\nfn beta() {}\n");
let params = mk_params(f.path(), "fn alpha() {}", "fn gamma() {}", false, false);
let (first, effect) = run_io(¶ms, "");
assert!(first.starts_with("patched "), "{first}");
assert!(matches!(effect, CacheEffect::Invalidate));
let (repeat, effect) = run_io(¶ms, "");
assert_eq!(
repeat,
format!(
"No change: {} — this exact edit was already applied by an earlier ctx_edit \
call (old_string is absent, new_string is present).",
f.path().display()
)
);
assert!(repeat.starts_with(NO_CHANGE_PREFIX));
assert!(matches!(effect, CacheEffect::None));
assert_eq!(
std::fs::read_to_string(f.path()).unwrap(),
"fn gamma() {}\nfn beta() {}\n"
);
}
#[test]
fn a_repeat_is_not_served_without_proof() {
let f = make_temp("fn alpha() {}\n");
let params = mk_params(f.path(), "fn alpha() {}", "fn gamma() {}", false, false);
assert!(run_io(¶ms, "").0.starts_with("patched "));
std::fs::write(f.path(), "fn delta() {}\n").unwrap();
let (text, _) = run_io(¶ms, "");
assert!(text.starts_with("ERROR: old_string not found"), "{text}");
let other = make_temp("fn gamma() {}\n");
let (text, _) = run_io(
&mk_params(other.path(), "fn alpha() {}", "fn gamma() {}", false, false),
"",
);
assert!(text.starts_with("ERROR: old_string not found"), "{text}");
assert!(text.contains("does not establish"), "{text}");
std::fs::write(f.path(), "fn gamma() {}\n").unwrap();
let (text, _) = run_io(
&mk_params(f.path(), "fn alpha2() {}", "fn gamma() {}", false, false),
"",
);
assert!(text.starts_with("ERROR: old_string not found"), "{text}");
}
#[test]
fn a_proven_repeat_is_a_success_through_the_tool() {
let f = make_temp("let a = 1;\n");
let mut cache = SessionCache::new();
let params = mk_params(f.path(), "let a = 1;", "let a = 2;", false, false);
assert!(handle(&mut cache, ¶ms).starts_with("patched "));
let repeat = handle(&mut cache, ¶ms);
assert!(repeat.starts_with(NO_CHANGE_PREFIX), "{repeat}");
}
#[test]
fn closest_line_hint_shown() {
let f = make_temp(" fn hello() {\n println!(\"hi\");\n }\n");
let (text, _) = run_io(
&mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
"",
);
assert!(text.contains("Closest match at line"));
}
#[test]
fn missing_file_suggests_relocated_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
let (text, effect) = run_io(
&mk_params(
&dir.path().join("src/old/gizmo.rs"),
"fn gizmo() {}",
"fn gizmo2() {}",
false,
false,
),
"",
);
assert!(text.contains("same-named file was found"), "got: {text}");
assert!(text.contains("gizmo.rs"), "got: {text}");
assert!(matches!(effect, CacheEffect::None));
}
#[test]
fn old_string_in_other_file_is_reported() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let target = dir.path().join("a.rs");
std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
let (text, _) = run_io(
&mk_params(
&target,
"fn the_target_symbol() {}",
"fn renamed() {}",
false,
false,
),
"",
);
assert!(text.contains("matching line exists in"), "got: {text}");
assert!(text.contains("b.rs"), "got: {text}");
}
#[cfg(unix)]
#[test]
fn editing_through_a_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(
&mk_params(&link, "fn old() {}", "fn new() {}", false, false),
"",
);
assert!(text.contains("symlink"), "got: {text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
}
#[cfg(unix)]
#[test]
fn creating_over_a_symlink_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("victim.txt");
std::fs::write(&real, "precious").unwrap();
let link = dir.path().join("innocent.txt");
std::os::unix::fs::symlink(&real, &link).unwrap();
let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, true), "");
assert!(
text.contains("symlink") || text.contains("ERROR"),
"got: {text}"
);
assert_eq!(
std::fs::read_to_string(&real).unwrap(),
"precious",
"symlink target must not be modified"
);
}
#[test]
fn regular_file_edit_still_works_after_symlink_guard() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("normal.rs");
std::fs::write(&file, "fn old() {}\n").unwrap();
let (text, _) = run_io(
&mk_params(&file, "fn old() {}", "fn new() {}", false, false),
"",
);
assert!(
text.contains("Edit applied") || !text.starts_with("ERROR"),
"got: {text}"
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
}
#[test]
fn approximate_match_after_a_lossy_read_is_refused() {
let _data = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let cases = [
("trailing.rs", "fn a() { \n}\n", "fn a() {\n}"),
("indent.rs", " let x = 1;\n let y = 2;\n", "let x = 1;\nlet y = 2;"),
];
for (name, original, old) in cases {
let file = dir.path().join(name);
for mode in ["map", "signatures", "aggressive", "density:0.40", "reference"] {
std::fs::write(&file, original).unwrap();
let (text, effect) = run_io(&mk_params(&file, old, "CHANGED", false, false), mode);
assert!(text.starts_with("ERROR"), "{name} {mode}: {text}");
assert!(text.contains("lossy view"), "{name} {mode}: {text}");
assert!(text.contains("mode=\"full\""), "{name} {mode}: {text}");
assert!(!matches!(effect, CacheEffect::Invalidate));
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
for mode in ["", "full", "full-compact", "lines:1-2"] {
std::fs::write(&file, original).unwrap();
let (text, effect) = run_io(&mk_params(&file, old, "CHANGED", false, false), mode);
assert!(!text.starts_with("ERROR"), "{name} {mode}: {text}");
assert!(matches!(effect, CacheEffect::Invalidate));
}
}
}
#[test]
fn exact_match_after_a_lossy_read_still_applies() {
let _data = lean_ctx::core::data_dir::isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("exact.rs");
std::fs::write(&file, "fn old() {}\n").unwrap();
let (text, _) = run_io(&mk_params(&file, "fn old() {}", "fn new() {}", false, false), "map");
assert!(!text.starts_with("ERROR"), "{text}");
assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
}
}
#[cfg(test)]
mod admitted_reread_tests {
use super::*;
fn policy(body: &str) -> lean_ctx::core::policy::ResolvedPolicy {
lean_ctx::core::policy::load(&format!(
"name = \"ctx-edit-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{body}"
))
.unwrap()
}
fn params(path: &Path, old: &str, new: &str) -> EditParams {
EditParams {
path: path.to_string_lossy().to_string(),
old_string: old.to_string(),
new_string: new.to_string(),
replace_all: false,
create: false,
expected_blake3: None,
expected_size: None,
expected_mtime_ms: None,
backup: false,
backup_path: None,
evidence: false,
diff_artifact: false,
diff_max_lines: 200,
allow_lossy_utf8: false,
}
}
#[test]
fn a_withheld_file_is_neither_quoted_nor_cached_by_a_failed_edit() {
let _data = lean_ctx::core::data_dir::isolated_data_dir();
let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy(
"[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
)));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("plan.rs");
let original = "// CONFIDENTIAL\nfn merger_target_acme() {}\n";
std::fs::write(&file, original).unwrap();
let (text, effect) = run_io(
¶ms(&file, "fn merger_target() {}", "fn renamed() {}"),
"signatures",
);
assert!(
text.starts_with("ERROR: old_string not found"),
"got: {text}"
);
assert!(!text.contains("merger_target_acme"), "got: {text}");
assert!(!text.contains("[auto-escalation]"), "got: {text}");
assert!(!text.contains("Closest match"), "got: {text}");
assert!(matches!(effect, CacheEffect::None));
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
#[test]
fn the_escalated_reread_shows_the_admitted_text_and_the_write_keeps_original_bytes() {
let _data = lean_ctx::core::data_dir::isolated_data_dir();
let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy(
"[redaction]\ncustomer = \"CUS-[0-9]{4}\"\n",
)));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("billing.rs");
std::fs::write(&file, "const OWNER: &str = \"CUS-1234\";\nfn old() {}\n").unwrap();
let (text, effect) = run_io(
¶ms(&file, "fn missing_owner() {}", "fn renamed() {}"),
"signatures",
);
assert!(text.contains("[auto-escalation]"), "got: {text}");
assert!(text.contains("[REDACTED:customer]"), "got: {text}");
assert!(!text.contains("CUS-1234"), "got: {text}");
if let CacheEffect::StoreFull(stored) = &effect {
assert!(!stored.contains("CUS-1234"), "cache must hold admitted text");
}
let (applied, _) = run_io(¶ms(&file, "fn old() {}", "fn new() {}"), "");
assert!(!applied.starts_with("ERROR"), "got: {applied}");
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"const OWNER: &str = \"CUS-1234\";\nfn new() {}\n",
"the write keeps the original bytes of every untouched line"
);
}
#[test]
fn a_restricted_reread_is_never_handed_to_the_cache() {
let _data = lean_ctx::core::data_dir::isolated_data_dir();
let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("notes.rs");
std::fs::write(&file, "// TOP SECRET\nfn old() {}\n").unwrap();
let (_, effect) = run_io(
¶ms(&file, "fn missing() {}", "fn renamed() {}"),
"signatures",
);
assert!(!matches!(effect, CacheEffect::StoreFull(_)));
}
#[test]
fn an_admitted_reread_is_unchanged_without_a_policy() {
let _data = lean_ctx::core::data_dir::isolated_data_dir();
let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("plain.rs");
std::fs::write(&file, "line a\nline b\n").unwrap();
let (text, effect) = run_io(
¶ms(&file, "definitely-not-present", "x"),
"signatures",
);
assert!(
text.ends_with("retry edit with exact text from here:\n\nline a\nline b\n"),
"got: {text}"
);
assert!(matches!(effect, CacheEffect::StoreFull(content) if content == "line a\nline b\n"));
}
}