Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions crates/tui/src/tools/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1385,26 +1385,25 @@ impl WriteFileTool {
})?;
}
check_file_operation_cancelled(context)?;
crate::utils::write_atomic_workspace(&file_path, file_content.as_bytes()).map_err(
|error| {
ToolError::execution_failed(format!(
"Failed to write {}: {error}",
file_path.display()
))
},
)?;
// Preserve the existing file's line-ending style on overwrite (see
// `preserve_prior_line_endings`); otherwise a CRLF (Windows) file is
// silently rewritten with LF line endings.
let written = preserve_prior_line_endings(file_content, &prior_contents);
crate::utils::write_atomic_workspace(&file_path, written.as_bytes()).map_err(|error| {
ToolError::execution_failed(format!("Failed to write {}: {error}", file_path.display()))
})?;
check_file_operation_cancelled(context)?;
context.note_file_read(&file_path);
drop(mutation_guard);

let outcome = if existed_before { "updated" } else { "created" };
let utf16_units = file_content.encode_utf16().count();
let utf16_units = written.encode_utf16().count();
Ok(contract_mutation_result(
context,
&file_path,
path_str,
prior_contents.as_ref(),
file_content,
&written,
outcome,
format!("Successfully wrote {utf16_units} bytes to {path_str}"),
)
Expand Down Expand Up @@ -1504,17 +1503,22 @@ impl ToolSpec for WriteFileTool {
})?;
}

crate::utils::write_atomic_workspace(&file_path, file_content.as_bytes()).map_err(|e| {
// Preserve the existing file's line-ending style on overwrite (see
// `preserve_prior_line_endings`); a full `write_file` over a CRLF
// (Windows) file otherwise silently rewrites every line ending to LF.
let written = preserve_prior_line_endings(file_content, &prior_contents);

crate::utils::write_atomic_workspace(&file_path, written.as_bytes()).map_err(|e| {
ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
})?;
context.note_file_read(&file_path);

let display = file_path.display().to_string();
let diff = make_unified_diff(&display, &prior_contents, file_content);
let diff = make_unified_diff(&display, &prior_contents, &written);
let summary = if existed_before {
format!("Wrote {} bytes to {}", file_content.len(), display)
format!("Wrote {} bytes to {}", written.len(), display)
} else {
format!("Created {} ({} bytes)", display, file_content.len())
format!("Created {} ({} bytes)", display, written.len())
};
let body = if diff.is_empty() {
format!("{summary}\n(no changes)")
Expand All @@ -1533,7 +1537,7 @@ impl ToolSpec for WriteFileTool {
let outcome = if existed_before { "updated" } else { "created" };
// Keep the execution-owned receipt workspace-relative even though the
// legacy model-facing output above retains its resolved-path wording.
let receipt_diff = make_unified_diff(path_str, &prior_contents, file_content);
let receipt_diff = make_unified_diff(path_str, &prior_contents, &written);
Ok(ToolResult::success(full_body).with_metadata(json!({
"event": "file.mutation",
"mutation": {
Expand Down Expand Up @@ -1584,6 +1588,21 @@ fn restore_contract_line_endings(text: &str, ending: &str) -> String {
}
}

/// Rewrite `content` to match the line-ending style of an existing file's
/// `prior` content, so a full-file overwrite (`write_file` / contract `write`)
/// does not silently flip a CRLF (Windows) file to LF — the same policy
/// `edit_file` applies. A brand-new file (no prior content) is returned
/// verbatim: there is no style to preserve.
fn preserve_prior_line_endings(content: &str, prior: &str) -> String {
if prior.is_empty() {
return content.to_string();
}
restore_contract_line_endings(
&normalize_contract_line_endings(content),
contract_line_ending(prior),
)
}

/// Fallback matching view used only after a literal match fails. It follows
/// The small-contract normalization categories while leaving the public schema as
/// exact-text replacement rather than teaching a second edit mode.
Expand Down
75 changes: 75 additions & 0 deletions crates/tui/src/tools/file/tests/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,81 @@ async fn write_file_tool_preserves_existing_mode() {
assert_eq!(fs::read_to_string(&path).expect("read"), "after");
}

#[tokio::test]
async fn write_file_over_crlf_file_preserves_crlf_line_endings() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let path = tmp.path().join("crlf.txt");
fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");

WriteFileTool
.execute(
json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
&ctx,
)
.await
.expect("execute");

let written = fs::read(&path).expect("read");
assert_eq!(
written, b"gamma\r\ndelta\r\n",
"write_file must preserve the existing CRLF style, like edit_file"
);
}

#[tokio::test]
async fn contract_write_over_crlf_file_preserves_crlf_line_endings() {
// The contract `write` path (WriteFileTool::execute_contract_write) must
// honor the same line-ending policy as the full write_file tool.
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let path = tmp.path().join("crlf.txt");
fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");

WriteFileTool::execute_contract_write(
json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
&ctx,
)
.await
.expect("execute");

let written = fs::read(&path).expect("read");
assert_eq!(
written, b"gamma\r\ndelta\r\n",
"contract write must preserve the existing CRLF style, like edit_file"
);
}

#[test]
fn preserve_prior_line_endings_keeps_the_prior_style() {
// Existing CRLF file: incoming LF content is re-emitted as CRLF.
assert_eq!(
preserve_prior_line_endings("gamma\ndelta\n", "alpha\r\nbeta\r\n"),
"gamma\r\ndelta\r\n"
);
// Existing LF file: incoming CRLF content is re-emitted as LF.
assert_eq!(
preserve_prior_line_endings("gamma\r\ndelta\r\n", "alpha\nbeta\n"),
"gamma\ndelta\n"
);
// Brand-new file (no prior content): written verbatim, including CRLF.
assert_eq!(
preserve_prior_line_endings("gamma\r\ndelta\r\n", ""),
"gamma\r\ndelta\r\n"
);
assert_eq!(preserve_prior_line_endings("plain", ""), "plain");
// A lone CR in the incoming content is normalized like edit_file does: the
// bare \r becomes \n, then is re-emitted as CRLF when the prior is CRLF.
assert_eq!(
preserve_prior_line_endings("alpha\rbeta\n", "x\r\ny\r\n"),
"alpha\r\nbeta\r\n"
);
assert_eq!(
preserve_prior_line_endings("alpha\rbeta\n", "x\ny\n"),
"alpha\nbeta\n"
);
}

#[cfg(unix)]
#[tokio::test]
async fn edit_file_tool_preserves_executable_bits() {
Expand Down
Loading