polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! Read-only workspace search tools: `glob` (filenames) and `grep` (contents).
//! Both walk the workspace tree under [`workspace::root`](super::workspace),
//! scoped through [`workspace::resolve`](super::workspace::resolve). The walk +
//! per-file reads are blocking, so they run on a
//! [`spawn_blocking`](tokio::task::spawn_blocking) thread. Results are capped and
//! a `truncated` flag tells the model when the listing is incomplete.

use std::path::{Path, PathBuf};

use polyc_llm::ToolSpec;
use serde_json::{Value, json};

use super::{err, workspace};

/// Cap on results returned, so a huge tree can't run away.
const MAX_RESULTS: usize = 200;
/// Cap on total files visited during a walk.
const MAX_VISIT: usize = 20_000;
/// Per-matching-line preview length (chars).
const MAX_LINE_CHARS: usize = 400;

/// `glob` spec — list workspace files matching a glob pattern.
#[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()
}

/// `grep` spec — regex search over workspace file contents.
#[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()
}

/// Translate a glob pattern into an anchored regex over a `/`-joined path.
///
/// Shared with [`super::share_in`], so a share-in ceiling's patterns mean
/// exactly what the same pattern means to the `glob` tool.
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'*' {
                    // `**/` matches zero or more directory segments (so
                    // `src/**/*.rs` also matches `src/main.rs`); a bare `**`
                    // matches anything including slashes.
                    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
}

/// Collect files under `dir` (relative paths from `base`), skipping `.git` and
/// delegated workers' re-rooted subtrees, bounded by [`MAX_VISIT`]. Returns
/// whether the visit cap was hit. Entries are sorted within each directory so
/// results are deterministic across runs.
///
/// Worker subtrees (`#2286`) are skipped because they are created eagerly for
/// every delegate call and never cleaned up, so a parent searching its own
/// workspace would otherwise walk each worker's scratch space — returning a
/// sibling's files as matches, and spending its [`MAX_VISIT`] budget there
/// before reaching real project files. A worker searching its OWN root is
/// unaffected: its root is the subtree, so nothing under it carries the
/// prefix.
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
}

/// Execute `glob` against `root`.
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"))
}

/// Execute `grep` against `root`.
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; // skip binary / unreadable files
            };
            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();
    }
}