mod grep;
mod list_dir;
pub mod prompt;
mod read;
mod search_replace;
mod terminal;
use std::sync::Arc;
use locode_host::Host;
use locode_protocol::{ContentBlock, Message, Role};
use locode_tools::Registry;
use crate::pack::{Pack, PackContext};
use grep::GrokGrep;
use list_dir::GrokListDir;
use read::GrokReadFile;
use search_replace::GrokSearchReplace;
use terminal::GrokRunTerminalCmd;
#[derive(Debug, Default, Clone, Copy)]
pub struct GrokPack;
impl Pack for GrokPack {
fn name(&self) -> &'static str {
"grok"
}
fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
registry.register(
"run_terminal_cmd",
GrokRunTerminalCmd::new(Arc::clone(host)),
);
registry.register("read_file", GrokReadFile::new(Arc::clone(host)));
registry.register("search_replace", GrokSearchReplace::new(Arc::clone(host)));
registry.register("grep", GrokGrep::new(Arc::clone(host)));
registry.register("list_dir", GrokListDir::new(Arc::clone(host)));
}
fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
vec![
Message {
role: Role::System,
content: vec![ContentBlock::Text {
text: prompt::render_base_prompt(ctx),
}],
},
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: prompt::user_info_block(ctx),
}],
},
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use locode_host::HostConfig;
use locode_protocol::ResultChunk;
use locode_tools::ToolCtx;
use serde_json::json;
use std::path::Path;
use tokio_util::sync::CancellationToken;
fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let mut config = HostConfig::new(dir.path());
config.login_shell = false;
let host = Arc::new(Host::new(config).unwrap());
let root = host.workspace_root().to_path_buf();
let registry = GrokPack.build_registry(&host);
(dir, registry, root)
}
fn ctx(dir: &Path) -> ToolCtx {
ToolCtx::new(
dir.to_path_buf(),
"c1".into(),
dir.to_path_buf(),
CancellationToken::new(),
)
}
fn result_text(block: &ContentBlock) -> String {
match block {
ContentBlock::ToolResult { content, .. } => content
.iter()
.filter_map(|chunk| match chunk {
ResultChunk::Text { text } => Some(text.clone()),
ResultChunk::Image { .. } => None,
})
.collect(),
_ => panic!("expected a tool_result"),
}
}
fn is_error(block: &ContentBlock) -> bool {
matches!(block, ContentBlock::ToolResult { is_error: true, .. })
}
#[tokio::test]
async fn run_terminal_cmd_echo() {
let (_dir, registry, root) = setup();
let out = registry
.dispatch(
"run_terminal_cmd",
json!({ "command": "echo hi", "description": "say hi" }),
&ctx(&root),
)
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["exit_code"], json!(0));
let text = result_text(&out.tool_result);
assert!(text.contains("exit: 0"), "{text}");
assert!(text.contains("hi"), "{text}");
}
#[tokio::test]
async fn run_terminal_cmd_nonzero_exit_is_soft() {
let (_dir, registry, root) = setup();
let out = registry
.dispatch(
"run_terminal_cmd",
json!({ "command": "exit 3", "description": "fail" }),
&ctx(&root),
)
.await;
assert!(out.record.ok);
assert!(!is_error(&out.tool_result));
assert_eq!(out.record.output["exit_code"], json!(3));
}
#[tokio::test]
async fn read_file_numbers_lines() {
let (_dir, registry, root) = setup();
std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
let out = registry
.dispatch("read_file", json!({ "target_file": "f.txt" }), &ctx(&root))
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["lines"], json!(3));
assert_eq!(out.record.output["truncated"], json!(false));
let text = result_text(&out.tool_result);
assert!(text.contains("1→alpha"), "{text}");
assert!(text.contains("3→gamma"), "{text}");
}
#[tokio::test]
async fn read_file_line_cap_truncates() {
use std::fmt::Write as _;
let (_dir, registry, root) = setup();
let mut big = String::new();
for n in 1..=1500 {
writeln!(big, "line {n}").unwrap();
}
std::fs::write(root.join("big.txt"), big).unwrap();
let out = registry
.dispatch(
"read_file",
json!({ "target_file": "big.txt" }),
&ctx(&root),
)
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["lines"], json!(1500));
assert_eq!(out.record.output["truncated"], json!(true));
let text = result_text(&out.tool_result);
assert!(text.contains("1000→line 1000"), "capped at 1000");
assert!(!text.contains("1001→"), "line 1001 excluded");
}
#[tokio::test]
async fn read_file_not_found_is_soft_error() {
let (_dir, registry, root) = setup();
let out = registry
.dispatch(
"read_file",
json!({ "target_file": "nope.txt" }),
&ctx(&root),
)
.await;
assert!(!out.record.ok);
assert!(is_error(&out.tool_result));
}
#[tokio::test]
async fn read_file_outside_jail_is_soft_error() {
let (_dir, registry, root) = setup();
let out = registry
.dispatch(
"read_file",
json!({ "target_file": "/etc/passwd" }),
&ctx(&root),
)
.await;
assert!(!out.record.ok);
assert!(is_error(&out.tool_result));
}
async fn edit(
registry: &Registry,
root: &Path,
args: serde_json::Value,
) -> locode_tools::Dispatched {
registry.dispatch("search_replace", args, &ctx(root)).await
}
#[tokio::test]
async fn search_replace_creates_file_on_empty_old_string() {
let (_dir, registry, root) = setup();
let out = edit(
®istry,
&root,
json!({ "file_path": "new.txt", "old_string": "", "new_string": "hello world" }),
)
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["created"], json!(true));
assert_eq!(
std::fs::read_to_string(root.join("new.txt")).unwrap(),
"hello world"
);
}
#[tokio::test]
async fn search_replace_edits_unique_match() {
let (_dir, registry, root) = setup();
std::fs::write(root.join("f.txt"), "alpha beta gamma").unwrap();
let out = edit(
®istry,
&root,
json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
)
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["replacements"], json!(1));
assert_eq!(
std::fs::read_to_string(root.join("f.txt")).unwrap(),
"alpha BETA gamma"
);
}
#[tokio::test]
async fn search_replace_no_op_is_soft_error() {
let (_dir, registry, root) = setup();
std::fs::write(root.join("f.txt"), "x").unwrap();
let out = edit(
®istry,
&root,
json!({ "file_path": "f.txt", "old_string": "x", "new_string": "x" }),
)
.await;
assert!(!out.record.ok);
assert!(result_text(&out.tool_result).contains("same"));
}
#[tokio::test]
async fn search_replace_not_found_is_soft_error() {
let (_dir, registry, root) = setup();
std::fs::write(root.join("f.txt"), "abc").unwrap();
let out = edit(
®istry,
&root,
json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
)
.await;
assert!(!out.record.ok);
assert!(result_text(&out.tool_result).contains("not found"));
}
#[tokio::test]
async fn search_replace_multiple_matches_without_replace_all_is_soft_error() {
let (_dir, registry, root) = setup();
std::fs::write(root.join("f.txt"), "a a a").unwrap();
let out = edit(
®istry,
&root,
json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
)
.await;
assert!(!out.record.ok);
assert!(result_text(&out.tool_result).contains("multiple times"));
}
#[tokio::test]
async fn search_replace_replace_all() {
let (_dir, registry, root) = setup();
std::fs::write(root.join("f.txt"), "a a a").unwrap();
let out = edit(
®istry,
&root,
json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
)
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["replacements"], json!(3));
assert_eq!(
std::fs::read_to_string(root.join("f.txt")).unwrap(),
"b b b"
);
}
fn rg_present() -> bool {
std::process::Command::new("rg")
.arg("--version")
.output()
.is_ok()
}
#[tokio::test]
async fn grep_finds_matches() {
if !rg_present() {
return;
}
let (_dir, registry, root) = setup();
std::fs::write(root.join("a.txt"), "needle here\nother line\n").unwrap();
std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
let out = registry
.dispatch("grep", json!({ "pattern": "needle" }), &ctx(&root))
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["matched"], json!(true));
let text = result_text(&out.tool_result);
assert!(text.contains("needle"), "{text}");
assert!(text.contains("a.txt"), "{text}");
}
#[tokio::test]
async fn grep_no_match_is_soft_ok() {
if !rg_present() {
return;
}
let (_dir, registry, root) = setup();
std::fs::write(root.join("a.txt"), "hello\n").unwrap();
let out = registry
.dispatch("grep", json!({ "pattern": "zzzznotfound" }), &ctx(&root))
.await;
assert!(out.record.ok);
assert_eq!(out.record.output["matched"], json!(false));
assert!(result_text(&out.tool_result).contains("No matches"));
}
#[tokio::test]
async fn list_dir_walks_the_tree() {
let (_dir, registry, root) = setup();
std::fs::create_dir(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "").unwrap();
std::fs::write(root.join("Cargo.toml"), "").unwrap();
let out = registry
.dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
.await;
assert!(out.record.ok);
let text = result_text(&out.tool_result);
assert!(text.contains("src/"), "{text}");
assert!(text.contains("main.rs"), "{text}");
assert!(text.contains("Cargo.toml"), "{text}");
}
#[tokio::test]
async fn list_dir_missing_is_soft_error() {
let (_dir, registry, root) = setup();
let out = registry
.dispatch(
"list_dir",
json!({ "target_directory": "nope" }),
&ctx(&root),
)
.await;
assert!(!out.record.ok);
assert!(is_error(&out.tool_result));
}
}