use std::path::{Path, PathBuf};
use polyc_llm::ToolSpec;
use serde_json::{Value, json};
use super::{err, workspace};
const MAX_RESULTS: usize = 200;
const MAX_VISIT: usize = 20_000;
const MAX_LINE_CHARS: usize = 400;
#[must_use]
pub(super) fn glob_spec() -> ToolSpec {
ToolSpec::new(
"glob",
"List files in the workspace whose path matches a glob pattern (`*` \
within a segment, `**` across segments, `?` one char). Returns \
workspace-relative paths; a `truncated` flag is set when results are capped.",
json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Glob, e.g. `src/**/*.rs`." }
},
"required": ["pattern"],
"additionalProperties": false
}),
)
.titled("Find files by name")
.read_only()
.cacheable_approval()
}
#[must_use]
pub(super) fn grep_spec() -> ToolSpec {
ToolSpec::new(
"grep",
"Search workspace file contents with a regular expression and return \
matching lines as {file, line_number, line}; a `truncated` flag is set \
when results are capped.",
json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Rust regular expression." },
"path": { "type": "string", "description": "Optional workspace-relative subdir to scope the search." }
},
"required": ["pattern"],
"additionalProperties": false
}),
)
.titled("Search file contents")
.read_only()
.cacheable_approval()
}
pub(super) fn glob_to_regex(pattern: &str) -> String {
let mut re = String::from("^");
let bytes = pattern.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'*' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'*' {
if i + 2 < bytes.len() && bytes[i + 2] == b'/' {
re.push_str("(?:.*/)?");
i += 2;
} else {
re.push_str(".*");
i += 1;
}
} else {
re.push_str("[^/]*");
}
}
b'?' => re.push_str("[^/]"),
c => {
let ch = c as char;
if ".+()|[]{}^$\\".contains(ch) {
re.push('\\');
}
re.push(ch);
}
}
i += 1;
}
re.push('$');
re
}
fn walk(base: &Path, dir: &Path, out: &mut Vec<PathBuf>, visited: &mut usize) -> bool {
let Ok(read) = std::fs::read_dir(dir) else {
return false;
};
let mut entries: Vec<_> = read.flatten().map(|e| e.path()).collect();
entries.sort();
let mut capped = false;
for path in entries {
if *visited >= MAX_VISIT {
return true;
}
*visited += 1;
if path.file_name().is_some_and(|n| {
n == ".git"
|| n.to_str()
.is_some_and(|n| n.starts_with(super::workspace::WORKER_SUBDIR_PREFIX))
}) {
continue;
}
if path.is_dir() {
capped |= walk(base, &path, out, visited);
} else if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_path_buf());
}
}
capped
}
pub(super) async fn glob(root: &Path, args_json: &str) -> String {
let Ok(args) = serde_json::from_str::<Value>(args_json) else {
return err("arguments must be a JSON object");
};
let Some(pattern) = args.get("pattern").and_then(Value::as_str) else {
return err("`pattern` (string) is required");
};
let Ok(re) = regex::Regex::new(&glob_to_regex(pattern)) else {
return err("invalid glob pattern");
};
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut files = Vec::new();
let mut visited = 0;
let visit_capped = walk(&root, &root, &mut files, &mut visited);
let all: Vec<String> = files
.iter()
.filter_map(|p| {
let s = p.to_string_lossy().replace('\\', "/");
re.is_match(&s).then_some(s)
})
.collect();
let truncated = visit_capped || all.len() > MAX_RESULTS;
let matches: Vec<&String> = all.iter().take(MAX_RESULTS).collect();
json!({ "matches": matches, "truncated": truncated }).to_string()
})
.await
.unwrap_or_else(|_| err("glob task failed"))
}
pub(super) async fn grep(root: &Path, args_json: &str) -> String {
let Ok(args) = serde_json::from_str::<Value>(args_json) else {
return err("arguments must be a JSON object");
};
let Some(pattern) = args.get("pattern").and_then(Value::as_str) else {
return err("`pattern` (string) is required");
};
let re = match regex::Regex::new(pattern) {
Ok(re) => re,
Err(e) => return err(format!("invalid regex: {e}")),
};
let scope = match args.get("path").and_then(Value::as_str) {
Some(rel) => match workspace::resolve(root, rel) {
Ok(p) => p,
Err(e) => return err(e),
},
None => root.to_path_buf(),
};
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut files = Vec::new();
let mut visited = 0;
let visit_capped = walk(&root, &scope, &mut files, &mut visited);
let mut hits = Vec::new();
let mut result_capped = false;
'outer: for rel in files {
let abs = root.join(&rel);
let Ok(content) = std::fs::read_to_string(&abs) else {
continue; };
for (n, line) in content.lines().enumerate() {
if re.is_match(line) {
if hits.len() >= MAX_RESULTS {
result_capped = true;
break 'outer;
}
hits.push(json!({
"file": rel.to_string_lossy().replace('\\', "/"),
"line_number": n + 1,
"line": line.chars().take(MAX_LINE_CHARS).collect::<String>(),
}));
}
}
}
json!({ "matches": hits, "truncated": visit_capped || result_capped }).to_string()
})
.await
.unwrap_or_else(|_| err("grep task failed"))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn tmp_tree() -> PathBuf {
let p = super::super::tmp_dir("search-test");
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/main.rs"), "fn main() { let x = 1; }").unwrap();
std::fs::write(p.join("README.md"), "# hello\nworld").unwrap();
p
}
#[tokio::test]
async fn glob_matches_by_extension() {
let root = tmp_tree();
let out = glob(&root, r#"{"pattern":"src/**/*.rs"}"#).await;
let v: Value = serde_json::from_str(&out).unwrap();
let m = v["matches"].as_array().unwrap();
assert!(m.iter().any(|p| p == "src/main.rs"), "{out}");
assert!(!m.iter().any(|p| p == "README.md"));
assert_eq!(v["truncated"], false);
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn grep_finds_matching_lines() {
let root = tmp_tree();
let out = grep(&root, r#"{"pattern":"let \\w+"}"#).await;
let v: Value = serde_json::from_str(&out).unwrap();
let m = v["matches"].as_array().unwrap();
assert_eq!(m.len(), 1, "{out}");
assert_eq!(m[0]["file"], "src/main.rs");
std::fs::remove_dir_all(&root).ok();
}
}