use crate::brain::tools::Tool;
use crate::brain::tools::ToolExecutionContext;
use crate::brain::tools::edit::EditTool;
use serde_json::json;
use tempfile::TempDir;
use uuid::Uuid;
const CRLF_CONFIG: &str = "[app]\r\nname = demo\r\nmode = debug\r\nport = 8080\r\nworkers = 4\r\n";
fn context(dir: &TempDir) -> ToolExecutionContext {
ToolExecutionContext::new(Uuid::new_v4())
.with_working_directory(dir.path().to_path_buf())
.with_auto_approve(true)
}
async fn edit(name: &str, original: &str, input: serde_json::Value) -> String {
let dir = TempDir::new().unwrap();
let path = dir.path().join(name);
std::fs::write(&path, original).unwrap();
let result = EditTool.execute(input, &context(&dir)).await.unwrap();
assert!(result.success, "edit reported failure: {:?}", result.error);
std::fs::read_to_string(&path).unwrap()
}
fn assert_bytes(actual: &str, expected: &str) {
assert_eq!(
actual.as_bytes(),
expected.as_bytes(),
"\nactual {actual:?}\nexpected {expected:?}"
);
}
#[tokio::test]
async fn deleting_a_line_from_a_crlf_file_changes_only_that_line() {
let after = edit(
"settings.ini",
CRLF_CONFIG,
json!({"path": "settings.ini", "operation": "delete_lines", "start_line": 2, "end_line": 2}),
)
.await;
assert_bytes(
&after,
"[app]\r\nname = demo\r\nport = 8080\r\nworkers = 4\r\n",
);
assert_eq!(after.matches('\r').count(), 4, "one CR per remaining line");
assert!(after.ends_with("\r\n"), "final newline survives");
}
#[tokio::test]
async fn deleting_a_line_from_an_lf_file_keeps_the_final_newline() {
let after = edit(
"notes.txt",
"a\nb\nc\nd\ne\nf\n",
json!({"path": "notes.txt", "operation": "delete_lines", "start_line": 1, "end_line": 1}),
)
.await;
assert_bytes(&after, "a\nc\nd\ne\nf\n");
}
#[tokio::test]
async fn replacing_a_line_on_a_crlf_file_keeps_every_other_byte() {
let after = edit(
"settings.ini",
CRLF_CONFIG,
json!({"path": "settings.ini", "operation": "replace_lines", "start_line": 2, "end_line": 2, "new_text": "mode = release"}),
)
.await;
assert_bytes(
&after,
"[app]\r\nname = demo\r\nmode = release\r\nport = 8080\r\nworkers = 4\r\n",
);
}
#[tokio::test]
async fn replacement_text_adopts_the_file_ending() {
let after = edit(
"settings.ini",
CRLF_CONFIG,
json!({"path": "settings.ini", "operation": "replace_lines", "start_line": 2, "end_line": 2, "new_text": "mode = release\nverified = true"}),
)
.await;
assert_bytes(
&after,
"[app]\r\nname = demo\r\nmode = release\r\nverified = true\r\nport = 8080\r\nworkers = 4\r\n",
);
assert_eq!(after.matches('\r').count(), 6);
}
#[tokio::test]
async fn inserting_a_line_on_a_crlf_file_keeps_the_ending() {
let after = edit(
"settings.ini",
"[app]\r\nname = demo\r\nport = 8080\r\n",
json!({"path": "settings.ini", "operation": "insert_line", "line": 2, "text": "mode = debug"}),
)
.await;
assert_bytes(
&after,
"[app]\r\nname = demo\r\nmode = debug\r\nport = 8080\r\n",
);
}
#[tokio::test]
async fn a_file_without_a_final_newline_does_not_gain_one() {
let after = edit(
"tail.txt",
"a\nb\nc",
json!({"path": "tail.txt", "operation": "delete_lines", "start_line": 1, "end_line": 1}),
)
.await;
assert_bytes(&after, "a\nc");
}
#[tokio::test]
async fn deleting_the_last_line_leaves_the_preceding_terminator() {
let after = edit(
"tail.txt",
"a\nb\n",
json!({"path": "tail.txt", "operation": "delete_lines", "start_line": 1, "end_line": 1}),
)
.await;
assert_bytes(&after, "a\n");
}
#[tokio::test]
async fn inserting_at_eof_into_an_unterminated_file_terminates_the_old_last_line() {
let after = edit(
"tail.txt",
"a\nb",
json!({"path": "tail.txt", "operation": "insert_line", "line": 2, "text": "c"}),
)
.await;
assert_bytes(&after, "a\nb\nc");
}
#[tokio::test]
async fn inserting_at_the_top_adds_no_leading_blank_line() {
let after = edit(
"top.txt",
"a\r\nb\r\n",
json!({"path": "top.txt", "operation": "insert_line", "line": 0, "text": "header"}),
)
.await;
assert_bytes(&after, "header\r\na\r\nb\r\n");
}
#[tokio::test]
async fn a_mixed_ending_file_stays_mixed() {
let after = edit(
"mixed.txt",
"a\r\nb\nc\r\n",
json!({"path": "mixed.txt", "operation": "delete_lines", "start_line": 1, "end_line": 1}),
)
.await;
assert_bytes(&after, "a\r\nc\r\n");
}
#[tokio::test]
async fn replacing_a_range_with_several_lines_keeps_the_ending() {
let after = edit(
"range.txt",
"a\r\nb\r\nc\r\nd\r\n",
json!({"path": "range.txt", "operation": "replace_lines", "start_line": 1, "end_line": 2, "new_text": "x\ny"}),
)
.await;
assert_bytes(&after, "a\r\nx\r\ny\r\nd\r\n");
}
#[tokio::test]
async fn a_lone_carriage_return_is_not_a_line_break() {
let after = edit(
"old.txt",
"a\rb\nc\n",
json!({"path": "old.txt", "operation": "delete_lines", "start_line": 1, "end_line": 1}),
)
.await;
assert_bytes(&after, "a\rb\n");
}
#[tokio::test]
async fn the_reported_line_count_still_matches_the_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.ini");
std::fs::write(&path, CRLF_CONFIG).unwrap();
let result = EditTool
.execute(
json!({"path": "settings.ini", "operation": "delete_lines", "start_line": 2, "end_line": 2}),
&context(&dir),
)
.await
.unwrap();
assert!(result.success);
assert!(
result.output.contains("Lines: 5 \u{2192} 4"),
"unexpected output: {}",
result.output
);
assert!(
result.output.contains("- mode = debug"),
"the diff should still name the removed line: {}",
result.output
);
}