dot-ai 0.6.1

A minimal AI agent that lives in your terminal
Documentation
use anyhow::{Context, Result};
use regex::Regex;
use serde_json::Value;
use std::fs;
use std::path::Path;

use super::Tool;

const MAX_RESULTS: usize = 100;

pub struct GrepTool;

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

    fn description(&self) -> &str {
        "Search file contents using regex patterns. Returns matching lines with file paths and line numbers. More precise than search_files."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "pattern": {
                    "type": "string",
                    "description": "Regex pattern to search for"
                },
                "path": {
                    "type": "string",
                    "description": "Directory to search in"
                },
                "include": {
                    "type": "string",
                    "description": "File glob filter (e.g. '*.rs', '*.{ts,tsx}')"
                }
            },
            "required": ["pattern", "path"]
        })
    }

    fn execute(&self, input: Value) -> Result<String> {
        let pattern = input["pattern"]
            .as_str()
            .context("Missing required parameter 'pattern'")?;
        let path = input["path"]
            .as_str()
            .context("Missing required parameter 'path'")?;
        let include = input["include"].as_str().unwrap_or("");
        tracing::debug!("grep: '{}' in {}", pattern, path);

        let re = Regex::new(pattern).with_context(|| format!("invalid regex: {}", pattern))?;

        let mut results = Vec::new();
        grep_recursive(Path::new(path), &re, include, &mut results);

        if results.is_empty() {
            Ok(format!("No matches for '{}' in '{}'", pattern, path))
        } else {
            let truncated = results.len() >= MAX_RESULTS;
            let mut output = results.join("\n");
            if truncated {
                output.push_str(&format!("\n... (truncated at {} matches)", MAX_RESULTS));
            }
            Ok(output)
        }
    }
}

fn grep_recursive(dir: &Path, re: &Regex, include: &str, results: &mut Vec<String>) {
    if results.len() >= MAX_RESULTS {
        return;
    }

    let entries = match fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return,
    };

    for entry in entries {
        if results.len() >= MAX_RESULTS {
            return;
        }

        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };

        let path = entry.path();
        let metadata = match entry.metadata() {
            Ok(m) => m,
            Err(_) => continue,
        };

        if metadata.is_dir() {
            let name = path.file_name().unwrap_or_default().to_string_lossy();
            if name.starts_with('.')
                || name == "target"
                || name == "node_modules"
                || name == "__pycache__"
                || name == ".git"
            {
                continue;
            }
            grep_recursive(&path, re, include, results);
        } else if metadata.is_file() {
            let name = path
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string();
            if !include.is_empty() && !matches_include(&name, include) {
                continue;
            }

            let content = match fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };

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

fn matches_include(filename: &str, include: &str) -> bool {
    if let Some(ext_pat) = include.strip_prefix("*.") {
        if ext_pat.starts_with('{') && ext_pat.ends_with('}') {
            let inner = &ext_pat[1..ext_pat.len() - 1];
            return inner
                .split(',')
                .any(|ext| filename.ends_with(&format!(".{}", ext.trim())));
        }
        return filename.ends_with(&format!(".{}", ext_pat));
    }
    filename == include
}