返回 CodeWhale
tools.rs
根目录 / crates / tui / src / tools / file / tests / tools.rs
1 use super::*;
2 use tempfile::tempdir;
3
4 async fn read_before_edit(ctx: &ToolContext, path: &str) {
5 ReadFileTool
6 .execute(json!({"path": path}), ctx)
7 .await
8 .expect("read before edit");
9 }
10
11 #[tokio::test]
12 async fn test_read_file_tool() {
13 let tmp = tempdir().expect("tempdir");
14 let ctx = ToolContext::new(tmp.path().to_path_buf());
15
16 // Create a test file
17 let test_file = tmp.path().join("test.txt");
18 fs::write(&test_file, "hello world").expect("write");
19
20 let tool = ReadFileTool;
21 let result = tool
22 .execute(json!({"path": "test.txt"}), &ctx)
23 .await
24 .expect("execute");
25
26 assert!(result.success);
27 assert_eq!(result.content, "hello world");
28 }
29
30 // This test deliberately serializes process-global environment changes
31 // while awaiting the tool path.
32 #[allow(clippy::await_holding_lock)]
33 #[tokio::test]
34 async fn read_file_denies_codewhale_config_backups_and_secret_store() {
35 let _env_lock = crate::test_support::lock_test_env();
36 let tmp = tempdir().expect("tempdir");
37 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
38 let _config_path = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
39 let _legacy_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
40
41 fs::write(tmp.path().join("config.toml"), "api_key = \"secret\"\n").expect("write config");
42 fs::write(
43 tmp.path().join("config.toml.bak"),
44 "api_key = \"old-secret\"\n",
45 )
46 .expect("write config backup");
47 fs::create_dir_all(tmp.path().join("secrets")).expect("create secrets dir");
48 fs::write(
49 tmp.path().join("secrets").join("secrets.json"),
50 r#"{"provider":"secret"}"#,
51 )
52 .expect("write file keyring");
53 fs::write(tmp.path().join("notes.txt"), "ordinary workspace data")
54 .expect("write ordinary file");
55
56 let ctx = ToolContext::new(tmp.path().to_path_buf());
57 for path in ["config.toml", "config.toml.bak", "secrets/secrets.json"] {
58 let err = ReadFileTool
59 .execute(json!({"path": path}), &ctx)
60 .await
61 .expect_err("credential-bearing CodeWhale file must be denied");
62 let message = err.to_string();
63 assert!(message.contains("cannot expose CodeWhale"), "{message}");
64 assert!(message.contains("codewhale config list"), "{message}");
65 }
66
67 let ordinary = ReadFileTool
68 .execute(json!({"path": "notes.txt"}), &ctx)
69 .await
70 .expect("ordinary workspace file should remain readable");
71 assert_eq!(ordinary.content, "ordinary workspace data");
72 }
73
74 #[tokio::test]
75 async fn read_file_ocr_extracts_text_from_image_when_backend_exists() {
76 if !crate::tools::image_ocr::ocr_available() {
77 return;
78 }
79 let fixture =
80 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ocr_hello.png");
81 if !fixture.exists() {
82 return;
83 }
84 let tmp = tempdir().expect("tempdir");
85 fs::copy(&fixture, tmp.path().join("ocr_hello.png")).expect("copy fixture");
86 let ctx = ToolContext::new(tmp.path().to_path_buf());
87
88 let result = match ReadFileTool
89 .execute(json!({"path": "ocr_hello.png"}), &ctx)
90 .await
91 {
92 Ok(result) => result,
93 Err(err) => {
94 // Name is when_backend_exists — skip if live OCR fails after
95 // the availability probe (restricted Vision, etc.).
96 let msg = err.to_string();
97 let _skip_reason = format!("OCR backend probe passed but read_file OCR failed: {msg}");
98 let _ = &_skip_reason;
99 return;
100 }
101 };
102
103 assert!(result.success);
104 assert!(result.content.contains("<image_ocr"));
105 let normalized = result.content.to_uppercase();
106 assert!(
107 normalized.contains("HELLO") && normalized.contains("OCR"),
108 "expected OCR text in read_file result, got {:?}",
109 result.content
110 );
111 }
112
113 #[test]
114 fn parse_pages_arg_accepts_single_page() {
115 assert_eq!(parse_pages_arg("3"), Some((3, 3)));
116 assert_eq!(parse_pages_arg(" 7 "), Some((7, 7)));
117 }
118
119 #[test]
120 fn parse_pages_arg_accepts_range() {
121 assert_eq!(parse_pages_arg("1-5"), Some((1, 5)));
122 assert_eq!(parse_pages_arg("10-20"), Some((10, 20)));
123 // Whitespace around either side of the dash is tolerated so
124 // hand-typed `pages: "1 - 5"` still works.
125 assert_eq!(parse_pages_arg(" 1 - 5 "), Some((1, 5)));
126 }
127
128 #[test]
129 fn parse_pages_arg_rejects_invalid_ranges() {
130 // Caller would otherwise feed `pdftotext -f 5 -l 1`, which
131 // prints nothing — fail loudly so the model can re-issue.
132 assert!(parse_pages_arg("5-1").is_none(), "end < start must reject");
133 // 0-indexed pages aren't a thing in pdftotext; reject so the
134 // caller doesn't get a confusing "no output" silent fail.
135 assert!(
136 parse_pages_arg("0").is_none(),
137 "zero single-page must reject"
138 );
139 assert!(parse_pages_arg("0-3").is_none(), "zero start must reject");
140 // Empty / whitespace-only / non-numeric inputs must reject.
141 assert!(parse_pages_arg("").is_none());
142 assert!(parse_pages_arg(" ").is_none());
143 assert!(parse_pages_arg("abc").is_none());
144 assert!(parse_pages_arg("3.5").is_none(), "floats must reject");
145 }
146
147 #[test]
148 fn parse_pages_arg_rejects_half_open_ranges() {
149 // Half-open ranges like `1-` or `-5` are almost certainly a
150 // typo for `1-N`/`N` rather than intentional input. Reject
151 // them rather than silently extending to u32::MAX or 0.
152 assert!(parse_pages_arg("1-").is_none());
153 assert!(parse_pages_arg("-5").is_none());
154 assert!(parse_pages_arg("-").is_none());
155 }
156
157 #[test]
158 fn parse_pages_arg_rejects_negative_numbers() {
159 // u32::parse on a negative literal returns Err, so the
160 // function reports `None` rather than wrapping into a giant
161 // positive number — defensive but worth pinning.
162 assert!(parse_pages_arg("-3-5").is_none());
163 }
164
165 #[tokio::test]
166 async fn test_read_file_not_found() {
167 let tmp = tempdir().expect("tempdir");
168 let ctx = ToolContext::new(tmp.path().to_path_buf());
169
170 let tool = ReadFileTool;
171 let result = tool.execute(json!({"path": "nonexistent.txt"}), &ctx).await;
172
173 assert!(result.is_err());
174 }
175
176 #[tokio::test]
177 async fn read_file_small_file_returns_unwrapped_contents() {
178 // Small files (≤ 200 lines AND ≤ 16KB, no explicit range) keep
179 // the historical "return contents unchanged" behavior so
180 // existing prompts don't suddenly see <file> tags appear.
181 // Harvested from #1451 — pin the fast-path contract.
182 let tmp = tempdir().expect("tempdir");
183 let ctx = ToolContext::new(tmp.path().to_path_buf());
184 let file = tmp.path().join("small.txt");
185 fs::write(&file, "line 1\nline 2\nline 3\n").expect("write");
186 let tool = ReadFileTool;
187 let result = tool
188 .execute(json!({ "path": "small.txt" }), &ctx)
189 .await
190 .expect("execute");
191 assert!(result.success);
192 assert_eq!(result.content, "line 1\nline 2\nline 3\n");
193 assert!(
194 !result.content.contains("<file"),
195 "small-file fast path must not wrap output"
196 );
197 }
198
199 #[tokio::test]
200 async fn read_file_explicit_range_wraps_in_file_tag_with_one_based_lines() {
201 let tmp = tempdir().expect("tempdir");
202 let ctx = ToolContext::new(tmp.path().to_path_buf());
203 let file = tmp.path().join("ranged.txt");
204 let body: String = (1..=10).map(|n| format!("line {n}\n")).collect();
205 fs::write(&file, &body).expect("write");
206 let tool = ReadFileTool;
207 let result = tool
208 .execute(
209 json!({ "path": "ranged.txt", "start_line": 3, "max_lines": 4 }),
210 &ctx,
211 )
212 .await
213 .expect("execute");
214 assert!(result.success);
215 assert!(
216 result.content.contains("shown_lines=\"3-6\""),
217 "1-based inclusive range must be reflected in shown_lines: {}",
218 result.content
219 );
220 assert!(
221 result.content.contains("next_start_line=\"7\""),
222 "next_start_line must point one past the last shown line: {}",
223 result.content
224 );
225 assert!(
226 result.content.contains(" 3│ line 3"),
227 "rendered lines must start at the requested line number"
228 );
229 assert!(
230 result.content.contains(" 6│ line 6"),
231 "rendered lines must end at the last in-range line"
232 );
233 assert!(
234 !result.content.contains(" 7│ line 7"),
235 "lines past max_lines must be excluded"
236 );
237 assert!(result.content.contains("truncated=\"true\""));
238 }
239
240 #[tokio::test]
241 async fn read_file_range_beyond_total_returns_no_content_sentinel() {
242 let tmp = tempdir().expect("tempdir");
243 let ctx = ToolContext::new(tmp.path().to_path_buf());
244 let file = tmp.path().join("short.txt");
245 fs::write(&file, "only\nthree\nlines\n").expect("write");
246 let tool = ReadFileTool;
247 let result = tool
248 .execute(json!({ "path": "short.txt", "start_line": 99 }), &ctx)
249 .await
250 .expect("execute");
251 assert!(
252 result.success,
253 "out-of-range must not raise — it's a sentinel"
254 );
255 assert!(result.content.contains("[NO CONTENT]"));
256 assert!(result.content.contains("shown_lines=\"none\""));
257 assert!(result.content.contains("truncated=\"false\""));
258 }
259
260 /// 2026-08-04 review: a `start_line:"1200"` string (or any wrong type) used
261 /// to fall back SILENTLY to the defaults, returning lines 1-500 — the head
262 /// of the file dressed up as the window the model asked for. Wrong types
263 /// are errors, matching the shared `optional_u64` contract.
264 #[tokio::test]
265 async fn read_file_refuses_wrongly_typed_range_params_instead_of_defaulting() {
266 let tmp = tempdir().expect("tempdir");
267 let ctx = ToolContext::new(tmp.path().to_path_buf());
268 fs::write(tmp.path().join("any.txt"), "x\ny\nz\n").expect("write");
269 let tool = ReadFileTool;
270 for bad in [json!("1200"), json!(-5), json!(2.5), json!([1200])] {
271 let err = tool
272 .execute(json!({ "path": "any.txt", "start_line": bad }), &ctx)
273 .await
274 .expect_err("wrongly typed start_line must error, never default");
275 assert!(
276 err.to_string().contains("start_line"),
277 "error names the field: {err}"
278 );
279 let err = tool
280 .execute(json!({ "path": "any.txt", "max_lines": bad }), &ctx)
281 .await
282 .expect_err("wrongly typed max_lines must error, never default");
283 assert!(
284 err.to_string().contains("max_lines"),
285 "error names the field: {err}"
286 );
287 }
288 // Null still reads as absent, consistent with the strictness lane.
289 let ok = tool
290 .execute(json!({ "path": "any.txt", "start_line": null }), &ctx)
291 .await
292 .expect("null is absence, not a type error");
293 assert!(ok.success);
294 }
295
296 #[tokio::test]
297 async fn read_file_rejects_zero_start_line_and_zero_max_lines() {
298 let tmp = tempdir().expect("tempdir");
299 let ctx = ToolContext::new(tmp.path().to_path_buf());
300 fs::write(tmp.path().join("any.txt"), "x\n").expect("write");
301 let tool = ReadFileTool;
302 let zero_start = tool
303 .execute(json!({ "path": "any.txt", "start_line": 0 }), &ctx)
304 .await;
305 assert!(zero_start.is_err(), "start_line=0 must error (1-based)");
306 let zero_max = tool
307 .execute(json!({ "path": "any.txt", "max_lines": 0 }), &ctx)
308 .await;
309 assert!(zero_max.is_err(), "max_lines=0 must error");
310 }
311
312 #[tokio::test]
313 async fn read_file_byte_truncation_keeps_head_and_tail() {
314 // Long lines force the 16 KiB bound before the line cap. The model must
315 // see both ends of the window (qwen-style head = budget/5 + tail) and the
316 // recovery note must name the original path for a re-read.
317 let tmp = tempdir().expect("tempdir");
318 let ctx = ToolContext::new(tmp.path().to_path_buf());
319 let file = tmp.path().join("wide.txt");
320 let body: String = (1..=40)
321 .map(|n| format!("LINE{n}_START {} LINE{n}_END\n", "x".repeat(600)))
322 .collect();
323 assert!(body.len() > 16 * 1024, "fixture must exceed 16KB");
324 fs::write(&file, &body).expect("write");
325
326 let tool = ReadFileTool;
327 let result = tool
328 .execute(
329 json!({ "path": "wide.txt", "start_line": 1, "max_lines": 40 }),
330 &ctx,
331 )
332 .await
333 .expect("execute");
334
335 assert!(result.success);
336 assert!(result.content.contains("truncated=\"true\""));
337 assert!(
338 result.content.contains("LINE1_START"),
339 "head of the window must survive: {}",
340 &result.content[..result.content.len().min(400)]
341 );
342 assert!(
343 result.content.contains("LINE40_END") || result.content.contains("LINE40_START"),
344 "tail of the window must survive: {}",
345 &result.content[result.content.len().saturating_sub(400)..]
346 );
347 assert!(
348 result.content.contains("[CONTENT TRUNCATED]"),
349 "head/tail separator missing: {}",
350 result.content
351 );
352 assert!(
353 result.content.contains("path=\"wide.txt\""),
354 "recovery path must name the file: {}",
355 result.content
356 );
357 assert!(
358 result
359 .content
360 .contains("Re-read narrower windows to see the middle"),
361 "byte-truncation recovery note must give actionable advice: {}",
362 result.content
363 );
364 assert!(
365 result.content.contains("start_line=1 max_lines=20"),
366 "the note names a concrete narrower window: {}",
367 result.content
368 );
369 // Middle of the window should be the part omitted under a head+tail budget.
370 assert!(
371 !result.content.contains("LINE20_START") || result.content.contains("[CONTENT TRUNCATED]"),
372 "expected truncation of the middle: {}",
373 result.content
374 );
375 }
376
377 #[tokio::test]
378 async fn read_file_clamps_max_lines_to_hard_cap() {
379 let tmp = tempdir().expect("tempdir");
380 let ctx = ToolContext::new(tmp.path().to_path_buf());
381 let file = tmp.path().join("bigish.txt");
382 let body: String = (1..=600).map(|n| format!("L{n}\n")).collect();
383 fs::write(&file, &body).expect("write");
384 let tool = ReadFileTool;
385 let result = tool
386 .execute(json!({ "path": "bigish.txt", "max_lines": 5000 }), &ctx)
387 .await
388 .expect("execute");
389 // Hard cap is 500 lines; line 500 must appear, line 501 must not.
390 assert!(
391 result.content.contains(" 500│ L500"),
392 "line 500 should be in the window (max_lines clamped to 500)"
393 );
394 assert!(
395 !result.content.contains(" 501│ L501"),
396 "line 501 must be outside the clamped window"
397 );
398 assert!(result.content.contains("next_start_line=\"501\""));
399 assert!(result.content.contains("truncated=\"true\""));
400 }
401
402 #[tokio::test]
403 async fn read_file_large_file_without_range_uses_default_window() {
404 // A file over 200 lines / 16KB with no explicit range still
405 // gets the default window, not the unbounded raw content —
406 // this is the entire point of the patch (token-budget control).
407 let tmp = tempdir().expect("tempdir");
408 let ctx = ToolContext::new(tmp.path().to_path_buf());
409 let file = tmp.path().join("big.txt");
410 let body: String = (1..=250).map(|n| format!("row {n}\n")).collect();
411 fs::write(&file, &body).expect("write");
412 let tool = ReadFileTool;
413 let result = tool
414 .execute(json!({ "path": "big.txt" }), &ctx)
415 .await
416 .expect("execute");
417 // 250 rows is ~1.7 KB — far inside the 16 KB byte budget — so it reads in
418 // ONE call. The old 200-line default truncated here and charged a second
419 // round trip to fetch 50 lines, which is what this change removes.
420 // No `<file …>` envelope: at 250 lines / ~1.7 KB it now takes the
421 // whole-file path and comes back as plain text, which is the point.
422 assert!(result.content.contains("row 1"));
423 assert!(result.content.contains("row 250"));
424 assert!(
425 !result.content.contains("next_start_line"),
426 "a 250-line, ~1.7 KB file must not window: {}",
427 result.content
428 );
429
430 // Past the line cap it still windows, because the cap is a real guard for
431 // pathologically short lines.
432 let many = tmp.path().join("many.txt");
433 let body: String = (1..=600).map(|n| format!("row {n}\n")).collect();
434 fs::write(&many, &body).expect("write");
435 let windowed = tool
436 .execute(json!({ "path": "many.txt" }), &ctx)
437 .await
438 .expect("execute");
439 assert!(windowed.content.contains("shown_lines=\"1-500\""));
440 assert!(windowed.content.contains("next_start_line=\"501\""));
441 }
442
443 #[tokio::test]
444 async fn read_file_streamed_range_on_large_file_matches_windowed_contract() {
445 // Over 16KB forces the streamed BufRead path even without an
446 // explicit range; assert the ranged output stays byte-compatible
447 // with the historical full-read implementation.
448 let tmp = tempdir().expect("tempdir");
449 let ctx = ToolContext::new(tmp.path().to_path_buf());
450 let file = tmp.path().join("large.txt");
451 let body: String = (1..=2000)
452 .map(|n| format!("line {n} {}\n", "x".repeat(20)))
453 .collect();
454 assert!(body.len() > 16 * 1024, "fixture must exceed 16KB");
455 fs::write(&file, &body).expect("write");
456
457 let tool = ReadFileTool;
458 let result = tool
459 .execute(
460 json!({ "path": "large.txt", "start_line": 1500, "max_lines": 10 }),
461 &ctx,
462 )
463 .await
464 .expect("execute");
465
466 assert!(result.success);
467 assert!(result.content.contains("total_lines=\"2000\""));
468 assert!(result.content.contains("shown_lines=\"1500-1509\""));
469 assert!(result.content.contains("next_start_line=\"1510\""));
470 assert!(result.content.contains(" 1500│ line 1500"));
471 assert!(result.content.contains(" 1509│ line 1509"));
472 assert!(!result.content.contains(" 1510│"));
473 assert!(result.content.contains(
474 "[TRUNCATED] Showing lines 1500-1509 of 2000. To continue, call File with action=\"read\" path=\"large.txt\" start_line=1510 max_lines=10"
475 ));
476 assert!(!result.content.contains("read_file"), "{}", result.content);
477
478 // Default window (no range) on the same large file starts at line 1.
479 let default_window = tool
480 .execute(json!({ "path": "large.txt" }), &ctx)
481 .await
482 .expect("execute");
483 assert!(default_window.content.contains("shown_lines=\"1-500\""));
484 assert!(default_window.content.contains("next_start_line=\"501\""));
485 assert!(default_window.content.contains(" 1│ line 1"));
486
487 // Paging past EOF returns the no-content sentinel, not an error.
488 let past_end = tool
489 .execute(json!({ "path": "large.txt", "start_line": 5000 }), &ctx)
490 .await
491 .expect("execute");
492 assert!(past_end.content.contains("[NO CONTENT]"));
493 assert!(past_end.content.contains("shown_lines=\"none\""));
494 }
495
496 #[tokio::test]
497 async fn read_file_streamed_range_rejects_invalid_utf8_like_full_read() {
498 let tmp = tempdir().expect("tempdir");
499 let ctx = ToolContext::new(tmp.path().to_path_buf());
500 let file = tmp.path().join("mixed.bin");
501 // Valid first lines, invalid bytes later: the streamed path must
502 // still fail the whole read like read_to_string did.
503 let mut bytes = b"good line\n".repeat(5);
504 bytes.extend_from_slice(&[0xFF, 0xFE, b'\n']);
505 fs::write(&file, &bytes).expect("write");
506
507 let err = ReadFileTool
508 .execute(
509 json!({ "path": "mixed.bin", "start_line": 1, "max_lines": 2 }),
510 &ctx,
511 )
512 .await
513 .expect_err("invalid UTF-8 must error");
514 let message = err.to_string();
515 assert!(message.contains("Failed to read"), "{message}");
516 assert!(message.contains("valid UTF-8"), "{message}");
517 }
518
519 #[tokio::test]
520 async fn test_read_file_missing_path() {
521 let tmp = tempdir().expect("tempdir");
522 let ctx = ToolContext::new(tmp.path().to_path_buf());
523
524 let tool = ReadFileTool;
525 let result = tool.execute(json!({}), &ctx).await;
526
527 assert!(result.is_err());
528 let err = result.unwrap_err();
529 assert!(
530 err.to_string()
531 .contains("Failed to validate input: missing required field 'path'")
532 );
533 }
534
535 #[test]
536 fn pdf_detected_by_extension() {
537 let tmp = tempdir().expect("tempdir");
538 let path = tmp.path().join("paper.PDF");
539 fs::write(&path, b"not really a pdf, but extension says yes").unwrap();
540 assert!(is_pdf(&path).unwrap());
541 }
542
543 #[test]
544 fn pdf_detected_by_magic_bytes_without_extension() {
545 let tmp = tempdir().expect("tempdir");
546 let path = tmp.path().join("blob");
547 fs::write(&path, b"%PDF-1.7\nrest of bytes").unwrap();
548 assert!(is_pdf(&path).unwrap());
549 }
550
551 #[test]
552 fn non_pdf_not_detected() {
553 let tmp = tempdir().expect("tempdir");
554 let path = tmp.path().join("notes.txt");
555 fs::write(&path, "hello").unwrap();
556 assert!(!is_pdf(&path).unwrap());
557 }
558
559 #[test]
560 fn pages_arg_parses_single_and_range() {
561 assert_eq!(parse_pages_arg("5"), Some((5, 5)));
562 assert_eq!(parse_pages_arg("1-10"), Some((1, 10)));
563 assert_eq!(parse_pages_arg(" 3 - 7 "), Some((3, 7)));
564 assert_eq!(parse_pages_arg("0"), None);
565 assert_eq!(parse_pages_arg("10-3"), None);
566 assert_eq!(parse_pages_arg(""), None);
567 assert_eq!(parse_pages_arg("abc"), None);
568 }
569
570 /// Sample PDF shipped with the repo for parity tests against the
571 /// pure-Rust extractor. 38 pages, born-digital LaTeX (arXiv 2512.24601).
572 /// Path is workspace-root-relative because the fixture lives outside
573 /// the tui crate.
574 const SAMPLE_PDF_PATH: &str = "../../docs/2512.24601v2.pdf";
575
576 fn sample_pdf_present() -> bool {
577 std::path::Path::new(SAMPLE_PDF_PATH).exists()
578 }
579
580 #[test]
581 fn clean_pdf_text_collapses_consecutive_blank_lines() {
582 let raw = "line1\n\n\n\n\nline2\n\n\nline3";
583 let cleaned = super::clean_pdf_text(raw);
584 assert_eq!(cleaned, "line1\n\nline2\n\nline3");
585 }
586
587 #[test]
588 fn clean_pdf_text_replaces_nul_bytes_with_replacement_char() {
589 let raw = "hello\0world";
590 let cleaned = super::clean_pdf_text(raw);
591 assert!(!cleaned.contains('\0'));
592 assert!(cleaned.contains('\u{FFFD}'));
593 }
594
595 #[test]
596 fn clean_pdf_text_replaces_non_breaking_spaces() {
597 let raw = "hello\u{A0}world";
598 let cleaned = super::clean_pdf_text(raw);
599 assert!(!cleaned.contains('\u{A0}'));
600 assert_eq!(cleaned, "hello world");
601 }
602
603 #[test]
604 fn clean_pdf_text_trims_trailing_whitespace() {
605 let raw = "hello ";
606 let cleaned = super::clean_pdf_text(raw);
607 assert_eq!(cleaned, "hello");
608 }
609
610 #[test]
611 fn clean_pdf_text_preserves_leading_indentation() {
612 let raw = " indented line\nregular line";
613 let cleaned = super::clean_pdf_text(raw);
614 assert_eq!(cleaned, " indented line\nregular line");
615 }
616
617 #[tokio::test]
618 async fn read_file_pdf_path_uses_optional_pdftotext_adapter() {
619 if !sample_pdf_present() || crate::dependencies::resolve_pdftotext().is_none() {
620 return;
621 }
622 let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../");
623 let ctx = ToolContext::new(workspace);
624 let result = ReadFileTool
625 .execute(json!({"path": "docs/2512.24601v2.pdf", "pages": "1"}), &ctx)
626 .await
627 .expect("execute");
628 assert!(result.success);
629 assert!(
630 result.content.contains("Recursive Language Models"),
631 "page-1 extraction must surface the title"
632 );
633 }
634
635 #[tokio::test]
636 async fn test_write_file_tool() {
637 let tmp = tempdir().expect("tempdir");
638 let ctx = ToolContext::new(tmp.path().to_path_buf());
639
640 let tool = WriteFileTool;
641 let result = tool
642 .execute(
643 json!({"path": "output.txt", "content": "test content"}),
644 &ctx,
645 )
646 .await
647 .expect("execute");
648
649 assert!(result.success);
650 // New file → "Created …" summary; the unified diff above the summary
651 // primes the TUI's diff-aware renderer (#505).
652 assert!(result.content.contains("Created"), "{}", result.content);
653 assert!(result.content.contains("--- a/"), "{}", result.content);
654 assert!(
655 result.content.contains("+test content"),
656 "{}",
657 result.content
658 );
659 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
660 assert_eq!(
661 mutation["files"],
662 json!([{ "path": "output.txt", "outcome": "created" }])
663 );
664 assert!(
665 mutation["diff"]
666 .as_str()
667 .is_some_and(|diff| diff.contains("--- a/output.txt")),
668 "{mutation}"
669 );
670 assert!(
671 !mutation["diff"]
672 .as_str()
673 .unwrap_or_default()
674 .contains(&tmp.path().display().to_string()),
675 "receipt headers must not expose the resolved host path: {mutation}"
676 );
677
678 // Verify file was written
679 let written = fs::read_to_string(tmp.path().join("output.txt")).expect("read");
680 assert_eq!(written, "test content");
681 }
682
683 #[tokio::test]
684 async fn test_write_file_creates_dirs() {
685 let tmp = tempdir().expect("tempdir");
686 let ctx = ToolContext::new(tmp.path().to_path_buf());
687
688 let tool = WriteFileTool;
689 let result = tool
690 .execute(
691 json!({"path": "subdir/nested/file.txt", "content": "nested content"}),
692 &ctx,
693 )
694 .await
695 .expect("execute");
696
697 assert!(result.success);
698
699 // Verify nested file was created
700 let written = fs::read_to_string(tmp.path().join("subdir/nested/file.txt")).expect("read");
701 assert_eq!(written, "nested content");
702 }
703
704 #[cfg(unix)]
705 #[tokio::test]
706 async fn write_file_tool_new_file_matches_standard_creation_mode() {
707 use std::os::unix::fs::PermissionsExt;
708
709 let tmp = tempdir().expect("tempdir");
710 let ctx = ToolContext::new(tmp.path().to_path_buf());
711
712 let control = tmp.path().join("control.txt");
713 fs::write(&control, b"control").expect("write control");
714
715 WriteFileTool
716 .execute(
717 json!({"path": "created.txt", "content": "from write_file"}),
718 &ctx,
719 )
720 .await
721 .expect("execute");
722
723 let control_mode = fs::metadata(&control)
724 .expect("control metadata")
725 .permissions()
726 .mode()
727 & 0o777;
728 let created_mode = fs::metadata(tmp.path().join("created.txt"))
729 .expect("created metadata")
730 .permissions()
731 .mode()
732 & 0o777;
733 assert_eq!(created_mode, control_mode);
734 }
735
736 #[cfg(unix)]
737 #[tokio::test]
738 async fn write_file_tool_preserves_existing_mode() {
739 use std::os::unix::fs::PermissionsExt;
740
741 let tmp = tempdir().expect("tempdir");
742 let ctx = ToolContext::new(tmp.path().to_path_buf());
743 let path = tmp.path().join("shared.txt");
744 fs::write(&path, b"before").expect("initial write");
745 fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).expect("set shared permissions");
746
747 WriteFileTool
748 .execute(json!({"path": "shared.txt", "content": "after"}), &ctx)
749 .await
750 .expect("execute");
751
752 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
753 assert_eq!(mode, 0o664);
754 assert_eq!(fs::read_to_string(&path).expect("read"), "after");
755 }
756
757 #[cfg(unix)]
758 #[tokio::test]
759 async fn edit_file_tool_preserves_executable_bits() {
760 use std::os::unix::fs::PermissionsExt;
761
762 let tmp = tempdir().expect("tempdir");
763 let ctx = ToolContext::new(tmp.path().to_path_buf());
764 let path = tmp.path().join("script.sh");
765 fs::write(&path, b"#!/bin/sh\nexit 0\n").expect("initial write");
766 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
767 .expect("set executable permissions");
768 read_before_edit(&ctx, "script.sh").await;
769
770 EditFileTool
771 .execute(
772 json!({
773 "path": "script.sh",
774 "search": "exit 0",
775 "replace": "exit 1"
776 }),
777 &ctx,
778 )
779 .await
780 .expect("execute");
781
782 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
783 assert_eq!(mode, 0o755);
784 assert_eq!(
785 fs::read_to_string(&path).expect("read"),
786 "#!/bin/sh\nexit 1\n"
787 );
788 }
789
790 #[tokio::test]
791 async fn edit_file_refuses_brace_collapsed_match_arm_payload() {
792 let tmp = tempdir().expect("tempdir");
793 let ctx = ToolContext::new(tmp.path().to_path_buf());
794 let path = tmp.path().join("arm.rs");
795 let original = r#"match outcome {
796 SendMessageOutcome::Finished {
797 status: TurnOutcomeStatus::Interrupted,
798 ..
799 } => self.pause_goal_after_interruption().await,
800 SendMessageOutcome::Finished {
801 status: TurnOutcomeStatus::Completed,
802 ..
803 } => {}
804 }
805 "#;
806 fs::write(&path, original).expect("write");
807 read_before_edit(&ctx, "arm.rs").await;
808
809 let search = r#"SendMessageOutcome::Finished {
810 status: TurnOutcomeStatus::Interrupted,
811 ..
812 } => self.pause_goal_after_interruption().await,"#;
813 // Corrupted host payload: brace block collapsed to empty brackets.
814 let replace = "[
815
816 ] => {},";
817 let err = EditFileTool
818 .execute(
819 json!({
820 "path": "arm.rs",
821 "search": search,
822 "replace": replace,
823 }),
824 &ctx,
825 )
826 .await
827 .expect_err("corrupted brace collapse must fail closed");
828 let msg = err.to_string();
829 assert!(
830 msg.contains("corrupted") || msg.contains("collapsed") || msg.contains("unbalanced"),
831 "unexpected error: {msg}"
832 );
833 assert_eq!(fs::read_to_string(&path).expect("read"), original);
834 }
835
836 #[tokio::test]
837 async fn edit_file_preserves_rust_match_arm_braces() {
838 let tmp = tempdir().expect("tempdir");
839 let ctx = ToolContext::new(tmp.path().to_path_buf());
840 let path = tmp.path().join("arm.rs");
841 let original = r#"match outcome {
842 SendMessageOutcome::Finished {
843 status: TurnOutcomeStatus::Interrupted,
844 ..
845 } => self.pause_goal_after_interruption().await,
846 other => {}
847 }
848 "#;
849 fs::write(&path, original).expect("write");
850 read_before_edit(&ctx, "arm.rs").await;
851
852 let search = r#"SendMessageOutcome::Finished {
853 status: TurnOutcomeStatus::Interrupted,
854 ..
855 } => self.pause_goal_after_interruption().await,"#;
856 let replace = r#"SendMessageOutcome::Finished {
857 status: TurnOutcomeStatus::Interrupted,
858 ..
859 } => {
860 // stay active
861 let _ = self.tx_event.send(Event::status("ok".into())).await;
862 }"#;
863 EditFileTool
864 .execute(
865 json!({
866 "path": "arm.rs",
867 "search": search,
868 "replace": replace,
869 }),
870 &ctx,
871 )
872 .await
873 .expect("brace-heavy replace must apply");
874 let updated = fs::read_to_string(&path).expect("read");
875 assert!(updated.contains("stay active"), "{updated}");
876 assert!(
877 updated.contains("SendMessageOutcome::Finished"),
878 "{updated}"
879 );
880 assert!(
881 !updated.contains("pause_goal_after_interruption"),
882 "{updated}"
883 );
884 }
885
886 #[tokio::test]
887 async fn test_edit_file_tool() {
888 let tmp = tempdir().expect("tempdir");
889 let ctx = ToolContext::new(tmp.path().to_path_buf());
890
891 // Create a file to edit
892 let test_file = tmp.path().join("edit_me.txt");
893 fs::write(&test_file, "hello world").expect("write");
894 read_before_edit(&ctx, "edit_me.txt").await;
895
896 let tool = EditFileTool;
897 let result = tool
898 .execute(
899 json!({"path": "edit_me.txt", "search": "hello", "replace": "hi"}),
900 &ctx,
901 )
902 .await
903 .expect("execute");
904
905 assert!(result.success);
906 assert!(result.content.contains("Replaced 1 occurrence"));
907 // Inline diff (#505) — the unified diff lands above the summary
908 // line so the TUI's diff-aware renderer kicks in.
909 assert!(result.content.contains("--- a/"), "{}", result.content);
910 assert!(
911 result.content.contains("-hello world"),
912 "{}",
913 result.content
914 );
915 assert!(result.content.contains("+hi world"), "{}", result.content);
916 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
917 assert_eq!(
918 mutation["files"],
919 json!([{ "path": "edit_me.txt", "outcome": "updated" }])
920 );
921 let receipt_diff = mutation["diff"].as_str().expect("receipt diff");
922 assert!(receipt_diff.contains("--- a/edit_me.txt"), "{receipt_diff}");
923 assert!(receipt_diff.contains("-hello world"), "{receipt_diff}");
924 assert!(receipt_diff.contains("+hi world"), "{receipt_diff}");
925 assert!(
926 !receipt_diff.contains(&tmp.path().display().to_string()),
927 "receipt headers must not expose the resolved host path: {receipt_diff}"
928 );
929
930 // Verify edit was applied
931 let edited = fs::read_to_string(&test_file).expect("read");
932 assert_eq!(edited, "hi world");
933 }
934
935 #[tokio::test]
936 async fn edit_file_matches_lf_search_in_crlf_file_and_preserves_crlf() {
937 let tmp = tempdir().expect("tempdir");
938 let ctx = ToolContext::new(tmp.path().to_path_buf());
939 let test_file = tmp.path().join("crlf.py");
940 fs::write(
941 &test_file,
942 b"def greet(name):\r\n print(name)\r\n\r\ndef add(a, b):\r\n return a + b\r\n",
943 )
944 .expect("write");
945 read_before_edit(&ctx, "crlf.py").await;
946
947 let result = EditFileTool
948 .execute(
949 json!({
950 "path": "crlf.py",
951 "search": "def add(a, b):\n return a + b",
952 "replace": "def add(a, b):\n return a * b",
953 }),
954 &ctx,
955 )
956 .await
957 .expect("LF model input should edit a CRLF file");
958
959 assert!(result.success, "{}", result.content);
960 assert_eq!(
961 fs::read(&test_file).expect("read"),
962 b"def greet(name):\r\n print(name)\r\n\r\ndef add(a, b):\r\n return a * b\r\n",
963 );
964 }
965
966 #[test]
967 fn edit_file_sparse_crlf_positions_map_utf8_range_through_eof() {
968 let original = "前\r\n尾";
969 let (normalized, crlf_positions) = normalize_crlf_with_positions(original);
970
971 assert_eq!(normalized, "前\n尾");
972 assert_eq!(crlf_positions.as_deref(), Some(&[3][..]));
973 assert_eq!(
974 map_normalized_range((0, normalized.len()), crlf_positions.as_deref()),
975 (0, original.len()),
976 );
977 }
978
979 #[tokio::test]
980 async fn edit_file_maps_utf8_crlf_match_ending_at_eof() {
981 let tmp = tempdir().expect("tempdir");
982 let ctx = ToolContext::new(tmp.path().to_path_buf());
983 let test_file = tmp.path().join("utf8-eof-crlf.txt");
984 fs::write(&test_file, "前\r\n尾").expect("write");
985 read_before_edit(&ctx, "utf8-eof-crlf.txt").await;
986
987 EditFileTool
988 .execute(
989 json!({
990 "path": "utf8-eof-crlf.txt",
991 "search": "前\n尾",
992 "replace": "始\n终",
993 }),
994 &ctx,
995 )
996 .await
997 .expect("UTF-8 CRLF match should map through EOF");
998
999 assert_eq!(fs::read(&test_file).expect("read"), "始\r\n终".as_bytes(),);
1000 }
1001
1002 #[tokio::test]
1003 async fn edit_file_normalizes_multiline_replacement_for_single_line_crlf_match() {
1004 let tmp = tempdir().expect("tempdir");
1005 let ctx = ToolContext::new(tmp.path().to_path_buf());
1006 let test_file = tmp.path().join("single-line-crlf.txt");
1007 fs::write(&test_file, b"alpha\r\nomega\r\n").expect("write");
1008 read_before_edit(&ctx, "single-line-crlf.txt").await;
1009
1010 EditFileTool
1011 .execute(
1012 json!({
1013 "path": "single-line-crlf.txt",
1014 "search": "omega",
1015 "replace": "beta\ngamma",
1016 }),
1017 &ctx,
1018 )
1019 .await
1020 .expect("replacement should follow the file's CRLF style");
1021
1022 assert_eq!(
1023 fs::read(&test_file).expect("read"),
1024 b"alpha\r\nbeta\r\ngamma\r\n",
1025 );
1026 }
1027
1028 #[tokio::test]
1029 async fn edit_file_normalizes_crlf_and_mixed_replacement_for_lf_file() {
1030 let tmp = tempdir().expect("tempdir");
1031 let ctx = ToolContext::new(tmp.path().to_path_buf());
1032 let test_file = tmp.path().join("lf.txt");
1033 fs::write(&test_file, b"alpha\nomega\n").expect("write");
1034 read_before_edit(&ctx, "lf.txt").await;
1035
1036 EditFileTool
1037 .execute(
1038 json!({
1039 "path": "lf.txt",
1040 "search": "omega",
1041 "replace": "beta\r\ngamma\nfinal",
1042 }),
1043 &ctx,
1044 )
1045 .await
1046 .expect("replacement should follow the file's LF style");
1047
1048 assert_eq!(
1049 fs::read(&test_file).expect("read"),
1050 b"alpha\nbeta\ngamma\nfinal\n",
1051 );
1052 }
1053
1054 #[tokio::test]
1055 async fn edit_file_rejects_logical_duplicate_across_lf_and_crlf() {
1056 let tmp = tempdir().expect("tempdir");
1057 let ctx = ToolContext::new(tmp.path().to_path_buf());
1058 let test_file = tmp.path().join("mixed.txt");
1059 let original = b"same\nblock\r\nsame\r\nblock\r\n";
1060 fs::write(&test_file, original).expect("write");
1061 read_before_edit(&ctx, "mixed.txt").await;
1062
1063 let error = EditFileTool
1064 .execute(
1065 json!({
1066 "path": "mixed.txt",
1067 "search": "same\nblock",
1068 "replace": "changed",
1069 }),
1070 &ctx,
1071 )
1072 .await
1073 .expect_err("logical duplicates must remain non-unique");
1074
1075 assert!(error.to_string().contains("matched 2"), "{error}");
1076 assert_eq!(fs::read(&test_file).expect("read"), original);
1077 }
1078
1079 #[tokio::test]
1080 async fn edit_file_combines_crlf_and_indentation_fuzzy_matching() {
1081 let tmp = tempdir().expect("tempdir");
1082 let ctx = ToolContext::new(tmp.path().to_path_buf());
1083 let test_file = tmp.path().join("fuzzy-crlf.txt");
1084 fs::write(&test_file, "前言\r\n 数据 = 1\r\n").expect("write");
1085 read_before_edit(&ctx, "fuzzy-crlf.txt").await;
1086
1087 let result = EditFileTool
1088 .execute(
1089 json!({
1090 "path": "fuzzy-crlf.txt",
1091 "search": "前言\n 数据 = 1",
1092 "replace": "前言\n 数据 = 2",
1093 }),
1094 &ctx,
1095 )
1096 .await
1097 .expect("indentation fallback should compose with CRLF normalization");
1098
1099 assert!(
1100 result.content.contains("fuzzy indentation match"),
1101 "{}",
1102 result.content
1103 );
1104 assert_eq!(
1105 fs::read(&test_file).expect("read"),
1106 "前言\r\n 数据 = 2\r\n".as_bytes(),
1107 );
1108 }
1109
1110 #[tokio::test]
1111 async fn edit_file_combines_crlf_and_punctuation_fuzzy_matching() {
1112 let tmp = tempdir().expect("tempdir");
1113 let ctx = ToolContext::new(tmp.path().to_path_buf());
1114 let test_file = tmp.path().join("punctuation-crlf.txt");
1115 fs::write(&test_file, "前言\r\n数据 \"x\"\r\n").expect("write");
1116 read_before_edit(&ctx, "punctuation-crlf.txt").await;
1117
1118 let result = EditFileTool
1119 .execute(
1120 json!({
1121 "path": "punctuation-crlf.txt",
1122 "search": "前言\n数据 \u{201C}x\u{201D}",
1123 "replace": "前言\r\n数据 y\n下一行",
1124 }),
1125 &ctx,
1126 )
1127 .await
1128 .expect("punctuation fallback should compose with CRLF normalization");
1129
1130 assert!(
1131 result.content.contains("fuzzy punctuation match"),
1132 "{}",
1133 result.content
1134 );
1135 assert_eq!(
1136 fs::read(&test_file).expect("read"),
1137 "前言\r\n数据 y\r\n下一行\r\n".as_bytes(),
1138 );
1139 }
1140
1141 #[tokio::test]
1142 async fn edit_file_rejects_line_ending_normalized_noop() {
1143 let tmp = tempdir().expect("tempdir");
1144 let ctx = ToolContext::new(tmp.path().to_path_buf());
1145 let test_file = tmp.path().join("noop-crlf.txt");
1146 let original = b"alpha\r\nbeta\r\n";
1147 fs::write(&test_file, original).expect("write");
1148 read_before_edit(&ctx, "noop-crlf.txt").await;
1149
1150 let error = EditFileTool
1151 .execute(
1152 json!({
1153 "path": "noop-crlf.txt",
1154 "search": "alpha\nbeta",
1155 "replace": "alpha\r\nbeta",
1156 }),
1157 &ctx,
1158 )
1159 .await
1160 .expect_err("normalized no-op should be rejected");
1161
1162 assert!(error.to_string().contains("no change intended"), "{error}");
1163 assert_eq!(fs::read(&test_file).expect("read"), original);
1164 }
1165
1166 #[tokio::test]
1167 async fn edit_file_requires_prior_read() {
1168 let tmp = tempdir().expect("tempdir");
1169 let ctx = ToolContext::new(tmp.path().to_path_buf());
1170
1171 let test_file = tmp.path().join("blind.txt");
1172 fs::write(&test_file, "hello world").expect("write");
1173
1174 let err = EditFileTool
1175 .execute(
1176 json!({"path": "blind.txt", "search": "hello", "replace": "hi"}),
1177 &ctx,
1178 )
1179 .await
1180 .expect_err("edit without read should fail");
1181 let message = err.to_string();
1182 assert!(message.contains("not been read"), "{message}");
1183 // The recovery has to be spelled as a call the model can make: `read_file`
1184 // was retired in v0.9.3 and the registry has no fuzzy resolve step.
1185 assert!(message.contains(r#"File with action="read""#), "{message}");
1186 assert!(!message.contains("read_file"), "{message}");
1187
1188 let unchanged = fs::read_to_string(&test_file).expect("read");
1189 assert_eq!(unchanged, "hello world");
1190 }
1191
1192 #[tokio::test]
1193 async fn edit_file_rejects_stale_prior_read() {
1194 let tmp = tempdir().expect("tempdir");
1195 let ctx = ToolContext::new(tmp.path().to_path_buf());
1196
1197 let test_file = tmp.path().join("stale.txt");
1198 fs::write(&test_file, "alpha beta").expect("write");
1199 read_before_edit(&ctx, "stale.txt").await;
1200 fs::write(&test_file, "alpha beta gamma").expect("external write");
1201
1202 let err = EditFileTool
1203 .execute(
1204 json!({"path": "stale.txt", "search": "alpha", "replace": "omega"}),
1205 &ctx,
1206 )
1207 .await
1208 .expect_err("stale read should fail");
1209 let message = err.to_string();
1210 assert!(message.contains("changed since"), "{message}");
1211 assert!(message.contains(r#"File with action="read""#), "{message}");
1212 assert!(!message.contains("read_file"), "{message}");
1213
1214 let unchanged = fs::read_to_string(&test_file).expect("read");
1215 assert_eq!(unchanged, "alpha beta gamma");
1216 }
1217
1218 #[tokio::test]
1219 async fn edit_file_rejects_non_unique_exact_match() {
1220 let tmp = tempdir().expect("tempdir");
1221 let ctx = ToolContext::new(tmp.path().to_path_buf());
1222
1223 let test_file = tmp.path().join("multi.txt");
1224 fs::write(&test_file, "hello world hello").expect("write");
1225 read_before_edit(&ctx, "multi.txt").await;
1226
1227 let err = EditFileTool
1228 .execute(
1229 json!({"path": "multi.txt", "search": "hello", "replace": "hi"}),
1230 &ctx,
1231 )
1232 .await
1233 .expect_err("non-unique exact match should fail");
1234 let message = err.to_string();
1235 assert!(message.contains("non-unique"), "{message}");
1236 assert!(message.contains("matched 2"), "{message}");
1237 // Recovery text must name the live surface. `read_file` is retired and
1238 // cannot dispatch (crates/tui/src/tools/registry.rs:2067).
1239 assert!(
1240 message.contains("call File with action=\"read\""),
1241 "{message}"
1242 );
1243 assert!(!message.contains("read_file"), "{message}");
1244
1245 let unchanged = fs::read_to_string(&test_file).expect("read");
1246 assert_eq!(unchanged, "hello world hello");
1247 }
1248
1249 /// `fuzz` on `edit` was an advertised parameter with no implementation: it
1250 /// was parsed into `let _fuzz` and thrown away, and a live model read the
1251 /// schema as offering "an optional fuzzy-matching flag for the search". The
1252 /// advertisement is gone, so the name now means nothing to `edit` and is
1253 /// refused like any other name with no known meaning — the fuzzy fallbacks it
1254 /// appeared to control run unconditionally either way.
1255 #[tokio::test]
1256 async fn edit_file_refuses_the_retired_fuzz_parameter() {
1257 let tmp = tempdir().expect("tempdir");
1258 let ctx = ToolContext::new(tmp.path().to_path_buf());
1259 let test_file = tmp.path().join("fuzz_retired.txt");
1260 fs::write(&test_file, "hello world").expect("write");
1261 read_before_edit(&ctx, "fuzz_retired.txt").await;
1262
1263 let err = EditFileTool
1264 .execute(
1265 json!({
1266 "path": "fuzz_retired.txt",
1267 "search": "hello",
1268 "replace": "hi",
1269 "fuzz": true,
1270 }),
1271 &ctx,
1272 )
1273 .await
1274 .expect_err("a parameter edit does not implement must be refused");
1275 let msg = err.to_string();
1276 assert!(msg.contains("fuzz"), "must name the parameter: {msg}");
1277 assert!(
1278 msg.contains("was not performed"),
1279 "must deny having edited: {msg}"
1280 );
1281 assert_eq!(
1282 fs::read_to_string(&test_file).expect("read"),
1283 "hello world",
1284 "a refused edit must not touch the file"
1285 );
1286 }
1287
1288 #[tokio::test]
1289 async fn test_edit_file_single_match_has_no_multi_match_warning() {
1290 let tmp = tempdir().expect("tempdir");
1291 let ctx = ToolContext::new(tmp.path().to_path_buf());
1292
1293 let test_file = tmp.path().join("single.txt");
1294 fs::write(&test_file, "hello world").expect("write");
1295 read_before_edit(&ctx, "single.txt").await;
1296
1297 let tool = EditFileTool;
1298 let result = tool
1299 .execute(
1300 json!({"path": "single.txt", "search": "hello", "replace": "hi"}),
1301 &ctx,
1302 )
1303 .await
1304 .expect("execute");
1305
1306 assert!(result.success);
1307 assert!(result.content.contains("Replaced 1 occurrence"));
1308 assert!(!result.content.contains("multiple matches were replaced"));
1309 }
1310
1311 #[tokio::test]
1312 async fn test_edit_file_fuzz_tolerates_leading_whitespace() {
1313 let tmp = tempdir().expect("tempdir");
1314 let ctx = ToolContext::new(tmp.path().to_path_buf());
1315
1316 let test_file = tmp.path().join("fuzzy.txt");
1317 fs::write(
1318 &test_file,
1319 "fn main() {\n if true {\n let value = 1;\n }\n}\n",
1320 )
1321 .expect("write");
1322 read_before_edit(&ctx, "fuzzy.txt").await;
1323
1324 let tool = EditFileTool;
1325 let result = tool
1326 .execute(
1327 json!({
1328 "path": "fuzzy.txt",
1329 "search": "if true {\n let value = 1;\n}",
1330 "replace": " if true {\n let value = 2;\n }"
1331 }),
1332 &ctx,
1333 )
1334 .await
1335 .expect("execute");
1336
1337 assert!(result.success);
1338 assert!(result.content.contains("fuzzy indentation match"));
1339 let edited = fs::read_to_string(&test_file).expect("read");
1340 assert_eq!(
1341 edited,
1342 "fn main() {\n if true {\n let value = 2;\n }\n}\n"
1343 );
1344 }
1345
1346 #[tokio::test]
1347 async fn test_edit_file_fuzz_tolerates_leading_whitespace_after_multibyte_start() {
1348 let tmp = tempdir().expect("tempdir");
1349 let ctx = ToolContext::new(tmp.path().to_path_buf());
1350
1351 let test_file = tmp.path().join("fuzzy_cjk.txt");
1352 fs::write(&test_file, "数据\n").expect("write");
1353 read_before_edit(&ctx, "fuzzy_cjk.txt").await;
1354
1355 let tool = EditFileTool;
1356 let result = tool
1357 .execute(
1358 json!({
1359 "path": "fuzzy_cjk.txt",
1360 "search": " 数据",
1361 "replace": "记录"
1362 }),
1363 &ctx,
1364 )
1365 .await
1366 .expect("execute");
1367
1368 assert!(result.success, "{}", result.content);
1369 assert!(result.content.contains("fuzzy indentation match"));
1370 let edited = fs::read_to_string(&test_file).expect("read");
1371 assert_eq!(edited, "记录\n");
1372 }
1373
1374 #[tokio::test]
1375 async fn test_edit_file_fuzz_tolerates_smart_quote_substitution() {
1376 // The file on disk has ASCII quotes. The search comes from a
1377 // browser paste with curly quotes. Exact match fails; the
1378 // punctuation-normalized fallback should still land the edit.
1379 let tmp = tempdir().expect("tempdir");
1380 let ctx = ToolContext::new(tmp.path().to_path_buf());
1381
1382 let test_file = tmp.path().join("smart.rs");
1383 fs::write(&test_file, "let s = \"hello world\";\n").expect("write");
1384 read_before_edit(&ctx, "smart.rs").await;
1385
1386 let tool = EditFileTool;
1387 let result = tool
1388 .execute(
1389 json!({
1390 "path": "smart.rs",
1391 // \u{201C} \u{201D} are the curly double-quote pair.
1392 "search": "let s = \u{201C}hello world\u{201D};",
1393 "replace": "let s = \"hello universe\";"
1394 }),
1395 &ctx,
1396 )
1397 .await
1398 .expect("execute");
1399
1400 assert!(result.success, "fuzzy punctuation edit should succeed");
1401 assert!(
1402 result.content.contains("fuzzy punctuation match"),
1403 "expected punctuation-fuzz note, got: {}",
1404 result.content
1405 );
1406 let edited = fs::read_to_string(&test_file).expect("read");
1407 assert_eq!(edited, "let s = \"hello universe\";\n");
1408 }
1409
1410 #[tokio::test]
1411 async fn test_edit_file_fuzz_tolerates_smart_quote_after_multibyte_start() {
1412 let tmp = tempdir().expect("tempdir");
1413 let ctx = ToolContext::new(tmp.path().to_path_buf());
1414
1415 let test_file = tmp.path().join("smart_cjk.md");
1416 fs::write(&test_file, "数据 \"x\"\n").expect("write");
1417 read_before_edit(&ctx, "smart_cjk.md").await;
1418
1419 let tool = EditFileTool;
1420 let result = tool
1421 .execute(
1422 json!({
1423 "path": "smart_cjk.md",
1424 "search": "数据 \u{201C}x\u{201D}",
1425 "replace": "数据 y"
1426 }),
1427 &ctx,
1428 )
1429 .await
1430 .expect("execute");
1431
1432 assert!(result.success, "{}", result.content);
1433 assert!(result.content.contains("fuzzy punctuation match"));
1434 let edited = fs::read_to_string(&test_file).expect("read");
1435 assert_eq!(edited, "数据 y\n");
1436 }
1437
1438 #[tokio::test]
1439 async fn test_edit_file_fuzz_tolerates_em_dash_and_nbsp() {
1440 let tmp = tempdir().expect("tempdir");
1441 let ctx = ToolContext::new(tmp.path().to_path_buf());
1442
1443 let test_file = tmp.path().join("dash.md");
1444 // File has an ASCII hyphen and ASCII space.
1445 fs::write(&test_file, "alpha - beta\n").expect("write");
1446 read_before_edit(&ctx, "dash.md").await;
1447
1448 let tool = EditFileTool;
1449 let result = tool
1450 .execute(
1451 json!({
1452 "path": "dash.md",
1453 // Search uses em-dash + NBSP, common after a copy-paste
1454 // from a styled document.
1455 "search": "alpha\u{00A0}\u{2014}\u{00A0}beta",
1456 "replace": "alpha - gamma"
1457 }),
1458 &ctx,
1459 )
1460 .await
1461 .expect("execute");
1462
1463 assert!(result.success);
1464 let edited = fs::read_to_string(&test_file).expect("read");
1465 assert_eq!(edited, "alpha - gamma\n");
1466 }
1467
1468 #[tokio::test]
1469 async fn test_edit_file_not_found() {
1470 let tmp = tempdir().expect("tempdir");
1471 let ctx = ToolContext::new(tmp.path().to_path_buf());
1472
1473 // Create a file without the search string
1474 let test_file = tmp.path().join("no_match.txt");
1475 fs::write(&test_file, "foo bar baz").expect("write");
1476 read_before_edit(&ctx, "no_match.txt").await;
1477
1478 let tool = EditFileTool;
1479 let result = tool
1480 .execute(
1481 json!({"path": "no_match.txt", "search": "hello", "replace": "hi"}),
1482 &ctx,
1483 )
1484 .await;
1485
1486 assert!(result.is_err());
1487 let err = result.unwrap_err();
1488 assert!(err.to_string().contains("not found"));
1489 assert!(err.to_string().contains("call File with action=\"read\""));
1490 assert!(!err.to_string().contains("read_file"));
1491 }
1492
1493 #[tokio::test]
1494 async fn test_edit_file_rejects_identical_search_and_replace() {
1495 let tmp = tempdir().expect("tempdir");
1496 let ctx = ToolContext::new(tmp.path().to_path_buf());
1497
1498 let test_file = tmp.path().join("same.txt");
1499 fs::write(&test_file, "a := \"foo\"").expect("write");
1500
1501 let tool = EditFileTool;
1502 let result = tool
1503 .execute(
1504 json!({
1505 "path": "same.txt",
1506 "search": "a := \"foo\"",
1507 "replace": "a := \"foo\""
1508 }),
1509 &ctx,
1510 )
1511 .await;
1512
1513 assert!(result.is_err());
1514 let err = result.unwrap_err().to_string();
1515 assert!(
1516 err.contains("search and replace are identical"),
1517 "error must explain the no-op input: {err}"
1518 );
1519 // #5003 - the diagnostic must help the model self-correct: it should
1520 // size the payload and point at the root cause instead of a bare
1521 // "no change intended".
1522 assert!(
1523 err.contains("10 chars"),
1524 "error should size the payload: {err}"
1525 );
1526 assert!(
1527 err.contains("Recovery"),
1528 "error should offer recovery: {err}"
1529 );
1530 let unchanged = fs::read_to_string(&test_file).expect("read");
1531 assert_eq!(unchanged, "a := \"foo\"");
1532 }
1533
1534 #[test]
1535 fn test_c_preprocessor_rejects_missing_close() {
1536 let before = "#if FEATURE\nold code\n#endif\n";
1537 let after = "#if FEATURE\nnew code\n";
1538 assert_eq!(
1539 invalid_preprocessor_edit(Path::new("source.c"), before, after),
1540 Some(PREPROCESSOR_CONDITIONAL_ERROR)
1541 );
1542 }
1543
1544 #[test]
1545 fn test_c_preprocessor_rejects_extra_close() {
1546 let before = "#if FEATURE\nold code\n#endif\n";
1547 let after = "#if FEATURE\nnew code\n#endif\n#endif\n";
1548 assert_eq!(
1549 invalid_preprocessor_edit(Path::new("source.hpp"), before, after),
1550 Some(PREPROCESSOR_CONDITIONAL_ERROR)
1551 );
1552 }
1553
1554 #[test]
1555 fn test_c_preprocessor_allows_balanced_block_removal_and_insertion() {
1556 let block = "#ifdef FEATURE\nfeature();\n#endif\n";
1557 assert!(invalid_preprocessor_edit(Path::new("source.cc"), block, "").is_none());
1558 assert!(invalid_preprocessor_edit(Path::new("source.cc"), "", block).is_none());
1559 }
1560
1561 #[test]
1562 fn test_c_preprocessor_allows_in_block_edit() {
1563 let before = "#if FEATURE\nold_call();\n#endif\n";
1564 let after = "#if FEATURE\nnew_call();\n#endif\n";
1565 assert!(invalid_preprocessor_edit(Path::new("source.cxx"), before, after).is_none());
1566 }
1567
1568 #[test]
1569 fn test_non_c_directive_prose_is_not_validated() {
1570 let before = "#if this example is enabled\nexplanation\n#endif\n";
1571 let after = "#if this example is enabled\nupdated explanation\n";
1572 assert!(invalid_preprocessor_edit(Path::new("guide.md"), before, after).is_none());
1573 }
1574
1575 #[test]
1576 fn test_preview_search_for_error_truncates() {
1577 let long_line = "x".repeat(200);
1578 let search = format!("{long_line}\nsecond line\nthird line\nfourth line\n");
1579 let preview = preview_search_for_error(&search);
1580 assert!(preview.lines().count() <= 3);
1581 assert!(preview.contains("..."));
1582 assert!(!preview.contains("fourth line"));
1583 }
1584
1585 #[tokio::test]
1586 async fn test_edit_file_not_found_shows_search_preview() {
1587 // #5003 - when search misses, the error should preview the search text
1588 // so the model can compare what it searched for against the file.
1589 let tmp = tempdir().expect("tempdir");
1590 let ctx = ToolContext::new(tmp.path().to_path_buf());
1591
1592 let test_file = tmp.path().join("preview.txt");
1593 fs::write(&test_file, "foo bar baz").expect("write");
1594 read_before_edit(&ctx, "preview.txt").await;
1595
1596 let tool = EditFileTool;
1597 let result = tool
1598 .execute(
1599 json!({
1600 "path": "preview.txt",
1601 "search": "first line\nsecond line\n",
1602 "replace": "changed"
1603 }),
1604 &ctx,
1605 )
1606 .await;
1607
1608 assert!(result.is_err());
1609 let err = result.unwrap_err().to_string();
1610 assert!(err.contains("Search string not found"));
1611 assert!(
1612 err.contains("first line"),
1613 "error should preview search text: {err}"
1614 );
1615 }
1616
1617 /// #157 / #5209 — `replacement` is an unambiguous synonym for `replace`, so
1618 /// the edit the model asked for is the edit that lands. The #5209 guarantee
1619 /// being protected is that the file and the receipt agree: a reported
1620 /// replacement must correspond to a real one.
1621 #[tokio::test]
1622 async fn edit_file_accepts_replacement_alias_and_applies_the_edit() {
1623 let tmp = tempdir().expect("tempdir");
1624 let ctx = ToolContext::new(tmp.path().to_path_buf());
1625
1626 let test_file = tmp.path().join("test.txt");
1627 fs::write(&test_file, "hello world").expect("write");
1628 read_before_edit(&ctx, "test.txt").await;
1629
1630 let result = EditFileTool
1631 .execute(
1632 json!({"path": "test.txt", "search": "hello", "replacement": "hi"}),
1633 &ctx,
1634 )
1635 .await
1636 .expect("replacement alias must be honored");
1637
1638 assert!(result.success);
1639 assert_eq!(
1640 fs::read_to_string(&test_file).expect("read"),
1641 "hi world",
1642 "the receipt claimed an edit, so the file must actually carry it"
1643 );
1644 }
1645
1646 /// Every cross-harness spelling of the two edit arguments resolves to the
1647 /// same applied edit. A model that guesses from a different harness's prior
1648 /// gets its work done instead of a rejection and a wasted turn (#5209).
1649 #[tokio::test]
1650 async fn edit_file_accepts_every_cross_harness_edit_alias() {
1651 for (search_key, replace_key) in [
1652 ("old_string", "new_string"),
1653 ("old_str", "new_str"),
1654 ("oldText", "newText"),
1655 ("old_text", "new_text"),
1656 ] {
1657 let tmp = tempdir().expect("tempdir");
1658 let ctx = ToolContext::new(tmp.path().to_path_buf());
1659 let path = tmp.path().join("doc.md");
1660 fs::write(&path, "old text line\n").expect("write");
1661 read_before_edit(&ctx, "doc.md").await;
1662
1663 let result = EditFileTool
1664 .execute(
1665 json!({
1666 "path": "doc.md",
1667 search_key: "old text line",
1668 replace_key: "new text line",
1669 }),
1670 &ctx,
1671 )
1672 .await
1673 .unwrap_or_else(|err| panic!("{search_key}/{replace_key} must apply: {err}"));
1674
1675 assert!(result.success, "{search_key}/{replace_key}");
1676 assert_eq!(
1677 fs::read_to_string(&path).expect("read"),
1678 "new text line\n",
1679 "{search_key}/{replace_key} must reach the file"
1680 );
1681 }
1682 }
1683
1684 /// The unified `File` tool takes the same alias path as the inner tool, so
1685 /// the model-facing surface and the dispatch target cannot disagree.
1686 #[tokio::test]
1687 async fn file_tool_action_edit_accepts_new_str_alias() {
1688 use crate::tools::file_tool::FileTool;
1689
1690 let tmp = tempdir().expect("tempdir");
1691 let ctx = ToolContext::new(tmp.path().to_path_buf());
1692 let path = tmp.path().join("doc.md");
1693 fs::write(&path, "old text line\n").expect("write");
1694 read_before_edit(&ctx, "doc.md").await;
1695
1696 let result = FileTool::with_patch("File")
1697 .execute(
1698 json!({
1699 "action": "edit",
1700 "path": "doc.md",
1701 "search": "old text line",
1702 "new_str": "new text line",
1703 }),
1704 &ctx,
1705 )
1706 .await
1707 .expect("File action=edit with new_str must apply");
1708
1709 assert!(result.success);
1710 assert_eq!(fs::read_to_string(&path).expect("read"), "new text line\n");
1711 }
1712
1713 /// An alias that contradicts an explicitly supplied canonical value is
1714 /// ambiguous. Picking one would be the guess this whole path exists to
1715 /// avoid, so it fails and changes nothing.
1716 #[tokio::test]
1717 async fn edit_file_rejects_alias_conflicting_with_canonical_name() {
1718 let tmp = tempdir().expect("tempdir");
1719 let ctx = ToolContext::new(tmp.path().to_path_buf());
1720 let path = tmp.path().join("doc.md");
1721 fs::write(&path, "old text line\n").expect("write");
1722 read_before_edit(&ctx, "doc.md").await;
1723
1724 let err = EditFileTool
1725 .execute(
1726 json!({
1727 "path": "doc.md",
1728 "search": "old text line",
1729 "replace": "one thing",
1730 "new_string": "a different thing",
1731 }),
1732 &ctx,
1733 )
1734 .await
1735 .expect_err("conflicting alias must not be silently resolved");
1736
1737 let msg = err.to_string();
1738 assert!(
1739 msg.contains("`replace`") && msg.contains("`new_string`"),
1740 "must name both spellings: {msg}"
1741 );
1742 assert_eq!(
1743 fs::read_to_string(&path).expect("read"),
1744 "old text line\n",
1745 "nothing may change on an ambiguous call"
1746 );
1747 }
1748
1749 /// An alias that merely repeats the canonical value is a harmless
1750 /// duplicate, not a conflict.
1751 #[tokio::test]
1752 async fn edit_file_accepts_alias_agreeing_with_canonical_name() {
1753 let tmp = tempdir().expect("tempdir");
1754 let ctx = ToolContext::new(tmp.path().to_path_buf());
1755 let path = tmp.path().join("doc.md");
1756 fs::write(&path, "old text line\n").expect("write");
1757 read_before_edit(&ctx, "doc.md").await;
1758
1759 EditFileTool
1760 .execute(
1761 json!({
1762 "path": "doc.md",
1763 "search": "old text line",
1764 "replace": "new text line",
1765 "new_string": "new text line",
1766 }),
1767 &ctx,
1768 )
1769 .await
1770 .expect("agreeing duplicate must be accepted");
1771
1772 assert_eq!(fs::read_to_string(&path).expect("read"), "new text line\n");
1773 }
1774
1775 /// `file_path` is the other widespread spelling of `path` and is accepted on
1776 /// every file action.
1777 #[tokio::test]
1778 async fn file_actions_accept_file_path_alias() {
1779 let tmp = tempdir().expect("tempdir");
1780 let ctx = ToolContext::new(tmp.path().to_path_buf());
1781
1782 WriteFileTool
1783 .execute(json!({"file_path": "note.txt", "content": "first\n"}), &ctx)
1784 .await
1785 .expect("write must accept file_path");
1786 assert_eq!(
1787 fs::read_to_string(tmp.path().join("note.txt")).expect("read"),
1788 "first\n"
1789 );
1790
1791 let read = ReadFileTool
1792 .execute(json!({"file_path": "note.txt"}), &ctx)
1793 .await
1794 .expect("read must accept file_path");
1795 assert!(read.content.contains("first"));
1796
1797 EditFileTool
1798 .execute(
1799 json!({"file_path": "note.txt", "search": "first", "replace": "second"}),
1800 &ctx,
1801 )
1802 .await
1803 .expect("edit must accept file_path");
1804 assert_eq!(
1805 fs::read_to_string(tmp.path().join("note.txt")).expect("read"),
1806 "second\n"
1807 );
1808 }
1809
1810 /// `offset`/`limit` name the same read window as `start_line`/`max_lines`.
1811 /// Before they were translated, a wrong guess was dropped and the model
1812 /// silently got the head of the file instead of the window it asked for.
1813 #[tokio::test]
1814 async fn read_file_accepts_offset_and_limit_aliases() {
1815 let tmp = tempdir().expect("tempdir");
1816 let ctx = ToolContext::new(tmp.path().to_path_buf());
1817 let body: String = (1..=20).map(|n| format!("line {n}\n")).collect();
1818 fs::write(tmp.path().join("many.txt"), &body).expect("write");
1819
1820 let aliased = ReadFileTool
1821 .execute(json!({"path": "many.txt", "offset": 5, "limit": 3}), &ctx)
1822 .await
1823 .expect("offset/limit must be honored");
1824 let canonical = ReadFileTool
1825 .execute(
1826 json!({"path": "many.txt", "start_line": 5, "max_lines": 3}),
1827 &ctx,
1828 )
1829 .await
1830 .expect("canonical read");
1831
1832 assert_eq!(
1833 aliased.content, canonical.content,
1834 "aliases must select the same window as the canonical names"
1835 );
1836 assert!(
1837 aliased.content.contains("line 5") && !aliased.content.contains("line 1\n"),
1838 "must start at the requested offset: {}",
1839 aliased.content
1840 );
1841 }
1842
1843 /// #5209 — unknown keys on edit hard-error even when required fields are present.
1844 #[tokio::test]
1845 async fn edit_file_rejects_unexpected_parameter_names() {
1846 let tmp = tempdir().expect("tempdir");
1847 let ctx = ToolContext::new(tmp.path().to_path_buf());
1848 let path = tmp.path().join("doc.md");
1849 fs::write(&path, "hello\n").expect("write");
1850 read_before_edit(&ctx, "doc.md").await;
1851
1852 let err = EditFileTool
1853 .execute(
1854 json!({
1855 "path": "doc.md",
1856 "search": "hello",
1857 "replace": "hi",
1858 "mystery": true,
1859 }),
1860 &ctx,
1861 )
1862 .await
1863 .expect_err("unexpected params must hard-error");
1864 let msg = err.to_string();
1865 assert!(
1866 msg.contains("unexpected") && msg.contains("mystery"),
1867 "must name unexpected key: {msg}"
1868 );
1869 assert_eq!(fs::read_to_string(&path).expect("read"), "hello\n");
1870 }
1871
1872 #[test]
1873 fn edit_payload_allows_same_brace_delta_unbalanced_fragment() {
1874 // Same-delta unbalanced fragment: both sides open one more brace than
1875 // they close (typical mid-block edit).
1876 let search = " handler({\n a: 1,\n";
1877 let replace = " handler({\n a: 1,\n b: 2,\n";
1878 assert!(
1879 edit_payload_looks_corrupted(search, replace).is_none(),
1880 "same brace delta unbalanced fragment must be allowed"
1881 );
1882
1883 // Unbalanced-to-unbalanced with the same closing delta (e.g. near `});`).
1884 let search = " done();\n });\n";
1885 let replace = " done();\n cleanup();\n });\n";
1886 assert!(
1887 edit_payload_looks_corrupted(search, replace).is_none(),
1888 "unbalanced-to-unbalanced with same delta (e.g. around `}});`) must be allowed"
1889 );
1890 }
1891
1892 #[test]
1893 fn edit_payload_rejects_divergent_brace_delta() {
1894 let search = "fn f() {\n body\n}\n";
1895 let replace = "fn f() {\n body\n"; // lost closing brace
1896 let reason =
1897 edit_payload_looks_corrupted(search, replace).expect("divergent brace delta must reject");
1898 assert!(
1899 reason.contains("brace balance") || reason.contains("unbalanced"),
1900 "reason should mention brace balance: {reason}"
1901 );
1902 }
1903
1904 #[test]
1905 fn edit_payload_still_rejects_empty_bracket_collapse() {
1906 let search = r#"SendMessageOutcome::Finished {
1907 status: TurnOutcomeStatus::Interrupted,
1908 ..
1909 } => self.pause_goal_after_interruption().await,"#;
1910 let replace = "[
1911
1912 ] => {},";
1913 assert!(
1914 edit_payload_looks_corrupted(search, replace).is_some(),
1915 "empty bracket collapse must still fail closed"
1916 );
1917 }
1918
1919 #[test]
1920 fn edit_payload_still_rejects_extreme_shrinkage() {
1921 // Many nested braces in search, collapsed to a tiny stub that lost opens.
1922 let search = "fn long_match_arm() {\n".to_string()
1923 + &" if cond { statement(); }\n".repeat(20)
1924 + "}\n";
1925 let replace = "fn long_match_arm() {}\n";
1926 assert!(
1927 search.len() >= 80,
1928 "fixture must be long enough for shrinkage guard"
1929 );
1930 assert!(
1931 edit_payload_looks_corrupted(&search, replace).is_some(),
1932 "extreme shrinkage with lost braces must still fail closed"
1933 );
1934 }
1935
1936 #[tokio::test]
1937 async fn test_list_dir_tool() {
1938 let tmp = tempdir().expect("tempdir");
1939 let ctx = ToolContext::new(tmp.path().to_path_buf());
1940
1941 // Create some files and directories
1942 fs::write(tmp.path().join("file1.txt"), "").expect("write");
1943 fs::write(tmp.path().join("file2.txt"), "").expect("write");
1944 fs::create_dir(tmp.path().join("subdir")).expect("mkdir");
1945
1946 let tool = ListDirTool;
1947 let result = tool.execute(json!({}), &ctx).await.expect("execute");
1948
1949 assert!(result.success);
1950 assert!(result.content.contains("file1.txt"));
1951 assert!(result.content.contains("file2.txt"));
1952 assert!(result.content.contains("subdir"));
1953 let entries: Value = serde_json::from_str(&result.content).expect("list_dir json");
1954 assert!(entries.as_array().expect("entries").iter().any(|entry| {
1955 entry.get("name").and_then(Value::as_str) == Some("subdir")
1956 && entry.get("is_dir").and_then(Value::as_bool) == Some(true)
1957 }));
1958 }
1959
1960 #[tokio::test]
1961 async fn test_list_dir_with_path() {
1962 let tmp = tempdir().expect("tempdir");
1963 let ctx = ToolContext::new(tmp.path().to_path_buf());
1964
1965 // Create a subdirectory with files
1966 let subdir = tmp.path().join("mydir");
1967 fs::create_dir(&subdir).expect("mkdir");
1968 fs::write(subdir.join("nested.txt"), "").expect("write");
1969
1970 let tool = ListDirTool;
1971 let result = tool
1972 .execute(json!({"path": "mydir"}), &ctx)
1973 .await
1974 .expect("execute");
1975
1976 assert!(result.success);
1977 assert!(result.content.contains("nested.txt"));
1978 }
1979
1980 #[tokio::test]
1981 async fn test_list_dir_small_dir_keeps_plain_array_response() {
1982 let tmp = tempdir().expect("tempdir");
1983 let ctx = ToolContext::new(tmp.path().to_path_buf());
1984 fs::write(tmp.path().join("only.txt"), "").expect("write");
1985
1986 let tool = ListDirTool;
1987 let result = tool.execute(json!({}), &ctx).await.expect("execute");
1988
1989 let parsed: Value = serde_json::from_str(&result.content).expect("json");
1990 assert!(
1991 parsed.is_array(),
1992 "small dirs must keep the historical array shape: {parsed}"
1993 );
1994 assert_eq!(parsed.as_array().unwrap().len(), 1);
1995 }
1996
1997 #[tokio::test]
1998 async fn test_list_dir_caps_entries_with_truncation_metadata() {
1999 let tmp = tempdir().expect("tempdir");
2000 let ctx = ToolContext::new(tmp.path().to_path_buf());
2001 let extra = 7;
2002 for i in 0..LIST_DIR_MAX_ENTRIES + extra {
2003 fs::write(tmp.path().join(format!("f{i:04}.txt")), "").expect("write");
2004 }
2005
2006 let tool = ListDirTool;
2007 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2008
2009 let parsed: Value = serde_json::from_str(&result.content).expect("json");
2010 assert!(parsed.is_object(), "oversized dirs return an object");
2011 assert_eq!(parsed["truncated"], json!(true));
2012 assert_eq!(
2013 parsed["listed_entries"].as_u64().unwrap() as usize,
2014 LIST_DIR_MAX_ENTRIES
2015 );
2016 assert_eq!(
2017 parsed["total_entries"].as_u64().unwrap() as usize,
2018 LIST_DIR_MAX_ENTRIES + extra
2019 );
2020 assert_eq!(
2021 parsed["entries"].as_array().unwrap().len(),
2022 LIST_DIR_MAX_ENTRIES
2023 );
2024 }
2025
2026 #[tokio::test]
2027 async fn test_list_dir_respects_cancel_token() {
2028 let tmp = tempdir().expect("tempdir");
2029 fs::write(tmp.path().join("file.txt"), "").expect("write");
2030 let cancel_token = CancellationToken::new();
2031 cancel_token.cancel();
2032 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
2033
2034 let tool = ListDirTool;
2035 let err = tool
2036 .execute(json!({}), &ctx)
2037 .await
2038 .expect_err("cancelled list_dir should return an error");
2039
2040 assert!(
2041 format!("{err:?}").contains("cancelled"),
2042 "unexpected error: {err:?}"
2043 );
2044 }
2045
2046 #[tokio::test]
2047 async fn test_list_dir_blocking_wrapper_reports_timeout() {
2048 let err = run_blocking_list_dir(Duration::from_millis(1), None, || {
2049 std::thread::sleep(Duration::from_millis(50));
2050 Ok(Value::Array(Vec::new()))
2051 })
2052 .await
2053 .expect_err("slow list_dir worker should time out");
2054
2055 assert!(
2056 matches!(err, ToolError::Timeout { seconds: 1 }),
2057 "unexpected error: {err:?}"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_read_file_tool_properties() {
2063 let tool = ReadFileTool;
2064 assert_eq!(tool.name(), "read_file");
2065 assert!(tool.is_read_only());
2066 assert!(tool.is_sandboxable());
2067 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
2068 }
2069
2070 #[test]
2071 fn test_write_file_tool_properties() {
2072 let tool = WriteFileTool;
2073 assert_eq!(tool.name(), "write_file");
2074 assert!(!tool.is_read_only());
2075 assert!(tool.is_sandboxable());
2076 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2077 }
2078
2079 #[test]
2080 fn test_edit_file_tool_properties() {
2081 let tool = EditFileTool;
2082 assert_eq!(tool.name(), "edit_file");
2083 assert!(!tool.is_read_only());
2084 assert!(tool.is_sandboxable());
2085 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2086 assert!(tool.description().contains("exact search/replace"));
2087 assert!(tool.description().contains("structural"));
2088 }
2089
2090 #[test]
2091 fn test_list_dir_tool_properties() {
2092 let tool = ListDirTool;
2093 assert_eq!(tool.name(), "list_dir");
2094 assert!(tool.is_read_only());
2095 assert!(tool.is_sandboxable());
2096 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
2097 }
2098
2099 #[test]
2100 fn test_parallel_support_flags() {
2101 let read_tool = ReadFileTool;
2102 let list_tool = ListDirTool;
2103 let write_tool = WriteFileTool;
2104
2105 assert!(read_tool.supports_parallel());
2106 assert!(list_tool.supports_parallel());
2107 assert!(!write_tool.supports_parallel());
2108 }
2109
2110 #[test]
2111 fn test_input_schemas() {
2112 // Verify all tools have valid JSON schemas
2113 let read_schema = ReadFileTool.input_schema();
2114 assert!(read_schema.get("type").is_some());
2115 assert!(read_schema.get("properties").is_some());
2116
2117 let write_schema = WriteFileTool.input_schema();
2118 let required = write_schema
2119 .get("required")
2120 .and_then(|value| value.as_array())
2121 .expect("write schema should include required array");
2122 assert!(required.iter().any(|v| v.as_str() == Some("path")));
2123 assert!(required.iter().any(|v| v.as_str() == Some("content")));
2124
2125 let edit_schema = EditFileTool.input_schema();
2126 let required = edit_schema
2127 .get("required")
2128 .and_then(|value| value.as_array())
2129 .expect("edit schema should include required array");
2130 let required_fields: Vec<_> = required.iter().filter_map(|value| value.as_str()).collect();
2131 assert_eq!(required_fields, vec!["path", "search", "replace"]);
2132 assert!(!required_fields.contains(&"fuzz"));
2133 // `fuzz` was never read by `edit` — it was parsed into a discarded
2134 // binding while the schema advertised it. An unimplemented parameter has
2135 // no place in a schema the model is asked to trust.
2136 assert!(edit_schema["properties"].get("fuzz").is_none());
2137 let search_desc = edit_schema["properties"]["search"]["description"]
2138 .as_str()
2139 .expect("search description");
2140 assert!(search_desc.contains("Exact text"));
2141 assert!(search_desc.contains("whitespace"));
2142
2143 let list_schema = ListDirTool.input_schema();
2144 let required = list_schema
2145 .get("required")
2146 .and_then(|value| value.as_array())
2147 .expect("list schema should include required array");
2148 assert!(required.is_empty()); // path is optional
2149 }
2150
2150 lines RUST