返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / file / tests.rs
1 use super::*;
2 use serde_json::json;
3 use std::time::Duration;
4
5 #[tokio::test]
6 async fn missing_pdf_path_precedes_unavailable_helper() {
7 let temporary = tempfile::tempdir().expect("tempdir");
8 let input = temporary.path().join("missing.pdf");
9 let missing = temporary.path().join("definitely-not-pdftotext");
10 let error = read_pdf_if_detected(
11 &input,
12 None,
13 super::super::pdf::PdfTextCommand::test(missing.as_os_str(), Duration::from_secs(1), None),
14 )
15 .await
16 .expect_err("missing path must fail before the missing helper is launched");
17
18 match error {
19 ToolError::ExecutionFailed { message, .. } => {
20 assert!(message.contains("Failed to read"), "{message}");
21 assert!(message.contains("missing.pdf"), "{message}");
22 }
23 other => panic!("expected ordinary read failure, got {other:?}"),
24 }
25 }
26
27 #[tokio::test]
28 async fn read_file_missing_pdftotext_is_a_failed_typed_outcome() {
29 let temporary = tempfile::tempdir().expect("tempdir");
30 let missing = temporary.path().join("definitely-not-pdftotext");
31 let input = temporary.path().join("input.pdf");
32 std::fs::write(&input, b"%PDF-1.7\n%%EOF").expect("fixture");
33
34 let error = read_pdf_with_command(
35 &input,
36 None,
37 super::super::pdf::PdfTextCommand::test(missing.as_os_str(), Duration::from_secs(1), None),
38 )
39 .await
40 .expect_err("missing helper must fail the tool call");
41 let payload = match &error {
42 ToolError::NotAvailable { message } => {
43 serde_json::from_str::<Value>(message).expect("structured unavailable payload")
44 }
45 other => panic!("unexpected error: {other:?}"),
46 };
47 assert_eq!(payload["type"], "binary_unavailable");
48 assert_eq!(
49 crate::tools::spec::ToolExecutionOutcome::from_legacy(Err(error)).status,
50 crate::tools::spec::ToolTerminalStatus::Failed
51 );
52 }
53
54 /// C05 regression: the reader used to stop at a fixed 2 000 lines even when
55 /// the byte budget had barely been touched, fragmenting an ordinary file for
56 /// no reason. Bytes are now the only bound.
57 #[tokio::test]
58 async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whole() {
59 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
60 let content = (0..5_000)
61 .map(|index| format!("line-{index}"))
62 .collect::<Vec<_>>()
63 .join("\n")
64 + "\n";
65 assert!(
66 content.len() < READ_DEFAULT_MAX_BYTES,
67 "fixture fits the budget"
68 );
69
70 let window = contract_read_window(&content, READ_DEFAULT_MAX_BYTES);
71 assert!(!window.truncated);
72 assert_eq!(window.shown_lines, 5_000);
73 assert_eq!(window.content, content);
74
75 let temporary = tempfile::tempdir().expect("tempdir");
76 std::fs::write(temporary.path().join("many.txt"), &content).expect("fixture");
77 let context = ToolContext::new(temporary.path());
78 let result = ReadFileTool::execute_contract_read(json!({"path": "many.txt"}), &context)
79 .await
80 .expect("read result");
81 assert_eq!(result.content, content);
82 assert!(
83 !result.content.contains("[Showing lines"),
84 "no truncation footer"
85 );
86 }
87
88 #[tokio::test]
89 async fn contract_read_returns_an_ordinary_source_file_whole_without_a_footer() {
90 let temporary = tempfile::tempdir().expect("tempdir");
91 let content = "fn main() {\n println!(\"hi\");\n}\n";
92 std::fs::write(temporary.path().join("main.rs"), content).expect("fixture");
93 let context = ToolContext::new(temporary.path());
94 let result = ReadFileTool::execute_contract_read(json!({"path": "main.rs"}), &context)
95 .await
96 .expect("read result");
97 assert_eq!(result.content, content);
98 }
99
100 #[test]
101 fn contract_read_byte_limit_keeps_only_complete_utf8_lines() {
102 let first = "é".repeat(30_000);
103 let second = "z".repeat(60_000);
104 let window = contract_read_window(&format!("{first}\n{second}\n"), READ_DEFAULT_MAX_BYTES);
105 assert!(window.truncated);
106 assert_eq!(window.shown_lines, 1);
107 assert_eq!(window.content, first);
108 assert!(std::str::from_utf8(window.content.as_bytes()).is_ok());
109 }
110
111 #[test]
112 fn read_budget_precedence_is_request_then_workshop_then_default() {
113 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
114
115 crate::tools::large_output_router::WorkshopConfig::install_active(None);
116 assert_eq!(effective_read_max_bytes(None), READ_DEFAULT_MAX_BYTES);
117 assert_eq!(effective_read_max_bytes(Some(250_000)), 250_000);
118 // Above the model-requestable maximum clamps down instead of erroring.
119 assert_eq!(
120 effective_read_max_bytes(Some(READ_REQUEST_MAX_BYTES * 10)),
121 READ_REQUEST_MAX_BYTES
122 );
123 // A request below the active baseline leaves the baseline in place.
124 assert_eq!(effective_read_max_bytes(Some(10)), READ_DEFAULT_MAX_BYTES);
125
126 crate::tools::large_output_router::WorkshopConfig::install_active(Some(
127 &crate::tools::large_output_router::WorkshopConfig {
128 read_result_max_bytes: Some(700_000),
129 ..Default::default()
130 },
131 ));
132 assert_eq!(effective_read_max_bytes(None), 700_000);
133 assert_eq!(effective_read_max_bytes(Some(200_000)), 700_000);
134 // The workshop override keeps the 2 MiB absolute ceiling.
135 crate::tools::large_output_router::WorkshopConfig::install_active(Some(
136 &crate::tools::large_output_router::WorkshopConfig {
137 read_result_max_bytes: Some(READ_RESULT_ABSOLUTE_MAX_BYTES * 4),
138 ..Default::default()
139 },
140 ));
141 assert_eq!(
142 effective_read_max_bytes(None),
143 READ_RESULT_ABSOLUTE_MAX_BYTES
144 );
145 crate::tools::large_output_router::WorkshopConfig::install_active(None);
146 }
147
148 #[tokio::test]
149 async fn contract_read_max_bytes_raises_the_budget_for_one_call() {
150 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
151 let temporary = tempfile::tempdir().expect("tempdir");
152 let line = "y".repeat(199);
153 let content = std::iter::repeat_n(line.as_str(), 1_500)
154 .collect::<Vec<_>>()
155 .join("\n");
156 assert!(content.len() > READ_DEFAULT_MAX_BYTES);
157 assert!(content.len() < READ_REQUEST_MAX_BYTES);
158 std::fs::write(temporary.path().join("wide.txt"), &content).expect("fixture");
159 let context = ToolContext::new(temporary.path());
160
161 let default_budget = ReadFileTool::execute_contract_read(json!({"path": "wide.txt"}), &context)
162 .await
163 .expect("default budget read");
164 assert!(
165 default_budget.content.contains("100000-byte output budget"),
166 "{}",
167 default_budget.content
168 );
169
170 let raised = ReadFileTool::execute_contract_read(
171 json!({"path": "wide.txt", "max_bytes": 400_000}),
172 &context,
173 )
174 .await
175 .expect("raised budget read");
176 assert_eq!(raised.content, content);
177
178 // Above the hard maximum clamps down; the file still fits, so it is whole.
179 let clamped = ReadFileTool::execute_contract_read(
180 json!({"path": "wide.txt", "max_bytes": 9_000_000}),
181 &context,
182 )
183 .await
184 .expect("clamped budget read");
185 assert_eq!(clamped.content, content);
186 }
187
188 #[tokio::test]
189 async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer() {
190 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
191 let temporary = tempfile::tempdir().expect("tempdir");
192 // 999-byte lines: 100 of them plus their 99 separators are 99 999 bytes,
193 // one under the 100 000-byte budget, so page one is exactly lines 1-100.
194 let line = "z".repeat(999);
195 let content = std::iter::repeat_n(line.as_str(), 2_000)
196 .collect::<Vec<_>>()
197 .join("\n");
198 std::fs::write(temporary.path().join("big.txt"), &content).expect("fixture");
199 let context = ToolContext::new(temporary.path());
200
201 let first = ReadFileTool::execute_contract_read(json!({"path": "big.txt"}), &context)
202 .await
203 .expect("first page");
204 let footer = first
205 .content
206 .rsplit_once("\n\n")
207 .expect("footer present")
208 .1
209 .to_string();
210 assert_eq!(
211 footer,
212 "[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]"
213 );
214 let shown = first.content.rsplit_once("\n\n").expect("body").0;
215 assert_eq!(shown.lines().count(), 100);
216
217 // The named continuation offset is exact: page two starts on line 101.
218 let second =
219 ReadFileTool::execute_contract_read(json!({"path": "big.txt", "offset": 101}), &context)
220 .await
221 .expect("second page");
222 assert!(
223 second.content.starts_with(&line),
224 "second page starts at the named offset"
225 );
226 assert!(
227 second.content.contains("Use offset=201 to continue"),
228 "{}",
229 second.content
230 );
231 }
232
233 /// #6283 AC1: a >10 MiB file read without paging params returns page one
234 /// plus the file's size, line count, and truncated flag — never the whole
235 /// file.
236 #[tokio::test]
237 async fn contract_read_reports_size_and_truncation_for_huge_files() {
238 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
239 let temporary = tempfile::tempdir().expect("tempdir");
240 let line = "x".repeat(99);
241 let content = std::iter::repeat_n(line.as_str(), 110_000)
242 .collect::<Vec<_>>()
243 .join("\n");
244 assert!(
245 content.len() > 10 * 1024 * 1024,
246 "fixture exceeds 10 MiB: {}",
247 content.len()
248 );
249 std::fs::write(temporary.path().join("huge.bin.txt"), &content).expect("fixture");
250 let context = ToolContext::new(temporary.path());
251
252 let first = ReadFileTool::execute_contract_read(json!({"path": "huge.bin.txt"}), &context)
253 .await
254 .expect("first page");
255 let metadata = first.metadata.clone().expect("paging metadata");
256 assert_eq!(metadata["size"], content.len() as u64);
257 assert_eq!(metadata["truncated"], true);
258 assert_eq!(metadata["line_count"], 110_000);
259 assert!(
260 first.content.len() < content.len(),
261 "page one must never be the whole file"
262 );
263 assert!(
264 first.content.len() <= READ_DEFAULT_MAX_BYTES + 1_024,
265 "page one stays within the default budget plus footer slack: {}",
266 first.content.len()
267 );
268 assert!(
269 first.content.contains("total") && first.content.contains("Use offset="),
270 "footer names the size and the continuation: {}",
271 first
272 .content
273 .rsplit_once("\n\n")
274 .map(|(_, f)| f)
275 .unwrap_or("")
276 );
277 }
278
279 /// #6283 AC2: paging through a file keeps every response bounded and
280 /// terminates with an untruncated page whose union is the whole file.
281 #[tokio::test]
282 async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() {
283 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
284 let temporary = tempfile::tempdir().expect("tempdir");
285 let content = (0..3_000)
286 .map(|index| format!("paged-line-{index:05}"))
287 .collect::<Vec<_>>()
288 .join("\n");
289 std::fs::write(temporary.path().join("paged.txt"), &content).expect("fixture");
290 let context = ToolContext::new(temporary.path());
291
292 let mut seen: Vec<String> = Vec::new();
293 let mut offset = 1usize;
294 for page in 0..100 {
295 let result = ReadFileTool::execute_contract_read(
296 json!({"path": "paged.txt", "offset": offset, "limit": 500}),
297 &context,
298 )
299 .await
300 .expect("page read");
301 let metadata = result.metadata.clone().expect("paging metadata");
302 assert_eq!(metadata["size"], content.len() as u64);
303 assert!(
304 result.content.len() <= READ_DEFAULT_MAX_BYTES + 1_024,
305 "page {page} bounded: {}",
306 result.content.len()
307 );
308 let body = result
309 .content
310 .rsplit_once("\n\n[")
311 .map(|(body, _)| body)
312 .unwrap_or(&result.content);
313 seen.extend(body.lines().map(str::to_string));
314 let truncated = metadata["truncated"].as_bool().expect("truncated flag");
315 if !truncated {
316 break;
317 }
318 offset += 500;
319 assert!(page < 99, "paging must terminate");
320 }
321 assert_eq!(seen.len(), 3_000);
322 assert_eq!(seen.join("\n"), content);
323 }
324
325 /// #6283: ordinary whole reads carry the same paging metadata (with
326 /// truncated=false) and keep their footer-free shape.
327 #[tokio::test]
328 async fn contract_read_metadata_for_ordinary_whole_read() {
329 let temporary = tempfile::tempdir().expect("tempdir");
330 let content = "alpha\nbeta\ngamma\n";
331 std::fs::write(temporary.path().join("small.txt"), content).expect("fixture");
332 let context = ToolContext::new(temporary.path());
333
334 let result = ReadFileTool::execute_contract_read(json!({"path": "small.txt"}), &context)
335 .await
336 .expect("read result");
337 assert_eq!(result.content, content);
338 let metadata = result.metadata.clone().expect("paging metadata");
339 assert_eq!(metadata["size"], content.len() as u64);
340 assert_eq!(metadata["truncated"], false);
341 assert_eq!(metadata["line_count"], 4);
342 }
343
344 /// #6283 AC3: grep-then-read flow — locate a marker with `grep_files`,
345 /// then read exactly that line range. (`grep_files` in the child surface
346 /// is pinned by `an_explicit_parent_tool_scope_is_enforced_by_the_child_registry`.)
347 #[tokio::test]
348 async fn grep_then_read_flow_targets_matched_lines() {
349 use crate::tools::spec::ToolSpec;
350
351 let temporary = tempfile::tempdir().expect("tempdir");
352 let mut lines: Vec<String> = (0..200)
353 .map(|index| format!("filler line {index}"))
354 .collect();
355 lines[150] = "the needle marker lives here".to_string();
356 std::fs::write(temporary.path().join("haystack.txt"), lines.join("\n")).expect("fixture");
357 let context = ToolContext::new(temporary.path());
358
359 let grep = crate::tools::search::GrepFilesTool
360 .execute(
361 json!({"pattern": "needle marker", "path": ".", "context_lines": 1}),
362 &context,
363 )
364 .await
365 .expect("grep result");
366 let payload: serde_json::Value =
367 serde_json::from_str(&grep.content).expect("grep JSON envelope");
368 assert_eq!(payload["total_matches"], 1);
369 let matched = &payload["matches"][0];
370 assert_eq!(matched["line_number"], 151);
371
372 let read = ReadFileTool::execute_contract_read(
373 json!({
374 "path": matched["file"].as_str().expect("match file"),
375 "offset": matched["line_number"].as_u64().expect("match line"),
376 "limit": 1,
377 }),
378 &context,
379 )
380 .await
381 .expect("targeted read");
382 assert!(
383 read.content.contains("the needle marker lives here"),
384 "{}",
385 read.content
386 );
387 }
388
389 #[tokio::test]
390 async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() {
391 let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
392 let temporary = tempfile::tempdir().expect("tempdir");
393 std::fs::write(
394 temporary.path().join("huge.txt"),
395 "x".repeat(READ_DEFAULT_MAX_BYTES + 1),
396 )
397 .expect("fixture");
398 let context = ToolContext::new(temporary.path());
399 let result = ReadFileTool::execute_contract_read(json!({"path": "huge.txt"}), &context)
400 .await
401 .expect("read result");
402 assert_eq!(
403 result.content,
404 "[Line 1 is 97.7KB, exceeds the 100000-byte output budget for this call. Use bash: sed -n '1p' huge.txt | head -c 100000]"
405 );
406 }
407
408 #[tokio::test]
409 async fn contract_read_offset_oob_and_limit_continuation_match_contract() {
410 let temporary = tempfile::tempdir().expect("tempdir");
411 std::fs::write(temporary.path().join("lines.txt"), "one\ntwo\nthree").expect("fixture");
412 let context = ToolContext::new(temporary.path());
413
414 let limited = ReadFileTool::execute_contract_read(
415 json!({"path": "lines.txt", "offset": 2, "limit": 1}),
416 &context,
417 )
418 .await
419 .expect("limited read");
420 assert_eq!(
421 limited.content,
422 "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]"
423 );
424
425 let error =
426 ReadFileTool::execute_contract_read(json!({"path": "lines.txt", "offset": 4}), &context)
427 .await
428 .expect_err("offset beyond EOF");
429 assert_eq!(
430 error.to_string(),
431 "Failed to execute tool: Offset 4 is beyond end of file (3 lines total)"
432 );
433 }
434
435 /// Regression: grok-4.7 serializes every JSON number as a float, so its
436 /// ranged reads arrive as `{"offset": 2.0, "limit": 1.0}`. Those used to
437 /// fail with "offset must be a non-negative integer" on every call.
438 #[tokio::test]
439 async fn contract_read_accepts_whole_number_floats_and_still_refuses_fractions() {
440 let temporary = tempfile::tempdir().expect("tempdir");
441 std::fs::write(temporary.path().join("lines.txt"), "one\ntwo\nthree").expect("fixture");
442 let context = ToolContext::new(temporary.path());
443
444 let ranged = ReadFileTool::execute_contract_read(
445 json!({"path": "lines.txt", "offset": 2.0, "limit": 1.0, "max_bytes": 200.0}),
446 &context,
447 )
448 .await
449 .expect("float-typed ranged read");
450 assert_eq!(
451 ranged.content,
452 "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]"
453 );
454
455 for (key, bad) in [
456 ("offset", json!(-1.0)),
457 ("offset", json!(2.5)),
458 ("limit", json!("2")),
459 ("limit", json!([2])),
460 ] {
461 let error =
462 ReadFileTool::execute_contract_read(json!({"path": "lines.txt", key: bad}), &context)
463 .await
464 .expect_err("non-integer must be refused");
465 assert!(
466 error
467 .to_string()
468 .contains(&format!("{key} must be a non-negative integer")),
469 "{error}"
470 );
471 }
472 }
473
474 #[tokio::test]
475 async fn contract_read_uses_magic_not_extension_for_images() {
476 let temporary = tempfile::tempdir().expect("tempdir");
477 std::fs::write(temporary.path().join("plain.png"), "ordinary text").expect("text fixture");
478 std::fs::write(
479 temporary.path().join("renamed.data"),
480 crate::image_attach::tests::PNG_1X1,
481 )
482 .expect("image fixture");
483 std::fs::write(
484 temporary.path().join("truncated.png"),
485 [b"\x89PNG\r\n\x1a\n".as_slice(), b"\0\0\0\rIHDR".as_slice()].concat(),
486 )
487 .expect("truncated image fixture");
488 let context = ToolContext::new(temporary.path());
489
490 let text = ReadFileTool::execute_contract_read(json!({"path": "plain.png"}), &context)
491 .await
492 .expect("fake extension remains text");
493 assert_eq!(text.content, "ordinary text");
494 let image = ReadFileTool::execute_contract_read(json!({"path": "renamed.data"}), &context)
495 .await
496 .expect("real image uses typed transport");
497 assert_eq!(image.content_blocks.len(), 1);
498 assert!(matches!(
499 &image.content_blocks[0],
500 codewhale_tools::ToolResultContentBlock::Image { mime_type, .. }
501 if mime_type == "image/png"
502 ));
503 let truncated = ReadFileTool::execute_contract_read(json!({"path": "truncated.png"}), &context)
504 .await
505 .expect("invalid image retains an omission receipt");
506 assert!(truncated.content_blocks.is_empty());
507 assert!(truncated.content.contains("Image omitted"));
508 }
509
510 #[test]
511 fn contract_edit_preparation_accepts_string_and_legacy_recovery_forms() {
512 let encoded = prepare_contract_edit_input(json!({
513 "path": "doc.txt",
514 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
515 }))
516 .expect("encoded edits");
517 assert_eq!(encoded["edits"][0], json!({"oldText": "a", "newText": "b"}));
518
519 let recovered = prepare_contract_edit_input(json!({
520 "path": "doc.txt",
521 "edits": {"malformed": true},
522 "oldText": "a",
523 "newText": "b"
524 }))
525 .expect("legacy recovery");
526 assert_eq!(
527 recovered["edits"],
528 json!([{"oldText": "a", "newText": "b"}])
529 );
530 assert!(recovered.get("oldText").is_none());
531 assert!(recovered.get("newText").is_none());
532 }
533
534 #[test]
535 fn contract_edit_fuzzy_normalization_preserves_untouched_lines() {
536 let original = "untouched line \nShe said “hello”—today. \ntail \n";
537 let updated = apply_contract_edits(
538 original,
539 &[ContractEdit {
540 index: 0,
541 old_text: "She said \"hello\"-today.".to_string(),
542 new_text: "She said hello.".to_string(),
543 }],
544 "doc.txt",
545 false,
546 )
547 .expect("fuzzy edit");
548 assert_eq!(updated, "untouched line \nShe said hello.\ntail \n");
549 }
550
551 #[tokio::test]
552 async fn contract_edit_preserves_bom_and_crlf_without_prior_read() {
553 let temporary = tempfile::tempdir().expect("tempdir");
554 let path = temporary.path().join("doc.txt");
555 std::fs::write(&path, "\u{FEFF}alpha\r\nbeta\r\n").expect("fixture");
556 let context = ToolContext::new(temporary.path());
557 let result = EditFileTool::execute_contract_edits(
558 json!({
559 "path": "doc.txt",
560 "edits": [{"oldText": "alpha\nbeta", "newText": "one\ntwo"}]
561 }),
562 &context,
563 )
564 .await
565 .expect("edit");
566 assert_eq!(
567 result.content,
568 "Successfully replaced 1 block(s) in doc.txt."
569 );
570 assert_eq!(
571 std::fs::read(&path).expect("updated"),
572 "\u{FEFF}one\r\ntwo\r\n".as_bytes()
573 );
574 // The receipt describes the bytes written (BOM and CRLF included), not
575 // the edited text, so a turn artifact's revision matches the file read.
576 let on_disk = std::fs::read(&path).expect("updated");
577 assert_eq!(
578 result.metadata.as_ref().expect("metadata")["mutation"]["files"],
579 json!([{
580 "path": "doc.txt",
581 "outcome": "updated",
582 "size": on_disk.len(),
583 "sha256": crate::hashing::sha256_hex(&on_disk),
584 }])
585 );
586 }
587
588 #[tokio::test]
589 async fn contract_write_receipt_carries_written_size_and_sha256() {
590 let temporary = tempfile::tempdir().expect("tempdir");
591 let context = ToolContext::new(temporary.path());
592 let result = WriteFileTool::execute_contract_write(
593 json!({"path": "notes/out.md", "content": "# Title\n"}),
594 &context,
595 )
596 .await
597 .expect("write");
598 let on_disk = std::fs::read(temporary.path().join("notes/out.md")).expect("written");
599 assert_eq!(
600 result.metadata.as_ref().expect("metadata")["mutation"]["files"],
601 json!([{
602 "path": "notes/out.md",
603 "outcome": "created",
604 "size": on_disk.len(),
605 "sha256": crate::hashing::sha256_hex(&on_disk),
606 }])
607 );
608 }
609
610 /// B6: bytes that are not UTF-8 survive an edit elsewhere in the file.
611 #[tokio::test]
612 async fn contract_edit_keeps_non_utf8_bytes() {
613 let temporary = tempfile::tempdir().expect("tempdir");
614 let path = temporary.path().join("latin1.txt");
615 let mut original = b"caf\xe9 \xff\xfe tail\n".to_vec();
616 original.extend_from_slice(b"change me\n");
617 std::fs::write(&path, &original).expect("fixture");
618 let context = ToolContext::new(temporary.path());
619 EditFileTool::execute_contract_edits(
620 json!({"path": "latin1.txt", "edits": [{"oldText": "change me", "newText": "changed"}]}),
621 &context,
622 )
623 .await
624 .expect("edit");
625 assert_eq!(
626 std::fs::read(&path).expect("updated"),
627 b"caf\xe9 \xff\xfe tail\nchanged\n".to_vec()
628 );
629 }
630
631 /// A valid UTF-8 file may use the placeholder range itself (Nerd Font
632 /// Material Design icons are U+F0000..U+F00FF). Those characters are text,
633 /// not raw bytes, and an edit elsewhere must keep them.
634 #[tokio::test]
635 async fn contract_edit_keeps_placeholder_range_characters_in_utf8_files() {
636 let temporary = tempfile::tempdir().expect("tempdir");
637 let path = temporary.path().join("starship.toml");
638 let original = "icon = \"\u{F0026}\"\nwide = \"\u{F00A0}\"\ncolor = \"red\"\n";
639 std::fs::write(&path, original).expect("fixture");
640 let context = ToolContext::new(temporary.path());
641 EditFileTool::execute_contract_edits(
642 json!({"path": "starship.toml", "edits": [{"oldText": "red", "newText": "blue"}]}),
643 &context,
644 )
645 .await
646 .expect("edit");
647 assert_eq!(
648 std::fs::read_to_string(&path).expect("still UTF-8"),
649 original.replace("red", "blue")
650 );
651 }
652
653 /// B6: in a file with mixed line endings, only the lines an edit wrote take
654 /// the dominant ending; every untouched line keeps its own.
655 #[tokio::test]
656 async fn contract_edit_keeps_untouched_line_endings() {
657 let temporary = tempfile::tempdir().expect("tempdir");
658 let path = temporary.path().join("mixed.txt");
659 std::fs::write(&path, "one\r\ntwo\nthree\r\nfour\rfive\n").expect("fixture");
660 let context = ToolContext::new(temporary.path());
661 EditFileTool::execute_contract_edits(
662 json!({"path": "mixed.txt", "edits": [{"oldText": "three", "newText": "THREE\nand more"}]}),
663 &context,
664 )
665 .await
666 .expect("edit");
667 assert_eq!(
668 std::fs::read_to_string(&path).expect("updated"),
669 "one\r\ntwo\nTHREE\r\nand more\r\nfour\rfive\n"
670 );
671 }
672
673 #[tokio::test]
674 async fn queued_parallel_contract_edits_preserve_both_changes() {
675 let temporary = tempfile::tempdir().expect("tempdir");
676 let path = temporary.path().join("doc.txt");
677 std::fs::write(&path, "alpha\nbeta\ngamma\n").expect("fixture");
678 let context = ToolContext::new(temporary.path());
679 let first_context = context.clone();
680 let second_context = context.clone();
681
682 let first = tokio::spawn(async move {
683 EditFileTool::execute_contract_edits(
684 json!({"path": "doc.txt", "edits": [{"oldText": "alpha", "newText": "A"}]}),
685 &first_context,
686 )
687 .await
688 });
689 let second = tokio::spawn(async move {
690 EditFileTool::execute_contract_edits(
691 json!({"path": "doc.txt", "edits": [{"oldText": "gamma", "newText": "G"}]}),
692 &second_context,
693 )
694 .await
695 });
696 first.await.expect("first task").expect("first edit");
697 second.await.expect("second task").expect("second edit");
698 assert_eq!(
699 std::fs::read_to_string(path).expect("updated"),
700 "A\nbeta\nG\n"
701 );
702 }
703
704 #[tokio::test]
705 async fn cancelled_queued_pi_write_never_starts() {
706 let temporary = tempfile::tempdir().expect("tempdir");
707 let context = ToolContext::new(temporary.path());
708 let path = context.resolve_path("queued.txt").expect("resolved path");
709 let held = file_mutation_lock(&path).expect("queue").lock_owned().await;
710 let cancellation = CancellationToken::new();
711 let queued_context = context.clone().with_cancel_token(cancellation.clone());
712 let queued = tokio::spawn(async move {
713 WriteFileTool::execute_contract_write(
714 json!({"path": "queued.txt", "content": "must-not-land"}),
715 &queued_context,
716 )
717 .await
718 });
719 tokio::task::yield_now().await;
720 cancellation.cancel();
721 let error = queued
722 .await
723 .expect("queued task")
724 .expect_err("queued write must cancel");
725 assert!(matches!(error, ToolError::Cancelled { .. }));
726 assert!(!path.exists());
727 drop(held);
728 }
729
730 #[cfg(unix)]
731 #[tokio::test]
732 async fn contract_write_preserves_unreadable_existing_file() {
733 use std::os::unix::fs::PermissionsExt;
734
735 let temporary = tempfile::tempdir().expect("tempdir");
736 let path = temporary.path().join("write-only.txt");
737 std::fs::write(&path, "original\n").expect("fixture");
738 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o200))
739 .expect("make unreadable");
740 let context = ToolContext::new(temporary.path());
741 let result = WriteFileTool::execute_contract_write(
742 json!({"path": "write-only.txt", "content": "replacement\n"}),
743 &context,
744 )
745 .await;
746 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
747 .expect("restore permissions");
748
749 let error = result.expect_err("cannot overwrite without the prior contents");
750 assert!(error.to_string().contains("Failed to read"), "{error}");
751 assert_eq!(
752 std::fs::read_to_string(path).expect("unchanged"),
753 "original\n"
754 );
755 }
756
757 #[cfg(unix)]
758 #[tokio::test]
759 async fn compatibility_write_preserves_unreadable_existing_file() {
760 use std::os::unix::fs::PermissionsExt;
761
762 let temporary = tempfile::tempdir().expect("tempdir");
763 let path = temporary.path().join("write-only.txt");
764 let context = ToolContext::new(temporary.path());
765 let file_tool = crate::tools::file_tool::FileTool::new("File");
766 for tool in [&WriteFileTool as &dyn ToolSpec, &file_tool] {
767 std::fs::write(&path, "original\n").expect("fixture");
768 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o200))
769 .expect("make unreadable");
770 let mut input = json!({"path": "write-only.txt", "content": "replacement\n"});
771 if tool.name() == "File" {
772 input["action"] = json!("write");
773 }
774 let result = tool.execute(input, &context).await;
775 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
776 .expect("restore permissions");
777
778 let error = result.expect_err("cannot overwrite without the prior contents");
779 assert!(error.to_string().contains("Failed to read"), "{error}");
780 assert_eq!(
781 std::fs::read_to_string(&path).expect("unchanged"),
782 "original\n"
783 );
784 }
785 }
786
787 #[tokio::test]
788 async fn compatibility_write_preserves_non_utf8_existing_file() {
789 let temporary = tempfile::tempdir().expect("tempdir");
790 let path = temporary.path().join("latin1.txt");
791 let original = b"caf\xe9\n";
792 std::fs::write(&path, original).expect("fixture");
793 let context = ToolContext::new(temporary.path());
794 let file_tool = crate::tools::file_tool::FileTool::new("File");
795 for tool in [&WriteFileTool as &dyn ToolSpec, &file_tool] {
796 let mut input = json!({"path": "latin1.txt", "content": "replacement\n"});
797 if tool.name() == "File" {
798 input["action"] = json!("write");
799 }
800 let error = tool
801 .execute(input, &context)
802 .await
803 .expect_err("must decode original");
804 assert!(error.to_string().contains("Failed to read"), "{error}");
805 assert_eq!(std::fs::read(&path).expect("unchanged"), original);
806 }
807 }
808
809 #[cfg(unix)]
810 #[tokio::test]
811 async fn contract_edit_rejects_read_only_target_before_atomic_replace() {
812 use std::os::unix::fs::PermissionsExt;
813
814 let temporary = tempfile::tempdir().expect("tempdir");
815 let path = temporary.path().join("readonly.txt");
816 std::fs::write(&path, "alpha\n").expect("fixture");
817 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).expect("readonly");
818 let context = ToolContext::new(temporary.path());
819 let result = EditFileTool::execute_contract_edits(
820 json!({"path": "readonly.txt", "edits": [{"oldText": "alpha", "newText": "beta"}]}),
821 &context,
822 )
823 .await;
824 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
825 .expect("restore permissions");
826 let error = result.expect_err("read-only target must fail");
827 assert!(error.to_string().contains("readable and writable"));
828 assert_eq!(std::fs::read_to_string(path).expect("unchanged"), "alpha\n");
829 }
830
831 async fn run_contract_bounds_operation(
832 operation: &str,
833 path: &str,
834 context: &ToolContext,
835 ) -> Result<(), ToolError> {
836 match operation {
837 "read" => ReadFileTool::execute_contract_read(
838 json!({"path": path, "offset": 1, "limit": 1}),
839 context,
840 )
841 .await
842 .map(|_| ()),
843 "write" => WriteFileTool::execute_contract_write(
844 json!({"path": path, "content": "replacement"}),
845 context,
846 )
847 .await
848 .map(|_| ()),
849 "edit" => EditFileTool::execute_contract_edits(
850 json!({"path": path, "edits": [{"oldText": "seed", "newText": "replacement"}]}),
851 context,
852 )
853 .await
854 .map(|_| ()),
855 _ => unreachable!(),
856 }
857 }
858
859 #[tokio::test]
860 async fn contract_source_cap_refuses_sparse_files_without_paging_or_mutation() {
861 use std::io::{Read as _, Write as _};
862 let temporary = tempfile::tempdir().unwrap();
863 let path = temporary.path().join("large.txt");
864 let mut file = fs::File::create(&path).unwrap();
865 file.write_all(b"seed").unwrap();
866 file.set_len(CONTRACT_FILE_MAX_BYTES as u64 + 1).unwrap();
867 drop(file);
868 let context = ToolContext::new(temporary.path());
869 for operation in ["read", "write", "edit"] {
870 let error = tokio::time::timeout(
871 Duration::from_secs(2),
872 run_contract_bounds_operation(operation, "large.txt", &context),
873 )
874 .await
875 .expect("bounded refusal")
876 .expect_err("source cap");
877 let message = error.to_string();
878 assert!(
879 message.contains("16 MiB processing cap"),
880 "{operation}: {message}"
881 );
882 assert!(
883 !message.contains("offset=") && !message.contains("max_bytes"),
884 "{message}"
885 );
886 assert_eq!(
887 fs::metadata(&path).unwrap().len(),
888 CONTRACT_FILE_MAX_BYTES as u64 + 1
889 );
890 let mut prefix = [0; 4];
891 fs::File::open(&path)
892 .unwrap()
893 .read_exact(&mut prefix)
894 .unwrap();
895 assert_eq!(&prefix, b"seed");
896 assert!(context.require_fresh_file_read(&path, "large.txt").is_err());
897 }
898 }
899
900 #[test]
901 fn contract_source_actual_reads_stop_at_cap_plus_one_and_poll_cancellation() {
902 use std::io::Read as _;
903 let mut exact = std::io::repeat(b'x').take(CONTRACT_FILE_MAX_BYTES as u64);
904 assert_eq!(
905 read_contract_source(&mut exact, None).unwrap().len(),
906 CONTRACT_FILE_MAX_BYTES
907 );
908
909 struct Reader {
910 consumed: usize,
911 cancel: Option<CancellationToken>,
912 }
913 impl std::io::Read for Reader {
914 fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
915 bytes.fill(b'x');
916 self.consumed += bytes.len();
917 if let Some(cancel) = &self.cancel {
918 cancel.cancel();
919 }
920 Ok(bytes.len())
921 }
922 }
923 // No metadata shortcut: this represents a growing/virtual regular file.
924 let mut growing = Reader {
925 consumed: 0,
926 cancel: None,
927 };
928 assert!(read_contract_source(&mut growing, None).is_err());
929 assert_eq!(growing.consumed, CONTRACT_FILE_MAX_BYTES + 1);
930
931 let token = CancellationToken::new();
932 let mut interrupted = Reader {
933 consumed: 0,
934 cancel: Some(token.clone()),
935 };
936 assert!(matches!(
937 read_contract_source(&mut interrupted, Some(&token)),
938 Err(ToolError::Cancelled { .. })
939 ));
940 assert_eq!(interrupted.consumed, 64 * 1024);
941 interrupted.consumed = 0;
942 assert!(matches!(
943 read_contract_source(&mut interrupted, Some(&token)),
944 Err(ToolError::Cancelled { .. })
945 ));
946 assert_eq!(interrupted.consumed, 0);
947 }
948
949 #[tokio::test]
950 async fn contract_source_directory_and_cancelled_calls_leave_targets_untouched() {
951 let temporary = tempfile::tempdir().unwrap();
952 fs::create_dir(temporary.path().join("directory")).unwrap();
953 let path = temporary.path().join("plain.txt");
954 fs::write(&path, b"seed").unwrap();
955 let context = ToolContext::new(temporary.path());
956 for operation in ["read", "write", "edit"] {
957 assert!(
958 run_contract_bounds_operation(operation, "directory", &context)
959 .await
960 .is_err()
961 );
962 }
963 let token = CancellationToken::new();
964 token.cancel();
965 let cancelled = context.with_cancel_token(token);
966 for operation in ["read", "write", "edit"] {
967 let error = run_contract_bounds_operation(operation, "plain.txt", &cancelled)
968 .await
969 .unwrap_err();
970 assert!(
971 matches!(error, ToolError::Cancelled { .. }),
972 "{operation}: {error}"
973 );
974 assert_eq!(fs::read(&path).unwrap(), b"seed");
975 assert!(
976 cancelled
977 .require_fresh_file_read(&path, "plain.txt")
978 .is_err()
979 );
980 }
981 }
982
983 #[cfg(unix)]
984 #[tokio::test]
985 async fn contract_source_fifo_without_writer_and_hard_links_are_refused_promptly() {
986 use std::os::unix::ffi::OsStrExt as _;
987 let temporary = tempfile::tempdir().unwrap();
988 let fifo = temporary.path().join("pipe");
989 let cpath = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
990 assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }, 0);
991 let plain = temporary.path().join("plain.txt");
992 fs::write(&plain, b"seed").unwrap();
993 fs::hard_link(&plain, temporary.path().join("linked.txt")).unwrap();
994 let context = ToolContext::new(temporary.path());
995 for path in ["pipe", "linked.txt"] {
996 for operation in ["read", "write", "edit"] {
997 let error = tokio::time::timeout(
998 Duration::from_secs(2),
999 run_contract_bounds_operation(operation, path, &context),
1000 )
1001 .await
1002 .expect("must not block opening a FIFO")
1003 .expect_err("regular single-link target only");
1004 assert!(
1005 error.to_string().contains("regular"),
1006 "{operation}: {error}"
1007 );
1008 }
1009 }
1010 assert_eq!(fs::read(&plain).unwrap(), b"seed");
1011 }
1012
1013 #[tokio::test]
1014 async fn contract_read_newline_dense_ranges_preserve_trailing_and_empty_lines() {
1015 let temporary = tempfile::tempdir().unwrap();
1016 let text = "\n".repeat(2 * 1024 * 1024);
1017 fs::write(temporary.path().join("dense.txt"), &text).unwrap();
1018 let context = ToolContext::new(temporary.path());
1019 let result = ReadFileTool::execute_contract_read(
1020 json!({"path": "dense.txt", "offset": text.len(), "limit": 2}),
1021 &context,
1022 )
1023 .await
1024 .unwrap();
1025 assert_eq!(result.content, "\n");
1026 let metadata = result.metadata.as_ref().unwrap();
1027 assert_eq!(metadata["size"], text.len());
1028 assert_eq!(metadata["line_count"], text.len() + 1);
1029 assert_eq!(metadata["truncated"], false);
1030 let empty = ReadFileTool::execute_contract_read(
1031 json!({"path": "dense.txt", "offset": text.len() + 1, "limit": 0}),
1032 &context,
1033 )
1034 .await
1035 .unwrap();
1036 assert_eq!(
1037 empty.content,
1038 "\n\n[1 more lines in file (2.0MB total). Use offset=2097153 to continue.]"
1039 );
1040 }
1041
1042 #[tokio::test]
1043 async fn contract_write_and_edit_reject_payload_or_line_ending_growth_before_mutation() {
1044 let temporary = tempfile::tempdir().unwrap();
1045 let path = temporary.path().join("plain.txt");
1046 let context = ToolContext::new(temporary.path());
1047 fs::write(&path, b"seed\r\n").unwrap();
1048 let error = WriteFileTool::execute_contract_write(
1049 json!({"path": "plain.txt", "content": "x".repeat(CONTRACT_FILE_MAX_BYTES + 1)}),
1050 &context,
1051 )
1052 .await
1053 .unwrap_err();
1054 assert!(error.to_string().contains("16 MiB processing cap"));
1055 for operation in ["write", "edit"] {
1056 let newlines = "\n".repeat(CONTRACT_FILE_MAX_BYTES / 2 + 1);
1057 let result = if operation == "write" {
1058 WriteFileTool::execute_contract_write(
1059 json!({"path": "plain.txt", "content": newlines}),
1060 &context,
1061 )
1062 .await
1063 } else {
1064 EditFileTool::execute_contract_edits(
1065 json!({"path": "plain.txt", "edits": [{"oldText": "seed", "newText": newlines}]}),
1066 &context,
1067 )
1068 .await
1069 };
1070 assert!(
1071 result
1072 .unwrap_err()
1073 .to_string()
1074 .contains("16 MiB processing cap")
1075 );
1076 assert_eq!(fs::read(&path).unwrap(), b"seed\r\n");
1077 assert!(context.require_fresh_file_read(&path, "plain.txt").is_err());
1078 }
1079 }
1080
1081 #[tokio::test]
1082 async fn contract_edit_rejects_oversized_intermediate_replacement_even_if_later_edit_shrinks() {
1083 let temporary = tempfile::tempdir().unwrap();
1084 let path = temporary.path().join("plain.txt");
1085 fs::write(&path, b"ab").unwrap();
1086 let context = ToolContext::new(temporary.path());
1087 let error = EditFileTool::execute_contract_edits(
1088 json!({"path": "plain.txt", "edits": [
1089 {"oldText": "a", "newText": ""},
1090 {"oldText": "b", "newText": "x".repeat(CONTRACT_FILE_MAX_BYTES)}
1091 ]}),
1092 &context,
1093 )
1094 .await
1095 .unwrap_err();
1096 assert!(error.to_string().contains("16 MiB processing cap"));
1097 assert_eq!(fs::read(&path).unwrap(), b"ab");
1098 }
1099
1099 lines RUST