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