返回 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 #[tokio::test]
436 async fn contract_read_uses_magic_not_extension_for_images() {
437 let temporary = tempfile::tempdir().expect("tempdir");
438 std::fs::write(temporary.path().join("plain.png"), "ordinary text").expect("text fixture");
439 std::fs::write(
440 temporary.path().join("renamed.data"),
441 crate::image_attach::tests::PNG_1X1,
442 )
443 .expect("image fixture");
444 std::fs::write(
445 temporary.path().join("truncated.png"),
446 [b"\x89PNG\r\n\x1a\n".as_slice(), b"\0\0\0\rIHDR".as_slice()].concat(),
447 )
448 .expect("truncated image fixture");
449 let context = ToolContext::new(temporary.path());
450
451 let text = ReadFileTool::execute_contract_read(json!({"path": "plain.png"}), &context)
452 .await
453 .expect("fake extension remains text");
454 assert_eq!(text.content, "ordinary text");
455 let image = ReadFileTool::execute_contract_read(json!({"path": "renamed.data"}), &context)
456 .await
457 .expect("real image uses typed transport");
458 assert_eq!(image.content_blocks.len(), 1);
459 assert!(matches!(
460 &image.content_blocks[0],
461 codewhale_tools::ToolResultContentBlock::Image { mime_type, .. }
462 if mime_type == "image/png"
463 ));
464 let truncated = ReadFileTool::execute_contract_read(json!({"path": "truncated.png"}), &context)
465 .await
466 .expect("invalid image retains an omission receipt");
467 assert!(truncated.content_blocks.is_empty());
468 assert!(truncated.content.contains("Image omitted"));
469 }
470
471 #[test]
472 fn contract_edit_preparation_accepts_string_and_legacy_recovery_forms() {
473 let encoded = prepare_contract_edit_input(json!({
474 "path": "doc.txt",
475 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
476 }))
477 .expect("encoded edits");
478 assert_eq!(encoded["edits"][0], json!({"oldText": "a", "newText": "b"}));
479
480 let recovered = prepare_contract_edit_input(json!({
481 "path": "doc.txt",
482 "edits": {"malformed": true},
483 "oldText": "a",
484 "newText": "b"
485 }))
486 .expect("legacy recovery");
487 assert_eq!(
488 recovered["edits"],
489 json!([{"oldText": "a", "newText": "b"}])
490 );
491 assert!(recovered.get("oldText").is_none());
492 assert!(recovered.get("newText").is_none());
493 }
494
495 #[test]
496 fn contract_edit_fuzzy_normalization_preserves_untouched_lines() {
497 let original = "untouched line \nShe said “hello”—today. \ntail \n";
498 let updated = apply_contract_edits(
499 original,
500 &[ContractEdit {
501 index: 0,
502 old_text: "She said \"hello\"-today.".to_string(),
503 new_text: "She said hello.".to_string(),
504 }],
505 "doc.txt",
506 )
507 .expect("fuzzy edit");
508 assert_eq!(updated, "untouched line \nShe said hello.\ntail \n");
509 }
510
511 #[tokio::test]
512 async fn contract_edit_preserves_bom_and_crlf_without_prior_read() {
513 let temporary = tempfile::tempdir().expect("tempdir");
514 let path = temporary.path().join("doc.txt");
515 std::fs::write(&path, "\u{FEFF}alpha\r\nbeta\r\n").expect("fixture");
516 let context = ToolContext::new(temporary.path());
517 let result = EditFileTool::execute_contract_edits(
518 json!({
519 "path": "doc.txt",
520 "edits": [{"oldText": "alpha\nbeta", "newText": "one\ntwo"}]
521 }),
522 &context,
523 )
524 .await
525 .expect("edit");
526 assert_eq!(
527 result.content,
528 "Successfully replaced 1 block(s) in doc.txt."
529 );
530 assert_eq!(
531 std::fs::read(&path).expect("updated"),
532 "\u{FEFF}one\r\ntwo\r\n".as_bytes()
533 );
534 }
535
536 #[tokio::test]
537 async fn queued_parallel_contract_edits_preserve_both_changes() {
538 let temporary = tempfile::tempdir().expect("tempdir");
539 let path = temporary.path().join("doc.txt");
540 std::fs::write(&path, "alpha\nbeta\ngamma\n").expect("fixture");
541 let context = ToolContext::new(temporary.path());
542 let first_context = context.clone();
543 let second_context = context.clone();
544
545 let first = tokio::spawn(async move {
546 EditFileTool::execute_contract_edits(
547 json!({"path": "doc.txt", "edits": [{"oldText": "alpha", "newText": "A"}]}),
548 &first_context,
549 )
550 .await
551 });
552 let second = tokio::spawn(async move {
553 EditFileTool::execute_contract_edits(
554 json!({"path": "doc.txt", "edits": [{"oldText": "gamma", "newText": "G"}]}),
555 &second_context,
556 )
557 .await
558 });
559 first.await.expect("first task").expect("first edit");
560 second.await.expect("second task").expect("second edit");
561 assert_eq!(
562 std::fs::read_to_string(path).expect("updated"),
563 "A\nbeta\nG\n"
564 );
565 }
566
567 #[tokio::test]
568 async fn cancelled_queued_pi_write_never_starts() {
569 let temporary = tempfile::tempdir().expect("tempdir");
570 let context = ToolContext::new(temporary.path());
571 let path = context.resolve_path("queued.txt").expect("resolved path");
572 let held = file_mutation_lock(&path).expect("queue").lock_owned().await;
573 let cancellation = CancellationToken::new();
574 let queued_context = context.clone().with_cancel_token(cancellation.clone());
575 let queued = tokio::spawn(async move {
576 WriteFileTool::execute_contract_write(
577 json!({"path": "queued.txt", "content": "must-not-land"}),
578 &queued_context,
579 )
580 .await
581 });
582 tokio::task::yield_now().await;
583 cancellation.cancel();
584 let error = queued
585 .await
586 .expect("queued task")
587 .expect_err("queued write must cancel");
588 assert!(matches!(error, ToolError::Cancelled { .. }));
589 assert!(!path.exists());
590 drop(held);
591 }
592
593 #[cfg(unix)]
594 #[tokio::test]
595 async fn contract_edit_rejects_read_only_target_before_atomic_replace() {
596 use std::os::unix::fs::PermissionsExt;
597
598 let temporary = tempfile::tempdir().expect("tempdir");
599 let path = temporary.path().join("readonly.txt");
600 std::fs::write(&path, "alpha\n").expect("fixture");
601 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).expect("readonly");
602 let context = ToolContext::new(temporary.path());
603 let result = EditFileTool::execute_contract_edits(
604 json!({"path": "readonly.txt", "edits": [{"oldText": "alpha", "newText": "beta"}]}),
605 &context,
606 )
607 .await;
608 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
609 .expect("restore permissions");
610 let error = result.expect_err("read-only target must fail");
611 assert!(error.to_string().contains("readable and writable"));
612 assert_eq!(std::fs::read_to_string(path).expect("unchanged"), "alpha\n");
613 }
614
614 lines RUST