microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use async_trait::async_trait;
use serde_json::json;
use std::path::{Path, PathBuf};
use tracing::info;

use crate::config::WorkingDirIsolation;
use microclaw_core::llm_types::ToolDefinition;

use super::{schema_object, Tool, ToolResult};

pub struct GrepTool {
    working_dir: PathBuf,
    working_dir_isolation: WorkingDirIsolation,
}

impl GrepTool {
    pub fn new(working_dir: &str) -> Self {
        Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
    }

    pub fn new_with_isolation(
        working_dir: &str,
        working_dir_isolation: WorkingDirIsolation,
    ) -> Self {
        Self {
            working_dir: PathBuf::from(working_dir),
            working_dir_isolation,
        }
    }
}

#[async_trait]
impl Tool for GrepTool {
    fn name(&self) -> &str {
        "grep"
    }

    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "grep".into(),
            description: "Search file contents using a regex pattern. Returns matching lines with file paths and line numbers.".into(),
            input_schema: schema_object(
                json!({
                    "pattern": {
                        "type": "string",
                        "description": "Regex pattern to search for"
                    },
                    "path": {
                        "type": "string",
                        "description": "File or directory to search in (default: current directory)"
                    },
                    "glob": {
                        "type": "string",
                        "description": "Glob pattern to filter files (e.g., '*.rs')"
                    }
                }),
                &["pattern"],
            ),
        }
    }

    async fn execute(&self, input: serde_json::Value) -> ToolResult {
        let pattern = match input.get("pattern").and_then(|v| v.as_str()) {
            Some(p) => p,
            None => return ToolResult::error("Missing 'pattern' parameter".into()),
        };
        let path = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
        let working_dir =
            super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input);
        let resolved_path = super::resolve_tool_path(&working_dir, path);
        let resolved_path_str = resolved_path.to_string_lossy().to_string();
        if let Err(msg) = crate::internal::tool_runtime::path_guard::check_path(&resolved_path_str)
        {
            return ToolResult::error(msg);
        }
        let file_glob = input.get("glob").and_then(|v| v.as_str());

        info!("Grep: {} in {}", pattern, resolved_path.display());

        let re = match regex::Regex::new(pattern) {
            Ok(r) => r,
            Err(e) => return ToolResult::error(format!("Invalid regex: {e}")),
        };

        let mut results = Vec::new();
        let mut file_count = 0;

        if let Err(e) = grep_recursive(
            &resolved_path,
            file_glob,
            &re,
            &mut results,
            &mut file_count,
        ) {
            return ToolResult::error(format!("Search error: {e}"));
        }

        if results.is_empty() {
            ToolResult::success("No matches found.".into())
        } else {
            if results.len() > 500 {
                results.truncate(500);
                results.push("... (results truncated)".into());
            }
            ToolResult::success(results.join("\n"))
        }
    }
}

fn grep_recursive(
    path: &Path,
    file_glob: Option<&str>,
    re: &regex::Regex,
    results: &mut Vec<String>,
    file_count: &mut usize,
) -> std::io::Result<()> {
    let metadata = std::fs::metadata(path)?;

    if metadata.is_file() {
        grep_file(path, re, results)?;
    } else if metadata.is_dir() {
        let glob_pattern = file_glob.and_then(|g| glob::Pattern::new(g).ok());

        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let entry_path = entry.path();
            let name = entry.file_name().to_string_lossy().to_string();

            // Skip hidden directories and common non-code dirs
            if name.starts_with('.') || name == "node_modules" || name == "target" {
                continue;
            }

            if entry_path.is_dir() {
                grep_recursive(&entry_path, file_glob, re, results, file_count)?;
            } else if entry_path.is_file() {
                if crate::internal::tool_runtime::path_guard::is_blocked(&entry_path) {
                    continue;
                }
                if let Some(ref pat) = glob_pattern {
                    if !pat.matches(&name) {
                        continue;
                    }
                }
                *file_count += 1;
                if *file_count > 10000 {
                    return Ok(());
                }
                grep_file(&entry_path, re, results)?;
            }
        }
    }
    Ok(())
}

fn grep_file(path: &Path, re: &regex::Regex, results: &mut Vec<String>) -> std::io::Result<()> {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return Ok(()), // Skip binary / unreadable files
    };

    for (line_num, line) in content.lines().enumerate() {
        if re.is_match(line) {
            results.push(format!("{}:{}: {}", path.display(), line_num + 1, line));
            if results.len() >= 500 {
                return Ok(());
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn setup_grep_dir() -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("microclaw_grep_{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(
            dir.join("hello.rs"),
            "fn main() {\n    println!(\"hello\");\n}\n",
        )
        .unwrap();
        std::fs::write(dir.join("world.txt"), "hello world\ngoodbye world\n").unwrap();
        dir
    }

    #[tokio::test]
    async fn test_grep_finds_matches() {
        let dir = setup_grep_dir();
        let tool = GrepTool::new(".");
        let result = tool
            .execute(json!({"pattern": "hello", "path": dir.to_str().unwrap()}))
            .await;
        assert!(!result.is_error);
        assert!(result.content.contains("hello"));
        // Should have file:line format
        assert!(result.content.contains(":"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_grep_no_matches() {
        let dir = setup_grep_dir();
        let tool = GrepTool::new(".");
        let result = tool
            .execute(json!({"pattern": "zzzzzzz", "path": dir.to_str().unwrap()}))
            .await;
        assert!(!result.is_error);
        assert!(result.content.contains("No matches"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_grep_with_file_glob() {
        let dir = setup_grep_dir();
        let tool = GrepTool::new(".");
        // Only search .txt files
        let result = tool
            .execute(json!({
                "pattern": "hello",
                "path": dir.to_str().unwrap(),
                "glob": "*.txt"
            }))
            .await;
        assert!(!result.is_error);
        assert!(result.content.contains("world.txt"));
        // Should NOT match the .rs file
        assert!(!result.content.contains("hello.rs"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_grep_invalid_regex() {
        let tool = GrepTool::new(".");
        let result = tool
            .execute(json!({"pattern": "[invalid", "path": "."}))
            .await;
        assert!(result.is_error);
        assert!(result.content.contains("Invalid regex"));
    }

    #[tokio::test]
    async fn test_grep_missing_pattern() {
        let tool = GrepTool::new(".");
        let result = tool.execute(json!({})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing 'pattern'"));
    }

    #[test]
    fn test_grep_file_function() {
        let dir = std::env::temp_dir().join(format!("microclaw_gf_{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("test.txt");
        std::fs::write(&file, "foo bar\nbaz qux\nfoo again\n").unwrap();

        let re = regex::Regex::new("foo").unwrap();
        let mut results = Vec::new();
        grep_file(&file, &re, &mut results).unwrap();
        assert_eq!(results.len(), 2);
        assert!(results[0].contains(":1:"));
        assert!(results[1].contains(":3:"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_grep_recursive_skips_hidden_dirs() {
        let dir = std::env::temp_dir().join(format!("microclaw_gr_{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(dir.join(".hidden")).unwrap();
        std::fs::write(dir.join(".hidden").join("secret.txt"), "match_me").unwrap();
        std::fs::write(dir.join("visible.txt"), "match_me").unwrap();

        let re = regex::Regex::new("match_me").unwrap();
        let mut results: Vec<String> = Vec::new();
        let mut count = 0;
        grep_recursive(&dir, None, &re, &mut results, &mut count).unwrap();

        // Should only find in visible.txt
        assert_eq!(results.len(), 1);
        assert!(results[0].contains("visible.txt"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_grep_defaults_to_working_dir() {
        let root = std::env::temp_dir().join(format!("microclaw_grep2_{}", uuid::Uuid::new_v4()));
        let work = root.join("workspace");
        let shared = work.join("shared");
        std::fs::create_dir_all(&shared).unwrap();
        std::fs::write(shared.join("a.txt"), "needle").unwrap();

        let tool = GrepTool::new(work.to_str().unwrap());
        let result = tool.execute(json!({"pattern":"needle"})).await;
        assert!(!result.is_error);
        assert!(result.content.contains("a.txt"));

        let _ = std::fs::remove_dir_all(&root);
    }
}