use super::*;
#[test]
fn parse_add_file_rejects_unprefixed_content() {
let patch = "*** Begin Patch\n*** Add File: hello.txt\nplain\n*** End Patch";
let err = parse_patch(patch).expect_err("unprefixed add-file content should fail");
assert_eq!(err, "invalid add-file line: plain");
}
#[test]
fn parse_update_hunk_with_move_destination() {
let patch = "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch";
let hunks = parse_patch(patch).expect("move update should parse");
assert_eq!(
hunks,
vec![Hunk::Update {
path: PathBuf::from("old.txt"),
move_path: Some(PathBuf::from("new.txt")),
chunks: vec![UpdateChunk {
change_context: None,
old_lines: vec!["old".to_owned()],
new_lines: vec!["new".to_owned()],
is_end_of_file: false,
}],
}]
);
}
#[test]
fn parse_update_hunk_end_of_file_marker() {
let patch = "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n*** End of File\n*** End Patch";
let hunks = parse_patch(patch).expect("end-of-file update should parse");
let [Hunk::Update { chunks, .. }] = hunks.as_slice() else {
panic!("expected one update hunk");
};
assert!(chunks[0].is_end_of_file);
}
#[test]
fn parse_delete_hunk() {
let patch = "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch";
let hunks = parse_patch(patch).expect("delete should parse");
assert_eq!(
hunks,
vec![Hunk::Delete {
path: PathBuf::from("old.txt")
}]
);
}
#[test]
fn escaped_overlong_delete_path_produces_unreportable_terminal() {
let path = "\u{1}".repeat(950_000);
let patch = format!("*** Begin Patch\n*** Delete File: {path}\n*** End Patch");
let arguments = CborValue::Text(patch);
let mut world = ShellWorld::real();
let failure = apply_patch(&arguments, &mut world)
.expect_err("overlong filesystem path must fail before mutation");
assert!(failure.message.starts_with("Failed to delete file "));
assert!(failure.message.contains("\\u{1}"));
let display = &failure.display;
assert_eq!(display.status_text, failure.message);
assert!(
failure.message.len() + display.status_text.len()
> usize::try_from(tau_client::MAX_OUTBOUND_FRAME_BYTES)
.expect("frame limit fits usize"),
"duplicated escaped failure text must exceed the complete frame budget"
);
}
#[test]
fn compute_replacements_with_context() {
let original = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
let chunks = vec![UpdateChunk {
change_context: Some("a".to_owned()),
old_lines: vec!["b".to_owned()],
new_lines: vec!["B".to_owned()],
is_end_of_file: false,
}];
let replacements = compute_replacements(&original, Path::new("file.txt"), &chunks)
.expect("replacement plan should compute");
assert_eq!(replacements, vec![(1, 1, vec!["B".to_owned()])]);
}
#[test]
fn expected_line_mismatch_separates_header_and_recovery_output() {
let original = vec!["present".to_owned()];
let chunks = vec![UpdateChunk {
change_context: None,
old_lines: vec!["missing one".to_owned(), "missing two".to_owned()],
new_lines: vec!["replacement".to_owned()],
is_end_of_file: false,
}];
let error = compute_replacements(&original, Path::new("line\tbreak.txt"), &chunks)
.expect_err("missing expected lines must fail");
assert_eq!(
error.message,
"Failed to find expected lines in line\\tbreak.txt"
);
assert!(!error.message.contains(['\n', '\r']));
assert_eq!(
error.recovery_output.as_deref(),
Some("Expected lines in line\\tbreak.txt:\nmissing one\nmissing two")
);
}
#[test]
fn expected_line_mismatch_recovery_is_bounded() {
let expected_lines = vec!["🙂".repeat(MAX_CONTEXT_MISMATCH_DETAIL_BYTES)];
let output = bounded_context_mismatch_output("file.txt", &expected_lines);
assert!(output.len() <= MAX_CONTEXT_MISMATCH_DETAIL_BYTES);
assert!(output.ends_with("...(context truncated)"));
let long_path = "p".repeat(MAX_CONTEXT_MISMATCH_DETAIL_BYTES * 2);
let output = bounded_context_mismatch_output(&long_path, &["expected".to_owned()]);
assert!(output.len() <= MAX_CONTEXT_MISMATCH_DETAIL_BYTES);
assert!(output.starts_with("Expected lines in "));
assert!(output.contains("...(path truncated):\nexpected"));
assert!(output.ends_with("expected"));
}
#[test]
fn context_only_chunk_can_position_later_update_chunk() {
let patch = "*** Begin Patch\n*** Update File: file.txt\n@@\n fn anchor() {\n@@\n }\n\n+#[test]\n+fn inserted() {}\n+\n #[test]\n fn next() {}\n*** End Patch";
let hunks = parse_patch(patch).expect("context-only chunk should parse");
let [Hunk::Update { chunks, .. }] = hunks.as_slice() else {
panic!("expected one update hunk");
};
let original = "fn before() {}\n\nfn anchor() {\n}\n\n#[test]\nfn next() {}\n";
let new_contents = derive_new_contents_from_chunks(Path::new("file.txt"), original, chunks)
.expect("context-only chunk should guide the later insertion");
assert_eq!(
new_contents,
"fn before() {}\n\nfn anchor() {\n}\n\n#[test]\nfn inserted() {}\n\n#[test]\nfn next() {}\n"
);
}
#[test]
fn single_file_diff_payload_keeps_the_escaped_file_path() {
let changes = [AppliedChange {
display_path: "line\\tbreak.txt".to_owned(),
path: PathBuf::from("file.txt"),
status: ChangeStatus::Modify,
old_content: "before\n".to_owned(),
new_content: Some("after\n".to_owned()),
}];
let summary = format_summary(&changes);
let payload = display_payload_for_changes(&changes, &summary);
let Some(ToolUsePayload::Diffs { files }) = payload else {
panic!("single apply_patch change must retain its file path");
};
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "line\\tbreak.txt");
assert_eq!(files[0].diff.added, 1);
assert_eq!(files[0].diff.removed, 1);
}
#[test]
fn single_file_display_names_the_changed_path() {
let changes = [AppliedChange {
display_path: "line\\tbreak.txt".to_owned(),
path: PathBuf::from("file.txt"),
status: ChangeStatus::Modify,
old_content: "before\n".to_owned(),
new_content: Some("after\n".to_owned()),
}];
let mut display = tau_proto::ToolUseState::default();
populate_change_display(&mut display, &changes);
assert_eq!(display.args, "line\\tbreak.txt");
assert_eq!(display.info_chips, ["1F"]);
}
#[test]
fn multi_file_display_summarizes_distinct_paths() {
let changes = [
AppliedChange {
display_path: "first.rs".to_owned(),
path: PathBuf::from("first.rs"),
status: ChangeStatus::Modify,
old_content: "old\n".to_owned(),
new_content: Some("new\n".to_owned()),
},
AppliedChange {
display_path: "second.rs".to_owned(),
path: PathBuf::from("second.rs"),
status: ChangeStatus::Add,
old_content: String::new(),
new_content: Some("new\n".to_owned()),
},
AppliedChange {
display_path: "first.rs".to_owned(),
path: PathBuf::from("first.rs"),
status: ChangeStatus::Modify,
old_content: "new\n".to_owned(),
new_content: Some("newer\n".to_owned()),
},
];
let mut display = tau_proto::ToolUseState::default();
populate_change_display(&mut display, &changes);
assert_eq!(display.args, "first.rs,…");
assert_eq!(display.info_chips, ["2F"]);
}
#[test]
fn empty_change_display_stays_unlabelled() {
let mut display = tau_proto::ToolUseState::default();
populate_change_display(&mut display, &[]);
assert!(display.args.is_empty());
assert!(display.info_chips.is_empty());
}
#[test]
fn add_file_rejects_existing_target() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("exists.txt");
std::fs::write(&path, "original\n").expect("write original");
let mut world = ShellWorld::real();
let err = apply_hunks(
&[Hunk::Add {
path: path.clone(),
contents: "replacement\n".to_owned(),
}],
&mut world,
)
.expect_err("add file should reject existing target");
assert!(
err.message.contains("Add File target already exists"),
"unexpected error: {}",
err.message
);
assert_eq!(
std::fs::read_to_string(&path).expect("read original"),
"original\n"
);
}
#[test]
fn move_update_remove_failure_records_destination_as_partial_add() {
let temp = tempfile::tempdir().expect("tempdir");
let source_dir = temp.path().join("source");
let dest_dir = temp.path().join("writable-dest");
std::fs::create_dir_all(&source_dir).expect("create source dir");
std::fs::create_dir_all(&dest_dir).expect("create destination dir");
let source = source_dir.join("source.txt");
let destination = dest_dir.join("destination.txt");
std::fs::write(&source, "old\n").expect("write source");
let mut world = ShellWorld::real();
world.fail_next_remove_file_for(source.clone());
let result = apply_hunks(
&[Hunk::Update {
path: source.clone(),
move_path: Some(destination.clone()),
chunks: vec![UpdateChunk {
change_context: None,
old_lines: vec!["old".to_owned()],
new_lines: vec!["new".to_owned()],
is_end_of_file: false,
}],
}],
&mut world,
);
let err = result.expect_err("source removal should fail after writing destination");
assert!(
err.message.contains("Failed to remove original"),
"unexpected error: {}",
err.message
);
assert_eq!(
std::fs::read_to_string(&destination).expect("read destination"),
"new\n"
);
assert_eq!(
std::fs::read_to_string(&source).expect("read source"),
"old\n"
);
assert_eq!(
err.changes,
vec![AppliedChange {
display_path: render_path(&destination),
path: destination,
status: ChangeStatus::Add,
old_content: String::new(),
new_content: Some("new\n".to_owned()),
}]
);
}