1mod bash;
17mod edit;
18mod glob;
19mod grep;
20pub mod prompt;
21mod read;
22mod state;
23mod write;
24
25use std::sync::Arc;
26
27use locode_host::Host;
28use locode_protocol::{ContentBlock, Message, Role};
29use locode_tools::Registry;
30
31use crate::pack::{Pack, PackContext};
32use bash::ClaudeBash;
33use edit::ClaudeEdit;
34use glob::ClaudeGlob;
35use grep::ClaudeGrep;
36use read::ClaudeRead;
37use state::ClaudeSessionState;
38use write::ClaudeWrite;
39
40#[derive(Debug, Default, Clone, Copy)]
43pub struct ClaudePack;
44
45impl Pack for ClaudePack {
46 fn name(&self) -> &'static str {
47 "claude"
48 }
49
50 fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
51 let state = Arc::new(ClaudeSessionState::default());
54 registry.register("Bash", ClaudeBash::new(Arc::clone(host)));
56 registry.register(
57 "Read",
58 ClaudeRead::new(Arc::clone(host), Arc::clone(&state)),
59 );
60 registry.register(
61 "Edit",
62 ClaudeEdit::new(Arc::clone(host), Arc::clone(&state)),
63 );
64 registry.register(
65 "Write",
66 ClaudeWrite::new(Arc::clone(host), Arc::clone(&state)),
67 );
68 registry.register("Glob", ClaudeGlob::new(Arc::clone(host)));
69 registry.register("Grep", ClaudeGrep::new(Arc::clone(host)));
70 }
71
72 fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
73 vec![
78 Message {
79 role: Role::System,
80 content: vec![ContentBlock::Text {
81 text: prompt::render(ctx),
82 }],
83 },
84 Message {
85 role: Role::User,
86 content: vec![ContentBlock::Text {
87 text: prompt::context_reminder(ctx),
88 }],
89 },
90 ]
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use locode_host::HostConfig;
98 use locode_protocol::ResultChunk;
99 use locode_tools::ToolCtx;
100 use serde_json::json;
101 use std::path::Path;
102 use tokio_util::sync::CancellationToken;
103
104 fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
108 let dir = tempfile::tempdir().unwrap();
109 let mut config = HostConfig::new(dir.path());
110 config.login_shell = false;
111 let host = Arc::new(Host::new(config).unwrap());
112 let root = host.workspace_root().to_path_buf();
113 let registry = ClaudePack.build_registry(&host);
114 (dir, registry, root)
115 }
116
117 fn ctx(dir: &Path) -> ToolCtx {
118 ToolCtx::new(
119 dir.to_path_buf(),
120 "c1".into(),
121 dir.to_path_buf(),
122 CancellationToken::new(),
123 )
124 }
125
126 fn result_text(block: &ContentBlock) -> String {
127 match block {
128 ContentBlock::ToolResult { content, .. } => content
129 .iter()
130 .filter_map(|chunk| match chunk {
131 ResultChunk::Text { text } => Some(text.clone()),
132 ResultChunk::Image { .. } => None,
133 })
134 .collect(),
135 _ => panic!("expected a tool_result"),
136 }
137 }
138
139 fn is_error(block: &ContentBlock) -> bool {
140 matches!(block, ContentBlock::ToolResult { is_error: true, .. })
141 }
142
143 #[test]
144 fn pack_registers_expected_tools_this_slice() {
145 let (_dir, registry, _root) = setup();
146 let mut names: Vec<&str> = registry.names().collect();
147 names.sort_unstable();
148 assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
149 assert_eq!(
150 registry.kind_of("Bash"),
151 Some(locode_tools::ToolKind::Shell)
152 );
153 assert_eq!(registry.kind_of("Read"), Some(locode_tools::ToolKind::Read));
154 assert_eq!(registry.kind_of("Edit"), Some(locode_tools::ToolKind::Edit));
155 assert_eq!(
156 registry.kind_of("Write"),
157 Some(locode_tools::ToolKind::Write)
158 );
159 assert_eq!(registry.kind_of("Glob"), Some(locode_tools::ToolKind::Glob));
160 assert_eq!(registry.kind_of("Grep"), Some(locode_tools::ToolKind::Grep));
161 }
162
163 #[test]
164 fn bash_schema_is_faithful() {
165 let (_dir, registry, _root) = setup();
166 let specs = registry.specs();
167 let bash = specs.iter().find(|s| s.name == "Bash").expect("Bash spec");
168 let params = match &bash.input {
169 locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
170 locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Bash is JSON-schema"),
171 };
172 assert_eq!(params["additionalProperties"], json!(false));
174 let props = params["properties"].as_object().unwrap();
175 assert_eq!(
177 props["command"]["description"],
178 json!("The command to execute")
179 );
180 assert_eq!(
181 props["timeout"]["description"],
182 json!("Optional timeout in milliseconds (max 600000)")
183 );
184 assert!(
185 props["description"]["description"]
186 .as_str()
187 .unwrap()
188 .starts_with("Clear, concise description of what this command does")
189 );
190 for absent in [
192 "run_in_background",
193 "dangerouslyDisableSandbox",
194 "_simulatedSedEdit",
195 ] {
196 assert!(!props.contains_key(absent), "{absent} must be absent");
197 }
198 let required: Vec<&str> = params["required"]
200 .as_array()
201 .unwrap()
202 .iter()
203 .map(|v| v.as_str().unwrap())
204 .collect();
205 assert_eq!(required, vec!["command"]);
206 }
207
208 #[test]
209 fn shape_user_prompt_is_verbatim_for_claude() {
210 assert_eq!(ClaudePack.shape_user_prompt("do the thing"), "do the thing");
212 assert_ne!(
213 crate::GrokPack.shape_user_prompt("do the thing"),
214 "do the thing"
215 );
216 }
217
218 #[test]
219 fn preamble_is_system_then_user_reminder() {
220 let dir = tempfile::tempdir().unwrap();
221 let pc = PackContext {
222 cwd: dir.path().to_path_buf(),
223 os: "macos".into(),
224 shell: "/bin/zsh".into(),
225 date: "2026-07-24".into(),
226 headless: true,
227 is_git_repo: false,
228 model: Some("claude-opus-4-8".into()),
229 os_version: Some("Darwin 24.6.0".into()),
230 timezone: None,
231 strip_identity: false,
232 };
233 let msgs = ClaudePack.preamble(&pc);
235 assert_eq!(msgs.len(), 2);
236 assert_eq!(msgs[0].role, Role::System);
237 assert_eq!(msgs[1].role, Role::User);
238 }
239
240 #[cfg(unix)]
243 #[tokio::test]
244 async fn bash_echo_ok() {
245 let (_dir, registry, root) = setup();
246 let out = registry
247 .dispatch("Bash", json!({ "command": "echo hi" }), &ctx(&root))
248 .await;
249 assert!(out.record.ok);
250 assert!(!is_error(&out.tool_result));
251 assert_eq!(result_text(&out.tool_result), "hi");
252 assert_eq!(out.record.output["exit_code"], json!(0));
253 }
254
255 #[cfg(unix)]
256 #[tokio::test]
257 async fn bash_nonzero_exit_is_soft_ok_with_exit_code_note() {
258 let (_dir, registry, root) = setup();
259 let out = registry
260 .dispatch(
261 "Bash",
262 json!({ "command": "echo oops; exit 3" }),
263 &ctx(&root),
264 )
265 .await;
266 assert!(out.record.ok);
268 assert!(!is_error(&out.tool_result));
269 let text = result_text(&out.tool_result);
270 assert_eq!(text, "oops\nExit code 3", "{text}");
271 assert_eq!(out.record.output["exit_code"], json!(3));
272 }
273
274 #[cfg(unix)]
275 #[tokio::test]
276 async fn bash_timeout_is_interrupted_soft_error() {
277 let (_dir, registry, root) = setup();
278 let out = registry
279 .dispatch(
280 "Bash",
281 json!({ "command": "sleep 5", "timeout": 50 }),
282 &ctx(&root),
283 )
284 .await;
285 assert!(is_error(&out.tool_result));
287 assert!(
288 result_text(&out.tool_result).contains("Command was aborted before completion"),
289 "{}",
290 result_text(&out.tool_result)
291 );
292 assert!(!out.record.ok);
294 }
295
296 #[cfg(unix)]
297 #[tokio::test]
298 async fn bash_large_output_is_middle_truncated() {
299 let (_dir, registry, root) = setup();
300 let out = registry
302 .dispatch(
303 "Bash",
304 json!({ "command": "for i in $(seq 1 8000); do echo LINE$i; done" }),
305 &ctx(&root),
306 )
307 .await;
308 assert!(out.record.ok);
309 let text = result_text(&out.tool_result);
310 assert!(
315 text.contains("truncated"),
316 "carries a truncation marker: {}",
317 &text[..60]
318 );
319 assert!(text.contains("LINE1\n"), "head retained: {}", &text[..40]);
320 assert!(text.contains("LINE8000"), "tail retained");
321 assert_eq!(out.record.output["truncated"], json!(true));
322 }
323
324 #[tokio::test]
327 async fn read_numbers_lines_cat_n_compact() {
328 let (_dir, registry, root) = setup();
329 std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
330 let out = registry
331 .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
332 .await;
333 assert!(out.record.ok);
334 assert_eq!(result_text(&out.tool_result), "1\talpha\n2\tbeta\n3\tgamma");
336 assert_eq!(out.record.output["lines"], json!(3));
337 assert_eq!(out.record.output["truncated"], json!(false));
338 }
339
340 #[tokio::test]
341 async fn read_offset_and_limit_window() {
342 let (_dir, registry, root) = setup();
343 std::fs::write(root.join("f.txt"), "l1\nl2\nl3\nl4\nl5\n").unwrap();
344 let out = registry
345 .dispatch(
346 "Read",
347 json!({ "file_path": "f.txt", "offset": 2, "limit": 2 }),
348 &ctx(&root),
349 )
350 .await;
351 assert!(out.record.ok);
352 assert_eq!(result_text(&out.tool_result), "2\tl2\n3\tl3");
354 assert_eq!(out.record.output["truncated"], json!(true));
355 }
356
357 #[tokio::test]
358 async fn read_empty_file_warns() {
359 let (_dir, registry, root) = setup();
360 std::fs::write(root.join("e.txt"), "").unwrap();
361 let out = registry
362 .dispatch("Read", json!({ "file_path": "e.txt" }), &ctx(&root))
363 .await;
364 assert!(out.record.ok);
365 assert_eq!(
366 result_text(&out.tool_result),
367 "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>"
368 );
369 }
370
371 #[tokio::test]
372 async fn read_offset_past_eof_warns() {
373 let (_dir, registry, root) = setup();
374 std::fs::write(root.join("f.txt"), "a\nb\n").unwrap();
375 let out = registry
376 .dispatch(
377 "Read",
378 json!({ "file_path": "f.txt", "offset": 99 }),
379 &ctx(&root),
380 )
381 .await;
382 assert!(out.record.ok);
383 assert_eq!(
384 result_text(&out.tool_result),
385 "<system-reminder>Warning: the file exists but is shorter than the provided offset (99). The file has 2 lines.</system-reminder>"
386 );
387 }
388
389 #[tokio::test]
390 async fn read_missing_file_is_soft_error() {
391 let (_dir, registry, root) = setup();
392 let out = registry
393 .dispatch("Read", json!({ "file_path": "nope.txt" }), &ctx(&root))
394 .await;
395 assert!(!out.record.ok);
396 assert!(is_error(&out.tool_result));
397 }
398
399 #[tokio::test]
400 async fn read_dedup_unchanged_then_rereads_after_change() {
401 let (_dir, registry, root) = setup();
402 let p = root.join("f.txt");
403 std::fs::write(&p, "one\ntwo\n").unwrap();
404 let first = registry
406 .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
407 .await;
408 assert_eq!(result_text(&first.tool_result), "1\tone\n2\ttwo");
409 let second = registry
411 .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
412 .await;
413 assert!(second.record.ok);
414 assert!(result_text(&second.tool_result).starts_with("File unchanged since last read."));
415 std::thread::sleep(std::time::Duration::from_millis(20));
417 std::fs::write(&p, "one\ntwo\nthree\n").unwrap();
418 let third = registry
419 .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
420 .await;
421 assert_eq!(result_text(&third.tool_result), "1\tone\n2\ttwo\n3\tthree");
422 }
423
424 #[test]
425 fn read_schema_is_faithful() {
426 let (_dir, registry, _root) = setup();
427 let specs = registry.specs();
428 let read = specs.iter().find(|s| s.name == "Read").expect("Read spec");
429 let params = match &read.input {
430 locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
431 locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Read is JSON-schema"),
432 };
433 assert_eq!(params["additionalProperties"], json!(false));
434 let props = params["properties"].as_object().unwrap();
435 assert_eq!(
436 props["file_path"]["description"],
437 json!("The absolute path to the file to read")
438 );
439 for key in ["file_path", "offset", "limit", "pages"] {
440 assert!(props.contains_key(key), "missing schema field {key}");
441 }
442 let required: Vec<&str> = params["required"]
443 .as_array()
444 .unwrap()
445 .iter()
446 .map(|v| v.as_str().unwrap())
447 .collect();
448 assert_eq!(required, vec!["file_path"]);
449 }
450
451 async fn read_then(registry: &Registry, root: &Path, file: &str) {
454 let _ = registry
455 .dispatch("Read", json!({ "file_path": file }), &ctx(root))
456 .await;
457 }
458
459 #[tokio::test]
460 async fn edit_requires_prior_read() {
461 let (_dir, registry, root) = setup();
462 std::fs::write(root.join("f.txt"), "alpha beta").unwrap();
463 let out = registry
465 .dispatch(
466 "Edit",
467 json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
468 &ctx(&root),
469 )
470 .await;
471 assert!(!out.record.ok);
472 assert_eq!(
473 result_text(&out.tool_result),
474 "File has not been read yet. Read it first before writing to it."
475 );
476 }
477
478 #[tokio::test]
479 async fn edit_after_read_succeeds_and_sequential_edit_is_fresh() {
480 let (_dir, registry, root) = setup();
481 std::fs::write(root.join("f.txt"), "one two three").unwrap();
482 read_then(®istry, &root, "f.txt").await;
483 let out = registry
484 .dispatch(
485 "Edit",
486 json!({ "file_path": "f.txt", "old_string": "two", "new_string": "TWO" }),
487 &ctx(&root),
488 )
489 .await;
490 assert!(out.record.ok, "{}", result_text(&out.tool_result));
491 assert_eq!(
492 result_text(&out.tool_result),
493 "The file f.txt has been updated successfully."
494 );
495 assert_eq!(
496 std::fs::read_to_string(root.join("f.txt")).unwrap(),
497 "one TWO three"
498 );
499 let out2 = registry
501 .dispatch(
502 "Edit",
503 json!({ "file_path": "f.txt", "old_string": "one", "new_string": "ONE" }),
504 &ctx(&root),
505 )
506 .await;
507 assert!(out2.record.ok, "{}", result_text(&out2.tool_result));
508 }
509
510 #[tokio::test]
511 async fn edit_stale_after_external_modification_is_rejected() {
512 let (_dir, registry, root) = setup();
513 let p = root.join("f.txt");
514 std::fs::write(&p, "hello world").unwrap();
515 read_then(®istry, &root, "f.txt").await;
516 std::thread::sleep(std::time::Duration::from_millis(20));
518 std::fs::write(&p, "hello there").unwrap();
519 let out = registry
520 .dispatch(
521 "Edit",
522 json!({ "file_path": "f.txt", "old_string": "hello", "new_string": "HI" }),
523 &ctx(&root),
524 )
525 .await;
526 assert!(!out.record.ok);
527 assert!(
528 result_text(&out.tool_result).starts_with("File has been modified since read"),
529 "{}",
530 result_text(&out.tool_result)
531 );
532 }
533
534 #[tokio::test]
535 async fn edit_no_op_not_found_and_multi_match() {
536 let (_dir, registry, root) = setup();
537 std::fs::write(root.join("f.txt"), "a a a").unwrap();
538 read_then(®istry, &root, "f.txt").await;
539 let noop = registry
541 .dispatch(
542 "Edit",
543 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "a" }),
544 &ctx(&root),
545 )
546 .await;
547 assert!(!noop.record.ok);
548 assert!(result_text(&noop.tool_result).contains("exactly the same"));
549 let nf = registry
551 .dispatch(
552 "Edit",
553 json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
554 &ctx(&root),
555 )
556 .await;
557 assert!(!nf.record.ok);
558 assert!(result_text(&nf.tool_result).starts_with("String to replace not found in file."));
559 let mm = registry
561 .dispatch(
562 "Edit",
563 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
564 &ctx(&root),
565 )
566 .await;
567 assert!(!mm.record.ok);
568 assert!(result_text(&mm.tool_result).starts_with("Found 3 matches"));
569 let all = registry
571 .dispatch(
572 "Edit",
573 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
574 &ctx(&root),
575 )
576 .await;
577 assert!(all.record.ok);
578 assert_eq!(
579 result_text(&all.tool_result),
580 "The file f.txt has been updated. All occurrences were successfully replaced."
581 );
582 assert_eq!(
583 std::fs::read_to_string(root.join("f.txt")).unwrap(),
584 "b b b"
585 );
586 }
587
588 #[tokio::test]
589 async fn edit_creates_file_with_empty_old_string() {
590 let (_dir, registry, root) = setup();
592 let out = registry
593 .dispatch(
594 "Edit",
595 json!({ "file_path": "new.txt", "old_string": "", "new_string": "fresh content" }),
596 &ctx(&root),
597 )
598 .await;
599 assert!(out.record.ok, "{}", result_text(&out.tool_result));
600 assert_eq!(out.record.output["created"], json!(true));
601 assert_eq!(
602 std::fs::read_to_string(root.join("new.txt")).unwrap(),
603 "fresh content"
604 );
605 }
606
607 #[tokio::test]
608 async fn edit_empty_old_string_on_existing_nonempty_is_rejected() {
609 let (_dir, registry, root) = setup();
610 std::fs::write(root.join("f.txt"), "existing").unwrap();
611 let out = registry
612 .dispatch(
613 "Edit",
614 json!({ "file_path": "f.txt", "old_string": "", "new_string": "x" }),
615 &ctx(&root),
616 )
617 .await;
618 assert!(!out.record.ok);
619 assert_eq!(
620 result_text(&out.tool_result),
621 "Cannot create new file - file already exists."
622 );
623 }
624
625 #[tokio::test]
628 async fn write_new_file_creates_freely() {
629 let (_dir, registry, root) = setup();
630 let out = registry
631 .dispatch(
632 "Write",
633 json!({ "file_path": "new.txt", "content": "hello\nworld\n" }),
634 &ctx(&root),
635 )
636 .await;
637 assert!(out.record.ok, "{}", result_text(&out.tool_result));
638 assert_eq!(
639 result_text(&out.tool_result),
640 "File created successfully at: new.txt"
641 );
642 assert_eq!(out.record.output["created"], json!(true));
643 assert_eq!(
644 std::fs::read_to_string(root.join("new.txt")).unwrap(),
645 "hello\nworld\n"
646 );
647 }
648
649 #[tokio::test]
650 async fn write_existing_unread_is_rejected() {
651 let (_dir, registry, root) = setup();
652 std::fs::write(root.join("f.txt"), "old").unwrap();
653 let out = registry
654 .dispatch(
655 "Write",
656 json!({ "file_path": "f.txt", "content": "new" }),
657 &ctx(&root),
658 )
659 .await;
660 assert!(!out.record.ok);
661 assert_eq!(
662 result_text(&out.tool_result),
663 "File has not been read yet. Read it first before writing to it."
664 );
665 }
666
667 #[tokio::test]
668 async fn write_existing_after_read_overwrites() {
669 let (_dir, registry, root) = setup();
670 std::fs::write(root.join("f.txt"), "old content").unwrap();
671 read_then(®istry, &root, "f.txt").await;
672 let out = registry
673 .dispatch(
674 "Write",
675 json!({ "file_path": "f.txt", "content": "brand new" }),
676 &ctx(&root),
677 )
678 .await;
679 assert!(out.record.ok, "{}", result_text(&out.tool_result));
680 assert_eq!(
681 result_text(&out.tool_result),
682 "The file f.txt has been updated successfully."
683 );
684 assert_eq!(out.record.output["created"], json!(false));
685 assert_eq!(
686 std::fs::read_to_string(root.join("f.txt")).unwrap(),
687 "brand new"
688 );
689 }
690
691 #[tokio::test]
692 async fn write_creates_missing_parent_dirs() {
693 let (_dir, registry, root) = setup();
695 let out = registry
696 .dispatch(
697 "Write",
698 json!({ "file_path": "a/b/c.txt", "content": "deep" }),
699 &ctx(&root),
700 )
701 .await;
702 assert!(out.record.ok, "{}", result_text(&out.tool_result));
703 assert_eq!(
704 std::fs::read_to_string(root.join("a/b/c.txt")).unwrap(),
705 "deep"
706 );
707 }
708
709 #[tokio::test]
710 async fn edit_creates_file_with_missing_parent_dirs() {
711 let (_dir, registry, root) = setup();
712 let out = registry
713 .dispatch(
714 "Edit",
715 json!({ "file_path": "x/y/z.txt", "old_string": "", "new_string": "made" }),
716 &ctx(&root),
717 )
718 .await;
719 assert!(out.record.ok, "{}", result_text(&out.tool_result));
720 assert_eq!(out.record.output["created"], json!(true));
721 assert_eq!(
722 std::fs::read_to_string(root.join("x/y/z.txt")).unwrap(),
723 "made"
724 );
725 }
726
727 #[tokio::test]
728 async fn write_stale_after_external_modification_is_rejected() {
729 let (_dir, registry, root) = setup();
730 let p = root.join("f.txt");
731 std::fs::write(&p, "v1").unwrap();
732 read_then(®istry, &root, "f.txt").await;
733 std::thread::sleep(std::time::Duration::from_millis(20));
734 std::fs::write(&p, "v2 external").unwrap();
735 let out = registry
736 .dispatch(
737 "Write",
738 json!({ "file_path": "f.txt", "content": "v3" }),
739 &ctx(&root),
740 )
741 .await;
742 assert!(!out.record.ok);
743 assert!(result_text(&out.tool_result).starts_with("File has been modified since read"));
744 }
745
746 fn rg_present() -> bool {
749 std::process::Command::new("rg")
750 .arg("--version")
751 .output()
752 .is_ok()
753 }
754
755 #[tokio::test]
756 async fn glob_finds_matching_files() {
757 if !rg_present() {
758 return;
759 }
760 let (_dir, registry, root) = setup();
761 std::fs::create_dir(root.join("src")).unwrap();
762 std::fs::write(root.join("src/main.rs"), "").unwrap();
763 std::fs::write(root.join("src/lib.rs"), "").unwrap();
764 std::fs::write(root.join("README.md"), "").unwrap();
765 let out = registry
766 .dispatch("Glob", json!({ "pattern": "**/*.rs" }), &ctx(&root))
767 .await;
768 assert!(out.record.ok, "{}", result_text(&out.tool_result));
769 let text = result_text(&out.tool_result);
770 assert!(text.contains("src/main.rs"), "{text}");
771 assert!(text.contains("src/lib.rs"), "{text}");
772 assert!(!text.contains("README.md"), "{text}");
773 assert_eq!(out.record.output["truncated"], json!(false));
774 }
775
776 #[tokio::test]
777 async fn glob_no_match_says_no_files_found() {
778 if !rg_present() {
779 return;
780 }
781 let (_dir, registry, root) = setup();
782 std::fs::write(root.join("a.txt"), "").unwrap();
783 let out = registry
784 .dispatch("Glob", json!({ "pattern": "**/*.zzz" }), &ctx(&root))
785 .await;
786 assert!(out.record.ok);
787 assert_eq!(result_text(&out.tool_result), "No files found");
788 }
789
790 #[tokio::test]
791 async fn glob_path_missing_is_soft_error() {
792 let (_dir, registry, root) = setup();
793 let out = registry
794 .dispatch(
795 "Glob",
796 json!({ "pattern": "*", "path": "nope" }),
797 &ctx(&root),
798 )
799 .await;
800 assert!(!out.record.ok);
801 assert!(result_text(&out.tool_result).starts_with("Directory does not exist: nope."));
802 }
803
804 #[tokio::test]
805 async fn glob_path_is_a_file_is_soft_error() {
806 let (_dir, registry, root) = setup();
807 std::fs::write(root.join("f.txt"), "x").unwrap();
808 let out = registry
809 .dispatch(
810 "Glob",
811 json!({ "pattern": "*", "path": "f.txt" }),
812 &ctx(&root),
813 )
814 .await;
815 assert!(!out.record.ok);
816 assert_eq!(
817 result_text(&out.tool_result),
818 "Path is not a directory: f.txt"
819 );
820 }
821
822 #[tokio::test]
825 async fn grep_files_with_matches_default() {
826 if !rg_present() {
827 return;
828 }
829 let (_dir, registry, root) = setup();
830 std::fs::write(root.join("a.txt"), "needle here\n").unwrap();
831 std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
832 let out = registry
833 .dispatch("Grep", json!({ "pattern": "needle" }), &ctx(&root))
834 .await;
835 assert!(out.record.ok, "{}", result_text(&out.tool_result));
836 let text = result_text(&out.tool_result);
837 assert!(text.starts_with("Found 1 file"), "{text}");
838 assert!(text.contains("a.txt"), "{text}");
839 assert!(!text.contains("b.txt"), "{text}");
840 }
841
842 #[tokio::test]
843 async fn grep_no_match_files_mode() {
844 if !rg_present() {
845 return;
846 }
847 let (_dir, registry, root) = setup();
848 std::fs::write(root.join("a.txt"), "hello\n").unwrap();
849 let out = registry
850 .dispatch("Grep", json!({ "pattern": "zzznope" }), &ctx(&root))
851 .await;
852 assert!(out.record.ok);
853 assert_eq!(result_text(&out.tool_result), "No files found");
854 }
855
856 #[tokio::test]
857 async fn grep_content_mode_with_line_numbers() {
858 if !rg_present() {
859 return;
860 }
861 let (_dir, registry, root) = setup();
862 std::fs::write(root.join("a.txt"), "one\nneedle two\nthree\n").unwrap();
863 let out = registry
864 .dispatch(
865 "Grep",
866 json!({ "pattern": "needle", "output_mode": "content" }),
867 &ctx(&root),
868 )
869 .await;
870 assert!(out.record.ok);
871 let text = result_text(&out.tool_result);
872 assert!(text.contains("a.txt:2:needle two"), "{text}");
874 }
875
876 #[tokio::test]
877 async fn grep_content_no_match() {
878 if !rg_present() {
879 return;
880 }
881 let (_dir, registry, root) = setup();
882 std::fs::write(root.join("a.txt"), "hello\n").unwrap();
883 let out = registry
884 .dispatch(
885 "Grep",
886 json!({ "pattern": "zzz", "output_mode": "content" }),
887 &ctx(&root),
888 )
889 .await;
890 assert!(out.record.ok);
891 assert_eq!(result_text(&out.tool_result), "No matches found");
892 }
893
894 #[tokio::test]
895 async fn grep_count_mode_summary() {
896 if !rg_present() {
897 return;
898 }
899 let (_dir, registry, root) = setup();
900 std::fs::write(root.join("a.txt"), "x\nx\nx\n").unwrap();
901 let out = registry
902 .dispatch(
903 "Grep",
904 json!({ "pattern": "x", "output_mode": "count" }),
905 &ctx(&root),
906 )
907 .await;
908 assert!(out.record.ok);
909 let text = result_text(&out.tool_result);
910 assert!(
911 text.contains("Found 3 total occurrences across 1 file."),
912 "{text}"
913 );
914 assert_eq!(out.record.output["num_matches"], json!(3));
915 }
916
917 #[test]
918 fn grep_schema_is_faithful() {
919 let (_dir, registry, _root) = setup();
920 let specs = registry.specs();
921 let grep = specs.iter().find(|s| s.name == "Grep").expect("Grep spec");
922 let params = match &grep.input {
923 locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
924 locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Grep is JSON-schema"),
925 };
926 assert_eq!(params["additionalProperties"], json!(false));
927 let props = params["properties"].as_object().unwrap();
928 for key in [
930 "pattern",
931 "path",
932 "glob",
933 "output_mode",
934 "-A",
935 "-B",
936 "-C",
937 "context",
938 "-n",
939 "-i",
940 "type",
941 "head_limit",
942 "offset",
943 "multiline",
944 ] {
945 assert!(props.contains_key(key), "missing schema field {key}");
946 }
947 assert_eq!(
948 props["pattern"]["description"],
949 json!("The regular expression pattern to search for in file contents")
950 );
951 let required: Vec<&str> = params["required"]
952 .as_array()
953 .unwrap()
954 .iter()
955 .map(|v| v.as_str().unwrap())
956 .collect();
957 assert_eq!(required, vec!["pattern"]);
958 }
959
960 #[test]
961 fn glob_schema_is_faithful() {
962 let (_dir, registry, _root) = setup();
963 let specs = registry.specs();
964 let glob = specs.iter().find(|s| s.name == "Glob").expect("Glob spec");
965 let params = match &glob.input {
966 locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
967 locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Glob is JSON-schema"),
968 };
969 assert_eq!(params["additionalProperties"], json!(false));
970 let props = params["properties"].as_object().unwrap();
971 assert_eq!(
972 props["pattern"]["description"],
973 json!("The glob pattern to match files against")
974 );
975 assert!(
976 props["path"]["description"]
977 .as_str()
978 .unwrap()
979 .contains("DO NOT enter \"undefined\" or \"null\"")
980 );
981 let required: Vec<&str> = params["required"]
982 .as_array()
983 .unwrap()
984 .iter()
985 .map(|v| v.as_str().unwrap())
986 .collect();
987 assert_eq!(required, vec!["pattern"]);
988 }
989
990 #[test]
991 fn write_schema_is_faithful() {
992 let (_dir, registry, _root) = setup();
993 let specs = registry.specs();
994 let write = specs
995 .iter()
996 .find(|s| s.name == "Write")
997 .expect("Write spec");
998 let params = match &write.input {
999 locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
1000 locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Write is JSON-schema"),
1001 };
1002 assert_eq!(params["additionalProperties"], json!(false));
1003 let props = params["properties"].as_object().unwrap();
1004 assert_eq!(
1005 props["file_path"]["description"],
1006 json!("The absolute path to the file to write (must be absolute, not relative)")
1007 );
1008 assert_eq!(
1009 props["content"]["description"],
1010 json!("The content to write to the file")
1011 );
1012 let mut required: Vec<&str> = params["required"]
1013 .as_array()
1014 .unwrap()
1015 .iter()
1016 .map(|v| v.as_str().unwrap())
1017 .collect();
1018 required.sort_unstable();
1019 assert_eq!(required, vec!["content", "file_path"]);
1020 }
1021
1022 #[test]
1023 fn edit_schema_is_faithful() {
1024 let (_dir, registry, _root) = setup();
1025 let specs = registry.specs();
1026 let edit = specs.iter().find(|s| s.name == "Edit").expect("Edit spec");
1027 let params = match &edit.input {
1028 locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
1029 locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Edit is JSON-schema"),
1030 };
1031 assert_eq!(params["additionalProperties"], json!(false));
1032 let props = params["properties"].as_object().unwrap();
1033 assert_eq!(
1034 props["new_string"]["description"],
1035 json!("The text to replace it with (must be different from old_string)")
1036 );
1037 for key in ["file_path", "old_string", "new_string", "replace_all"] {
1038 assert!(props.contains_key(key), "missing schema field {key}");
1039 }
1040 let mut required: Vec<&str> = params["required"]
1041 .as_array()
1042 .unwrap()
1043 .iter()
1044 .map(|v| v.as_str().unwrap())
1045 .collect();
1046 required.sort_unstable();
1047 assert_eq!(required, vec!["file_path", "new_string", "old_string"]);
1048 }
1049
1050 #[test]
1051 fn read_offset_rejects_string_and_float() {
1052 for bad in [
1054 json!({"file_path": "f", "offset": "3"}),
1055 json!({"file_path": "f", "limit": 2.5}),
1056 ] {
1057 assert!(serde_json::from_value::<read::ReadArgs>(bad).is_err());
1058 }
1059 }
1060
1061 #[tokio::test]
1062 async fn bash_rejects_unknown_field() {
1063 let (_dir, registry, root) = setup();
1064 let out = registry
1066 .dispatch(
1067 "Bash",
1068 json!({ "command": "echo hi", "run_in_background": true }),
1069 &ctx(&root),
1070 )
1071 .await;
1072 assert!(!out.record.ok, "unknown field rejected");
1073 assert!(is_error(&out.tool_result));
1074 }
1075}