mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
Compare commits
1
Commits
main
...
boat/issue-7070
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28673c94e3 |
No files matched your search
@@ -1688,9 +1688,10 @@ impl WriteFileTool {
|
||||
input: Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
reject_primitive_unknown(&input, "write", &["path", "content"])?;
|
||||
reject_primitive_unknown(&input, "write", &["path", "content", "line_endings"])?;
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let file_content = required_str(&input, "content")?;
|
||||
let line_endings = contract_line_endings_override(&input)?;
|
||||
check_contract_file_size(file_content.len())?;
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
@@ -1715,20 +1716,9 @@ impl WriteFileTool {
|
||||
check_file_operation_cancelled(context)?;
|
||||
// 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 mut written = if prior_contents.is_empty() {
|
||||
file_content.to_string()
|
||||
} else {
|
||||
let normalized = normalize_contract_line_endings(file_content);
|
||||
let ending = contract_line_ending(&prior_contents);
|
||||
let extra = if ending == "\r\n" {
|
||||
normalized.bytes().filter(|byte| *byte == b'\n').count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
check_contract_file_size(normalized.len().saturating_add(extra))?;
|
||||
restore_contract_line_endings(&normalized, ending)
|
||||
};
|
||||
// silently rewritten with LF line endings. An explicit `line_endings`
|
||||
// request ("lf" or "crlf") overrides the preserved style.
|
||||
let mut written = contract_write_content(file_content, &prior_contents, line_endings)?;
|
||||
guard_edit(
|
||||
&file_path,
|
||||
path_str,
|
||||
@@ -1966,6 +1956,58 @@ fn restore_contract_line_endings(text: &str, ending: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the optional `line_endings` override on the contract `write` tool.
|
||||
///
|
||||
/// `Ok(None)` means "preserve the existing file's style" (also what an absent
|
||||
/// parameter or the literal `"preserve"` mean); `"lf"` and `"crlf"` force a
|
||||
/// specific style.
|
||||
fn contract_line_endings_override(input: &Value) -> Result<Option<&'static str>, ToolError> {
|
||||
match input.get("line_endings") {
|
||||
None => Ok(None),
|
||||
Some(Value::String(value)) => match value.as_str() {
|
||||
"preserve" => Ok(None),
|
||||
"lf" => Ok(Some("lf")),
|
||||
"crlf" => Ok(Some("crlf")),
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"line_endings must be one of \"preserve\", \"lf\", or \"crlf\"; got \"{other}\""
|
||||
))),
|
||||
},
|
||||
Some(_) => Err(ToolError::invalid_input(
|
||||
"line_endings must be a string: \"preserve\", \"lf\", or \"crlf\"",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the text the contract `write` tool persists for `content`.
|
||||
///
|
||||
/// Without an override the existing file's line-ending style is preserved (a
|
||||
/// new file is written verbatim); with `"lf"` or `"crlf"` the content is
|
||||
/// normalized to exactly that style regardless of the prior file.
|
||||
fn contract_write_content(
|
||||
content: &str,
|
||||
prior: &str,
|
||||
override_ending: Option<&str>,
|
||||
) -> Result<String, ToolError> {
|
||||
let ending = match override_ending {
|
||||
Some("lf") => "\n",
|
||||
Some("crlf") => "\r\n",
|
||||
_ => {
|
||||
if prior.is_empty() {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
contract_line_ending(prior)
|
||||
}
|
||||
};
|
||||
let normalized = normalize_contract_line_endings(content);
|
||||
let extra = if ending == "\r\n" {
|
||||
normalized.bytes().filter(|byte| *byte == b'\n').count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
check_contract_file_size(normalized.len().saturating_add(extra))?;
|
||||
Ok(restore_contract_line_endings(&normalized, ending))
|
||||
}
|
||||
|
||||
/// First code point of the placeholder range that carries one non-UTF-8 byte
|
||||
/// through a text edit (Supplementary Private Use Area-A, U+F0000..=U+F00FF).
|
||||
const RAW_BYTE_PLACEHOLDER_BASE: u32 = 0xF_0000;
|
||||
|
||||
@@ -830,6 +830,77 @@ async fn contract_write_over_crlf_file_preserves_crlf_line_endings() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contract_write_lf_override_over_crlf_file_writes_lf() {
|
||||
// An explicit `line_endings: "lf"` must win over the preserved CRLF style,
|
||||
// so a user asking for Unix endings is not silently given CRLF (#7070).
|
||||
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");
|
||||
ctx.note_file_read(&ctx.resolve_path("crlf.txt").expect("resolve"));
|
||||
|
||||
WriteFileTool::execute_contract_write(
|
||||
json!({"path": "crlf.txt", "content": "gamma\ndelta\n", "line_endings": "lf"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("execute");
|
||||
|
||||
let written = fs::read(&path).expect("read");
|
||||
assert_eq!(
|
||||
written, b"gamma\ndelta\n",
|
||||
"explicit lf override must not restore the prior CRLF style"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contract_write_crlf_override_over_lf_file_writes_crlf() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let path = tmp.path().join("lf.txt");
|
||||
fs::write(&path, b"alpha\nbeta\n").expect("initial LF write");
|
||||
ctx.note_file_read(&ctx.resolve_path("lf.txt").expect("resolve"));
|
||||
|
||||
WriteFileTool::execute_contract_write(
|
||||
json!({"path": "lf.txt", "content": "gamma\ndelta\n", "line_endings": "crlf"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("execute");
|
||||
|
||||
let written = fs::read(&path).expect("read");
|
||||
assert_eq!(
|
||||
written, b"gamma\r\ndelta\r\n",
|
||||
"explicit crlf override must normalize LF content to CRLF"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contract_write_rejects_unknown_line_endings_value() {
|
||||
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");
|
||||
ctx.note_file_read(&ctx.resolve_path("crlf.txt").expect("resolve"));
|
||||
|
||||
let error = WriteFileTool::execute_contract_write(
|
||||
json!({"path": "crlf.txt", "content": "gamma\ndelta\n", "line_endings": "mixed"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid line_endings must be refused");
|
||||
assert!(
|
||||
error.to_string().contains("line_endings"),
|
||||
"refusal must name the bad parameter: {error}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&path).expect("read"),
|
||||
b"alpha\r\nbeta\r\n",
|
||||
"a refused write must leave the file unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_prior_line_endings_keeps_the_prior_style() {
|
||||
// Existing CRLF file: incoming LF content is re-emitted as CRLF.
|
||||
|
||||
@@ -133,7 +133,12 @@ impl ToolSpec for WriteTool {
|
||||
"type": "string",
|
||||
"description": "Path to the file to write (relative or absolute)."
|
||||
},
|
||||
"content": { "type": "string", "description": "Content to write to the file." }
|
||||
"content": { "type": "string", "description": "Content to write to the file." },
|
||||
"line_endings": {
|
||||
"type": "string",
|
||||
"enum": ["preserve", "lf", "crlf"],
|
||||
"description": "Line endings for the written file. `preserve` (default) keeps the existing file's style; `lf` forces Unix LF endings; `crlf` forces Windows CRLF endings."
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -567,7 +567,7 @@ fn primitive_schemas_are_separate_and_small_contract_shaped() {
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
["content", "path"]
|
||||
["content", "line_endings", "path"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
|
||||
Reference in new issue
Block a user