#![cfg(feature = "filesystem")]
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
use agents_tools::{FileChangeKind, apply_diff};
use tempfile::tempdir;
#[test]
fn applies_add_update_delete_and_move_blocks() {
let root = tempdir().unwrap();
fs::write(root.path().join("update.txt"), "old\nsecond\n").unwrap();
fs::write(root.path().join("delete.txt"), "gone\n").unwrap();
fs::write(root.path().join("move.txt"), "before\n").unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Add File: added.txt\n",
"+new\n",
"+file\n",
"*** Update File: update.txt\n",
"-old\n",
"+updated\n",
"*** Delete File: delete.txt\n",
"*** Update File: move.txt\n",
"*** Move to: nested/moved.txt\n",
"-before\n",
"+after\n",
"*** End Patch",
),
)
.unwrap();
assert!(report.is_success());
assert_eq!(report.applied.len(), 4);
assert_eq!(
fs::read_to_string(root.path().join("added.txt")).unwrap(),
"new\nfile"
);
assert_eq!(
fs::read_to_string(root.path().join("update.txt")).unwrap(),
"updated\nsecond\n"
);
assert!(!root.path().join("delete.txt").exists());
assert!(!root.path().join("move.txt").exists());
assert_eq!(
fs::read_to_string(root.path().join("nested/moved.txt")).unwrap(),
"after\n"
);
assert!(report.applied.iter().any(|change| {
change.kind == FileChangeKind::Moved
&& change.previous_path.as_deref() == Some(std::path::Path::new("move.txt"))
&& change.path == std::path::Path::new("nested/moved.txt")
}));
}
#[test]
fn applies_multiple_sections_to_one_file() {
let root = tempdir().unwrap();
fs::write(
root.path().join("main.rs"),
"fn one() {\n old();\n}\n\nfn two() {\n old();\n}\n",
)
.unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: main.rs\n",
"@@ fn one() {\n",
"- old();\n",
"+ new();\n",
"@@ fn two() {\n",
"- old();\n",
"+ newer();\n",
"*** End Patch",
),
)
.unwrap();
assert!(report.is_success());
assert_eq!(report.applied.len(), 1);
assert_eq!(
fs::read_to_string(root.path().join("main.rs")).unwrap(),
"fn one() {\n new();\n}\n\nfn two() {\n newer();\n}\n"
);
}
#[test]
fn one_failed_file_does_not_block_four_independent_files() {
let root = tempdir().unwrap();
for name in ["one.txt", "two.txt", "three.txt", "broken.txt"] {
fs::write(root.path().join(name), "old\n").unwrap();
}
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: one.txt\n-old\n+one\n",
"*** Update File: two.txt\n-old\n+two\n",
"*** Update File: three.txt\n-old\n+three\n",
"*** Add File: four.txt\n+four\n",
"*** Update File: broken.txt\n-missing\n+broken\n",
"*** End Patch",
),
)
.unwrap();
assert_eq!(report.applied.len(), 4);
assert_eq!(report.failed.len(), 1);
assert_eq!(report.failed[0].path, std::path::Path::new("broken.txt"));
assert_eq!(
fs::read_to_string(root.path().join("one.txt")).unwrap(),
"one\n"
);
assert_eq!(
fs::read_to_string(root.path().join("two.txt")).unwrap(),
"two\n"
);
assert_eq!(
fs::read_to_string(root.path().join("three.txt")).unwrap(),
"three\n"
);
assert_eq!(
fs::read_to_string(root.path().join("four.txt")).unwrap(),
"four"
);
assert_eq!(
fs::read_to_string(root.path().join("broken.txt")).unwrap(),
"old\n"
);
}
#[test]
fn repeated_updates_are_atomic_for_the_file() {
let root = tempdir().unwrap();
fs::write(root.path().join("shared.txt"), "first\nsecond\n").unwrap();
fs::write(root.path().join("other.txt"), "old\n").unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: shared.txt\n-first\n+changed\n",
"*** Update File: other.txt\n-old\n+new\n",
"*** Update File: shared.txt\n-missing\n+never\n",
"*** End Patch",
),
)
.unwrap();
assert_eq!(report.applied.len(), 1);
assert_eq!(report.failed.len(), 1);
assert_eq!(
fs::read_to_string(root.path().join("shared.txt")).unwrap(),
"first\nsecond\n"
);
assert_eq!(
fs::read_to_string(root.path().join("other.txt")).unwrap(),
"new\n"
);
}
#[test]
fn add_then_update_commits_once_after_both_succeed() {
let root = tempdir().unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Add File: composed.txt\n+old\n",
"*** Update File: composed.txt\n-old\n+new\n",
"*** End Patch",
),
)
.unwrap();
assert!(report.is_success());
assert_eq!(
fs::read_to_string(root.path().join("composed.txt")).unwrap(),
"new"
);
assert_eq!(report.applied[0].kind, FileChangeKind::Added);
}
#[test]
fn add_then_failed_update_leaves_no_file() {
let root = tempdir().unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Add File: composed.txt\n+old\n",
"*** Update File: composed.txt\n-missing\n+new\n",
"*** End Patch",
),
)
.unwrap();
assert!(report.applied.is_empty());
assert_eq!(report.failed.len(), 1);
assert!(!root.path().join("composed.txt").exists());
}
#[test]
fn update_then_delete_has_one_atomic_final_state() {
let root = tempdir().unwrap();
fs::write(root.path().join("removed.txt"), "old\n").unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: removed.txt\n-old\n+new\n",
"*** Delete File: removed.txt\n",
"*** End Patch",
),
)
.unwrap();
assert!(report.is_success());
assert!(!root.path().join("removed.txt").exists());
assert_eq!(report.applied[0].kind, FileChangeKind::Deleted);
}
#[test]
fn a_failed_move_keeps_both_paths_unchanged() {
let root = tempdir().unwrap();
fs::write(root.path().join("source.txt"), "source\n").unwrap();
fs::write(root.path().join("destination.txt"), "destination\n").unwrap();
fs::write(root.path().join("other.txt"), "old\n").unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: source.txt\n",
"*** Move to: destination.txt\n",
"-source\n+changed\n",
"*** Update File: other.txt\n-old\n+new\n",
"*** End Patch",
),
)
.unwrap();
assert_eq!(report.applied.len(), 1);
assert_eq!(report.failed.len(), 1);
assert_eq!(
fs::read_to_string(root.path().join("source.txt")).unwrap(),
"source\n"
);
assert_eq!(
fs::read_to_string(root.path().join("destination.txt")).unwrap(),
"destination\n"
);
assert_eq!(
fs::read_to_string(root.path().join("other.txt")).unwrap(),
"new\n"
);
}
#[test]
fn creates_missing_parent_directories() {
let root = tempdir().unwrap();
let report = apply_diff(
root.path(),
"*** Begin Patch\n*** Add File: one/two/three.txt\n+content\n*** End Patch",
)
.unwrap();
assert!(report.is_success());
assert_eq!(
fs::read_to_string(root.path().join("one/two/three.txt")).unwrap(),
"content"
);
}
#[test]
fn no_op_update_produces_no_change_entry() {
let root = tempdir().unwrap();
fs::write(root.path().join("same.txt"), "same\n").unwrap();
let report = apply_diff(
root.path(),
"*** Begin Patch\n*** Update File: same.txt\n*** End Patch",
)
.unwrap();
assert!(report.is_success());
assert!(report.applied.is_empty());
assert_eq!(
fs::read_to_string(root.path().join("same.txt")).unwrap(),
"same\n"
);
}
#[test]
fn add_existing_delete_missing_and_invalid_utf8_are_file_failures() {
let root = tempdir().unwrap();
fs::write(root.path().join("exists.txt"), "kept").unwrap();
fs::write(root.path().join("binary.dat"), [0xff, 0xfe]).unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Add File: exists.txt\n+replacement\n",
"*** Delete File: missing.txt\n",
"*** Update File: binary.dat\n-old\n+new\n",
"*** Add File: valid.txt\n+valid\n",
"*** End Patch",
),
)
.unwrap();
assert_eq!(report.failed.len(), 3);
assert_eq!(report.applied.len(), 1);
assert_eq!(
fs::read_to_string(root.path().join("exists.txt")).unwrap(),
"kept"
);
assert_eq!(
fs::read(root.path().join("binary.dat")).unwrap(),
[0xff, 0xfe]
);
assert_eq!(
fs::read_to_string(root.path().join("valid.txt")).unwrap(),
"valid"
);
}
#[test]
fn invalid_add_body_fails_only_that_file() {
let root = tempdir().unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Add File: invalid.txt\nnot-prefixed\n",
"*** Add File: valid.txt\n+valid\n",
"*** End Patch",
),
)
.unwrap();
assert_eq!(report.failed.len(), 1);
assert_eq!(report.applied.len(), 1);
assert!(!root.path().join("invalid.txt").exists());
assert_eq!(
fs::read_to_string(root.path().join("valid.txt")).unwrap(),
"valid"
);
}
#[test]
fn concurrent_updates_to_the_same_path_do_not_lose_an_update() {
let root = tempdir().unwrap();
fs::write(root.path().join("shared.txt"), "old\n").unwrap();
let root_path = Arc::new(root.path().to_path_buf());
let barrier = Arc::new(Barrier::new(3));
let handles: Vec<_> = ["first", "second"]
.into_iter()
.map(|replacement| {
let root_path = Arc::clone(&root_path);
let barrier = Arc::clone(&barrier);
thread::spawn(move || {
barrier.wait();
apply_diff(
root_path.as_path(),
&format!(
"*** Begin Patch\n*** Update File: shared.txt\n-old\n+{replacement}\n*** End Patch"
),
)
.unwrap()
})
})
.collect();
barrier.wait();
let reports: Vec<_> = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect();
assert_eq!(
reports
.iter()
.filter(|report| report.applied.len() == 1)
.count(),
1
);
assert_eq!(
reports
.iter()
.filter(|report| report.failed.len() == 1)
.count(),
1
);
let final_text = fs::read_to_string(root.path().join("shared.txt")).unwrap();
assert!(final_text == "first\n" || final_text == "second\n");
}
#[test]
fn concurrent_updates_to_unrelated_paths_both_commit() {
let root = tempdir().unwrap();
fs::write(root.path().join("one.txt"), "old\n").unwrap();
fs::write(root.path().join("two.txt"), "old\n").unwrap();
let root_path = Arc::new(root.path().to_path_buf());
let barrier = Arc::new(Barrier::new(3));
let handles: Vec<_> = [("one.txt", "one"), ("two.txt", "two")]
.into_iter()
.map(|(path, replacement)| {
let root_path = Arc::clone(&root_path);
let barrier = Arc::clone(&barrier);
thread::spawn(move || {
barrier.wait();
apply_diff(
root_path.as_path(),
&format!(
"*** Begin Patch\n*** Update File: {path}\n-old\n+{replacement}\n*** End Patch"
),
)
.unwrap()
})
})
.collect();
barrier.wait();
for handle in handles {
assert!(handle.join().unwrap().is_success());
}
assert_eq!(
fs::read_to_string(root.path().join("one.txt")).unwrap(),
"one\n"
);
assert_eq!(
fs::read_to_string(root.path().join("two.txt")).unwrap(),
"two\n"
);
}
#[test]
fn readers_never_observe_partially_written_content() {
let root = tempdir().unwrap();
let old = "a".repeat(256 * 1024);
let new = "b".repeat(256 * 1024);
let path = root.path().join("large.txt");
fs::write(&path, &old).unwrap();
let started = Arc::new(Barrier::new(2));
let finished = Arc::new(AtomicBool::new(false));
thread::scope(|scope| {
let reader_started = Arc::clone(&started);
let reader_finished = Arc::clone(&finished);
let old = &old;
let new = &new;
let path = &path;
let reader = scope.spawn(move || {
reader_started.wait();
while !reader_finished.load(Ordering::Acquire) {
let observed = fs::read_to_string(path).unwrap();
assert!(observed == *old || observed == *new);
}
});
started.wait();
let report = apply_diff(
root.path(),
&format!("*** Begin Patch\n*** Update File: large.txt\n-{old}\n+{new}\n*** End Patch"),
)
.unwrap();
assert!(report.is_success());
finished.store(true, Ordering::Release);
reader.join().unwrap();
});
assert_eq!(fs::read_to_string(path).unwrap(), new);
}
#[test]
fn unsafe_paths_reject_the_entire_envelope_before_writes() {
let root = tempdir().unwrap();
let error = apply_diff(
root.path(),
"*** Begin Patch\n*** Add File: ../outside.txt\n+nope\n*** End Patch",
)
.unwrap_err();
assert!(error.message().contains("unsafe component"));
assert!(fs::read_dir(root.path()).unwrap().next().is_none());
}
#[test]
fn malformed_envelopes_are_rejected() {
let root = tempdir().unwrap();
for patch in [
"*** Add File: file.txt\n+text\n*** End Patch",
"*** Begin Patch\n*** Add File: file.txt\n+text",
"*** Begin Patch\n*** End Patch",
"*** Begin Patch\n*** Rename File: a\n*** End Patch",
] {
assert!(apply_diff(root.path(), patch).is_err(), "{patch}");
}
}
#[test]
fn report_serializes_with_stable_change_names() {
let root = tempdir().unwrap();
let report = apply_diff(
root.path(),
"*** Begin Patch\n*** Add File: added.txt\n+text\n*** End Patch",
)
.unwrap();
let json = serde_json::to_value(report).unwrap();
assert_eq!(json["applied"][0]["kind"], "added");
assert_eq!(json["applied"][0]["path"], "added.txt");
assert_eq!(json["failed"], serde_json::json!([]));
}
#[cfg(unix)]
#[test]
fn update_and_move_preserve_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let root = tempdir().unwrap();
let source = root.path().join("script.sh");
fs::write(&source, "old\n").unwrap();
fs::set_permissions(&source, fs::Permissions::from_mode(0o751)).unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: script.sh\n",
"*** Move to: bin/script.sh\n",
"-old\n+new\n",
"*** End Patch",
),
)
.unwrap();
assert!(report.is_success());
assert_eq!(
fs::metadata(root.path().join("bin/script.sh"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o751
);
}
#[cfg(unix)]
#[test]
fn symlink_file_failure_does_not_block_an_unrelated_file() {
use std::os::unix::fs::symlink;
let root = tempdir().unwrap();
let outside = tempdir().unwrap();
fs::write(outside.path().join("outside.txt"), "outside\n").unwrap();
symlink(
outside.path().join("outside.txt"),
root.path().join("link.txt"),
)
.unwrap();
let report = apply_diff(
root.path(),
concat!(
"*** Begin Patch\n",
"*** Update File: link.txt\n-outside\n+escaped\n",
"*** Add File: safe.txt\n+safe\n",
"*** End Patch",
),
)
.unwrap();
assert_eq!(report.failed.len(), 1);
assert_eq!(report.applied.len(), 1);
assert_eq!(
fs::read_to_string(outside.path().join("outside.txt")).unwrap(),
"outside\n"
);
assert_eq!(
fs::read_to_string(root.path().join("safe.txt")).unwrap(),
"safe"
);
}
#[cfg(unix)]
#[test]
fn symlink_parent_cannot_escape_the_root() {
use std::os::unix::fs::symlink;
let root = tempdir().unwrap();
let outside = tempdir().unwrap();
symlink(outside.path(), root.path().join("linked-dir")).unwrap();
let report = apply_diff(
root.path(),
"*** Begin Patch\n*** Add File: linked-dir/escaped.txt\n+nope\n*** End Patch",
)
.unwrap();
assert_eq!(report.failed.len(), 1);
assert!(!outside.path().join("escaped.txt").exists());
}