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