1#[allow(clippy::pedantic, clippy::unwrap_used)]
7mod confusables;
8mod grep;
9mod list_dir;
10pub mod prompt;
11mod read;
12mod search_replace;
13mod terminal;
14
15use std::sync::Arc;
16
17use locode_host::Host;
18use locode_protocol::{ContentBlock, Message, Role};
19use locode_tools::Registry;
20
21use crate::pack::{Pack, PackContext};
22use grep::GrokGrep;
23use list_dir::GrokListDir;
24use read::GrokReadFile;
25use search_replace::GrokSearchReplace;
26use terminal::GrokRunTerminalCmd;
27
28#[derive(Debug, Default, Clone, Copy)]
30pub struct GrokPack;
31
32impl Pack for GrokPack {
33 fn name(&self) -> &'static str {
34 "grok"
35 }
36
37 fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
38 registry.register(
39 "run_terminal_cmd",
40 GrokRunTerminalCmd::new(Arc::clone(host)),
41 );
42 registry.register("read_file", GrokReadFile::new(Arc::clone(host)));
43 registry.register("search_replace", GrokSearchReplace::new(Arc::clone(host)));
44 registry.register("grep", GrokGrep::new(Arc::clone(host)));
45 registry.register("list_dir", GrokListDir::new(Arc::clone(host)));
46 }
47
48 fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
49 vec![
56 Message {
57 role: Role::System,
58 content: vec![ContentBlock::Text {
59 text: prompt::render_base_prompt(ctx),
60 }],
61 },
62 Message {
63 role: Role::User,
64 content: vec![ContentBlock::Text {
65 text: prompt::user_info_block(ctx),
66 }],
67 },
68 ]
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use locode_host::HostConfig;
76 use locode_protocol::ResultChunk;
77 use locode_tools::ToolCtx;
78 use serde_json::json;
79 use std::path::Path;
80 use tokio_util::sync::CancellationToken;
81
82 fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
87 let dir = tempfile::tempdir().unwrap();
88 let mut config = HostConfig::new(dir.path());
89 config.login_shell = false;
90 let host = Arc::new(Host::new(config).unwrap());
91 let root = host.workspace_root().to_path_buf();
92 let registry = GrokPack.build_registry(&host);
93 (dir, registry, root)
94 }
95
96 fn ctx(dir: &Path) -> ToolCtx {
97 ToolCtx::new(
98 dir.to_path_buf(),
99 "c1".into(),
100 dir.to_path_buf(),
101 CancellationToken::new(),
102 )
103 }
104
105 fn result_text(block: &ContentBlock) -> String {
106 match block {
107 ContentBlock::ToolResult { content, .. } => content
108 .iter()
109 .filter_map(|chunk| match chunk {
110 ResultChunk::Text { text } => Some(text.clone()),
111 ResultChunk::Image { .. } => None,
112 })
113 .collect(),
114 _ => panic!("expected a tool_result"),
115 }
116 }
117
118 fn is_error(block: &ContentBlock) -> bool {
119 matches!(block, ContentBlock::ToolResult { is_error: true, .. })
120 }
121
122 #[cfg(unix)]
124 #[tokio::test]
125 async fn run_terminal_cmd_echo() {
126 let (_dir, registry, root) = setup();
127 let out = registry
128 .dispatch(
129 "run_terminal_cmd",
130 json!({ "command": "echo hi", "description": "say hi" }),
131 &ctx(&root),
132 )
133 .await;
134 assert!(out.record.ok);
135 assert_eq!(out.record.output["exit_code"], json!(0));
136 let text = result_text(&out.tool_result);
137 assert!(text.contains("exit: 0"), "{text}");
138 assert!(text.contains("hi"), "{text}");
139 }
140
141 #[cfg(unix)]
143 #[tokio::test]
144 async fn run_terminal_cmd_nonzero_exit_is_soft() {
145 let (_dir, registry, root) = setup();
146 let out = registry
147 .dispatch(
148 "run_terminal_cmd",
149 json!({ "command": "exit 3", "description": "fail" }),
150 &ctx(&root),
151 )
152 .await;
153 assert!(out.record.ok);
155 assert!(!is_error(&out.tool_result));
156 assert_eq!(out.record.output["exit_code"], json!(3));
157 }
158
159 #[cfg(unix)]
161 #[tokio::test]
162 async fn run_terminal_cmd_truncates_front_back_with_grok_markers() {
163 let (_dir, registry, root) = setup();
164 let out = registry
165 .dispatch(
166 "run_terminal_cmd",
167 json!({
168 "command": "for i in $(seq 1 3000); do echo line-$i; done",
169 "description": "spam output"
170 }),
171 &ctx(&root),
172 )
173 .await;
174 assert!(out.record.ok);
175 let text = result_text(&out.tool_result);
176 assert!(
177 text.starts_with("exit: 0 [truncated: showing first/last "),
178 "{}",
179 &text[..80]
180 );
181 assert!(text.contains(" - full output at: "), "spill path in header");
182 assert!(
183 text.contains("\n\n... (output truncated) ...\n\n"),
184 "grok separator"
185 );
186 assert!(text.contains("line-1\n"), "front retained");
187 assert!(text.contains("line-3000"), "back retained");
188 assert_eq!(out.record.output["truncated"], json!(true));
189 }
190
191 #[tokio::test]
192 async fn run_terminal_cmd_rejects_background_operator() {
193 let (_dir, registry, root) = setup();
194 let out = registry
195 .dispatch(
196 "run_terminal_cmd",
197 json!({ "command": "sleep 5 &", "description": "bg" }),
198 &ctx(&root),
199 )
200 .await;
201 assert!(is_error(&out.tool_result));
202 assert_eq!(
203 result_text(&out.tool_result),
204 "Remove the background '&' from your command; background execution is disabled."
205 );
206 }
207
208 #[tokio::test]
209 async fn read_file_numbers_lines() {
210 let (_dir, registry, root) = setup();
211 std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
212 let out = registry
213 .dispatch("read_file", json!({ "target_file": "f.txt" }), &ctx(&root))
214 .await;
215 assert!(out.record.ok);
216 assert_eq!(out.record.output["lines"], json!(4));
219 assert_eq!(out.record.output["truncated"], json!(false));
220 let text = result_text(&out.tool_result);
223 assert_eq!(text, "1→alpha\nbeta\ngamma\n");
224 }
225
226 #[tokio::test]
227 async fn read_file_line_cap_truncates() {
228 use std::fmt::Write as _;
229 let (_dir, registry, root) = setup();
230 let mut big = String::new();
231 for n in 1..=1500 {
232 writeln!(big, "line {n}").unwrap();
233 }
234 std::fs::write(root.join("big.txt"), big).unwrap();
235 let out = registry
236 .dispatch(
237 "read_file",
238 json!({ "target_file": "big.txt" }),
239 &ctx(&root),
240 )
241 .await;
242 assert!(out.record.ok);
243 assert_eq!(out.record.output["lines"], json!(1501));
245 assert_eq!(out.record.output["truncated"], json!(true));
246 let text = result_text(&out.tool_result);
249 assert!(
250 text.starts_with("1→line 1\nline 2\n"),
251 "first anchor + bare"
252 );
253 assert!(text.contains("1000→line 1000"), "capped at 1000");
254 assert!(
255 !text.contains("990→line 990\nline 991\n991→"),
256 "no double anchors"
257 );
258 assert!(!text.contains("line 1001"), "line 1001 excluded");
259 }
260
261 #[tokio::test]
262 async fn read_file_not_found_is_soft_error() {
263 let (_dir, registry, root) = setup();
264 let out = registry
265 .dispatch(
266 "read_file",
267 json!({ "target_file": "nope.txt" }),
268 &ctx(&root),
269 )
270 .await;
271 assert!(!out.record.ok);
272 assert!(is_error(&out.tool_result));
273 }
274
275 #[tokio::test]
276 async fn read_file_outside_jail_is_soft_error() {
277 let (_dir, registry, root) = setup();
278 let out = registry
279 .dispatch(
280 "read_file",
281 json!({ "target_file": "/etc/passwd" }),
282 &ctx(&root),
283 )
284 .await;
285 assert!(!out.record.ok);
286 assert!(is_error(&out.tool_result));
287 }
288
289 async fn edit(
292 registry: &Registry,
293 root: &Path,
294 args: serde_json::Value,
295 ) -> locode_tools::Dispatched {
296 registry.dispatch("search_replace", args, &ctx(root)).await
297 }
298
299 #[tokio::test]
300 async fn search_replace_creates_file_on_empty_old_string() {
301 let (_dir, registry, root) = setup();
302 let out = edit(
303 ®istry,
304 &root,
305 json!({ "file_path": "new.txt", "old_string": "", "new_string": "hello world" }),
306 )
307 .await;
308 assert!(out.record.ok);
309 assert_eq!(out.record.output["created"], json!(true));
310 assert_eq!(
311 std::fs::read_to_string(root.join("new.txt")).unwrap(),
312 "hello world"
313 );
314 }
315
316 #[tokio::test]
317 async fn search_replace_edits_unique_match() {
318 let (_dir, registry, root) = setup();
319 std::fs::write(root.join("f.txt"), "alpha beta gamma").unwrap();
320 let out = edit(
321 ®istry,
322 &root,
323 json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
324 )
325 .await;
326 assert!(out.record.ok);
327 assert_eq!(out.record.output["replacements"], json!(1));
328 assert_eq!(
329 std::fs::read_to_string(root.join("f.txt")).unwrap(),
330 "alpha BETA gamma"
331 );
332 }
333
334 #[tokio::test]
335 async fn search_replace_no_op_is_soft_error() {
336 let (_dir, registry, root) = setup();
337 std::fs::write(root.join("f.txt"), "x").unwrap();
338 let out = edit(
339 ®istry,
340 &root,
341 json!({ "file_path": "f.txt", "old_string": "x", "new_string": "x" }),
342 )
343 .await;
344 assert!(!out.record.ok);
345 assert!(result_text(&out.tool_result).contains("same"));
346 }
347
348 #[tokio::test]
349 async fn search_replace_not_found_is_soft_error() {
350 let (_dir, registry, root) = setup();
351 std::fs::write(root.join("f.txt"), "abc").unwrap();
352 let out = edit(
353 ®istry,
354 &root,
355 json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
356 )
357 .await;
358 assert!(!out.record.ok);
359 assert!(result_text(&out.tool_result).contains("not found"));
360 }
361
362 #[tokio::test]
363 async fn search_replace_multiple_matches_without_replace_all_is_soft_error() {
364 let (_dir, registry, root) = setup();
365 std::fs::write(root.join("f.txt"), "a a a").unwrap();
366 let out = edit(
367 ®istry,
368 &root,
369 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
370 )
371 .await;
372 assert!(!out.record.ok);
373 assert!(result_text(&out.tool_result).contains("multiple times"));
374 }
375
376 #[tokio::test]
377 async fn search_replace_replace_all() {
378 let (_dir, registry, root) = setup();
379 std::fs::write(root.join("f.txt"), "a a a").unwrap();
380 let out = edit(
381 ®istry,
382 &root,
383 json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
384 )
385 .await;
386 assert!(out.record.ok);
387 assert_eq!(out.record.output["replacements"], json!(3));
388 assert_eq!(
389 std::fs::read_to_string(root.join("f.txt")).unwrap(),
390 "b b b"
391 );
392 }
393
394 #[tokio::test]
395 async fn search_replace_empty_old_string_overwrites_existing() {
396 let (_dir, registry, root) = setup();
399 std::fs::write(root.join("f.txt"), "previous content").unwrap();
400 let out = edit(
401 ®istry,
402 &root,
403 json!({ "file_path": "f.txt", "old_string": "", "new_string": "fresh" }),
404 )
405 .await;
406 assert!(out.record.ok);
407 assert_eq!(
408 result_text(&out.tool_result),
409 "The file f.txt has been created successfully."
410 );
411 assert_eq!(
412 std::fs::read_to_string(root.join("f.txt")).unwrap(),
413 "fresh"
414 );
415 }
416
417 #[tokio::test]
418 async fn search_replace_success_texts_match_grok_current() {
419 let (_dir, registry, root) = setup();
420 std::fs::write(root.join("f.txt"), "one two one").unwrap();
421 let out = edit(
422 ®istry,
423 &root,
424 json!({ "file_path": "f.txt", "old_string": "two", "new_string": "TWO" }),
425 )
426 .await;
427 assert_eq!(
428 result_text(&out.tool_result),
429 "The file f.txt has been updated successfully."
430 );
431 let out = edit(
432 ®istry,
433 &root,
434 json!({ "file_path": "f.txt", "old_string": "one", "new_string": "ONE", "replace_all": true }),
435 )
436 .await;
437 assert_eq!(
438 result_text(&out.tool_result),
439 "The file f.txt has been updated. All occurrences were successfully replaced."
440 );
441 }
442
443 #[tokio::test]
444 async fn search_replace_no_match_carries_current_era_hints() {
445 let (_dir, registry, root) = setup();
446 std::fs::write(root.join("f.txt"), "alpha\nthe quick fox\n").unwrap();
447 let out = edit(
448 ®istry,
449 &root,
450 json!({ "file_path": "f.txt", "old_string": "the quick cat", "new_string": "x" }),
451 )
452 .await;
453 assert!(!out.record.ok);
454 let text = result_text(&out.tool_result);
455 assert!(
456 text.starts_with(
457 "The string to replace was not found in the file, use the read_file tool to see the correct string. The user may have changed the file since you last read it."
458 ),
459 "{text}"
460 );
461 assert!(
462 text.contains("\n\nNearest match: line 2: the quick fox"),
463 "{text}"
464 );
465 }
466
467 #[tokio::test]
468 async fn search_replace_crlf_roundtrip() {
469 let (_dir, registry, root) = setup();
471 std::fs::write(root.join("f.txt"), "line one\r\nline two\r\n").unwrap();
472 let out = edit(
473 ®istry,
474 &root,
475 json!({ "file_path": "f.txt", "old_string": "one\nline two", "new_string": "1\nline 2" }),
476 )
477 .await;
478 assert!(out.record.ok, "{:?}", result_text(&out.tool_result));
479 assert_eq!(
480 std::fs::read_to_string(root.join("f.txt")).unwrap(),
481 "line 1\r\nline 2\r\n"
482 );
483 }
484
485 #[tokio::test]
486 async fn search_replace_gitignore_guard_blocks_edit() {
487 let (_dir, registry, root) = setup();
488 std::fs::create_dir(root.join(".git")).unwrap();
489 std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
490 std::fs::write(root.join("app.log"), "data").unwrap();
491 let out = edit(
492 ®istry,
493 &root,
494 json!({ "file_path": "app.log", "old_string": "data", "new_string": "x" }),
495 )
496 .await;
497 assert!(!out.record.ok);
498 assert_eq!(
499 result_text(&out.tool_result),
500 "Error: app.log is ignored by .gitignore and cannot be edited."
501 );
502 }
503
504 #[tokio::test]
505 async fn search_replace_not_found_uses_current_text() {
506 let (_dir, registry, root) = setup();
507 let out = edit(
508 ®istry,
509 &root,
510 json!({ "file_path": "missing.txt", "old_string": "a", "new_string": "b" }),
511 )
512 .await;
513 assert!(!out.record.ok);
514 assert_eq!(
515 result_text(&out.tool_result),
516 "Error: missing.txt does not exist."
517 );
518 }
519
520 fn rg_present() -> bool {
523 std::process::Command::new("rg")
524 .arg("--version")
525 .output()
526 .is_ok()
527 }
528
529 #[tokio::test]
530 async fn grep_finds_matches() {
531 if !rg_present() {
532 return;
533 }
534 let (_dir, registry, root) = setup();
535 std::fs::write(root.join("a.txt"), "needle here\nother line\n").unwrap();
536 std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
537 let out = registry
538 .dispatch("grep", json!({ "pattern": "needle" }), &ctx(&root))
539 .await;
540 assert!(out.record.ok);
541 assert_eq!(out.record.output["matched"], json!(true));
542 let text = result_text(&out.tool_result);
543 assert!(text.contains("needle"), "{text}");
544 assert!(text.contains("a.txt"), "{text}");
545 }
546
547 #[tokio::test]
548 async fn grep_no_match_is_soft_ok() {
549 if !rg_present() {
550 return;
551 }
552 let (_dir, registry, root) = setup();
553 std::fs::write(root.join("a.txt"), "hello\n").unwrap();
554 let out = registry
555 .dispatch("grep", json!({ "pattern": "zzzznotfound" }), &ctx(&root))
556 .await;
557 assert!(out.record.ok);
558 assert_eq!(out.record.output["matched"], json!(false));
559 assert!(result_text(&out.tool_result).contains("No matches"));
560 }
561
562 #[tokio::test]
565 async fn list_dir_walks_the_tree() {
566 let (_dir, registry, root) = setup();
567 std::fs::create_dir(root.join("src")).unwrap();
568 std::fs::write(root.join("src/main.rs"), "").unwrap();
569 std::fs::write(root.join("Cargo.toml"), "").unwrap();
570 let out = registry
571 .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
572 .await;
573 assert!(out.record.ok);
574 let text = result_text(&out.tool_result);
577 assert_eq!(
578 text,
579 format!(
580 "- {}/\n - Cargo.toml\n - src/\n - main.rs",
581 root.display()
582 ),
583 "{text}"
584 );
585 }
586
587 #[tokio::test]
588 async fn list_dir_hides_dotfiles_and_respects_gitignore() {
589 let (_dir, registry, root) = setup();
590 std::fs::create_dir(root.join(".git")).unwrap();
591 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
592 std::fs::write(root.join(".hidden"), "").unwrap();
593 std::fs::create_dir(root.join("target")).unwrap();
594 std::fs::write(root.join("target/out.o"), "").unwrap();
595 std::fs::write(root.join("visible.rs"), "").unwrap();
596 let out = registry
597 .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
598 .await;
599 let text = result_text(&out.tool_result);
600 assert!(text.contains("- visible.rs"), "{text}");
601 assert!(!text.contains(".hidden"), "dot-files hidden: {text}");
602 assert!(!text.contains(".gitignore"), "dot-files hidden: {text}");
603 assert!(!text.contains("target"), "gitignored excluded: {text}");
604 }
605
606 #[tokio::test]
607 async fn list_dir_missing_is_soft_error_with_grok_text() {
608 let (_dir, registry, root) = setup();
609 let out = registry
610 .dispatch(
611 "list_dir",
612 json!({ "target_directory": "nope" }),
613 &ctx(&root),
614 )
615 .await;
616 assert!(!out.record.ok);
617 assert!(is_error(&out.tool_result));
618 assert_eq!(
619 result_text(&out.tool_result),
620 format!("Error: {}/nope does not exist.", root.display())
621 );
622 }
623
624 #[tokio::test]
625 async fn list_dir_file_target_is_soft_error_with_grok_text() {
626 let (_dir, registry, root) = setup();
627 std::fs::write(root.join("f.txt"), "x").unwrap();
628 let out = registry
629 .dispatch(
630 "list_dir",
631 json!({ "target_directory": "f.txt" }),
632 &ctx(&root),
633 )
634 .await;
635 assert!(!out.record.ok);
636 assert_eq!(
637 result_text(&out.tool_result),
638 format!(
639 "Error: {}/f.txt is a file, not a directory.",
640 root.display()
641 )
642 );
643 }
644
645 #[tokio::test]
646 async fn list_dir_summarizes_fat_subdir() {
647 let (_dir, registry, root) = setup();
648 std::fs::create_dir(root.join("fat")).unwrap();
649 for i in 0..900 {
652 std::fs::write(root.join("fat").join(format!("file-{i:04}.rs")), "").unwrap();
653 }
654 std::fs::write(root.join("small.txt"), "").unwrap();
655 let out = registry
656 .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
657 .await;
658 let text = result_text(&out.tool_result);
659 assert!(text.contains("- fat/"), "{text}");
660 assert!(
661 text.contains("[900 files in subtree: 900 *.rs]"),
662 "collapsed summary: {text}"
663 );
664 assert!(
665 !text.contains("file-0000.rs"),
666 "children not listed: {text}"
667 );
668 assert!(text.contains("- small.txt"), "{text}");
669 }
670}