cats 0.1.27

Coding Agent ToolS - A comprehensive toolkit for building AI-powered coding agents
Documentation
//! Glob tool implementation compatible with OpenCode
//!
//! Fast file pattern matching tool using glob patterns.

use crate::core::{Tool, ToolArgs, ToolError, ToolResult};
use anyhow::Result;
use ignore::{overrides::OverrideBuilder, WalkBuilder};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

const LIMIT: usize = 100;

/// Glob tool parameters
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GlobParams {
    /// The glob pattern to match files against (gitignore/wildmatch syntax, e.g. **/*.rs, **/nghr/**)
    pub pattern: String,
    /// The directory to search in. Defaults to current working directory when omitted. If starts with "/" it is absolute, if not it is relative.
    pub path: Option<String>,
}

/// Glob tool for finding files by pattern
pub struct GlobTool {
    name: String,
}

impl GlobTool {
    pub fn new() -> Self {
        Self {
            name: "glob".to_string(),
        }
    }
}

impl Default for GlobTool {
    fn default() -> Self {
        Self::new()
    }
}

impl Tool for GlobTool {
    fn name(&self) -> &str {
        &self.name
    }

    fn description(&self) -> &str {
        "Fast file pattern matching tool that works with any codebase size. Uses gitignore/wildmatch syntax (e.g. **/*.rs, **/src/**, *.toml). 'path' is optional — defaults to current working directory. Supports recursive wildcards like **/dir/**."
    }

    fn signature(&self) -> &str {
        "glob --path <directory> --pattern <pattern>"
    }

    fn validate_args(&self, _args: &ToolArgs) -> Result<(), ToolError> {
        Ok(())
    }

    fn execute(
        &mut self,
        args: &ToolArgs,
        state: &Arc<Mutex<crate::state::ToolState>>,
    ) -> Result<ToolResult> {
        let params = parse_glob_args(args)?;

        // Get working directory from ToolState at execution time
        let working_dir = state
            .lock()
            .map(|s| s.working_directory.clone())
            .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());

        let search_path = match &params.path {
            Some(p) => {
                let pb = PathBuf::from(p);
                if pb.is_absolute() { pb } else { working_dir.join(p) }
            }
            None => working_dir.clone(),
        };

        let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
        let mut truncated = false;

        // Build gitignore/wildmatch override — positive pattern acts as whitelist
        let mut ob = OverrideBuilder::new(&search_path);
        ob.add(&params.pattern)
            .map_err(|e| anyhow::anyhow!("Invalid glob pattern: {}", e))?;
        let overrides = ob.build()
            .map_err(|e| anyhow::anyhow!("Failed to build glob override: {}", e))?;

        let walker = WalkBuilder::new(&search_path)
            .hidden(false)      // include hidden files
            .git_ignore(false)  // don't respect .gitignore
            .git_global(false)
            .git_exclude(false)
            .ignore(false)      // don't use .ignore files
            .overrides(overrides)
            .build();

        for result in walker {
            if files.len() >= LIMIT {
                truncated = true;
                break;
            }
            if let Ok(entry) = result {
                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
                    let path = entry.path().to_path_buf();
                    let mtime = fs::metadata(&path)
                        .ok()
                        .and_then(|m| m.modified().ok())
                        .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
                    files.push((path, mtime));
                }
            }
        }

        // Sort by modification time (most recent first)
        files.sort_by(|a, b| b.1.cmp(&a.1));

        let file_count = files.len();
        let mut output_lines: Vec<String> = Vec::new();

        if files.is_empty() {
            output_lines.push("No files found".to_string());
        } else {
            for (path, _) in &files {
                output_lines.push(path.display().to_string());
            }

            if truncated {
                output_lines.push("".to_string());
                output_lines.push(
                    "(Results are truncated. Consider using a more specific path or pattern.)"
                        .to_string(),
                );
            }
        }

        Ok(ToolResult::success_with_data(
            output_lines.join("\n"),
            serde_json::json!({
                "count": file_count,
                "truncated": truncated,
                "pattern": params.pattern,
                "path": search_path.display().to_string(),
            }),
        ))
    }

    fn get_parameters_schema(&self) -> serde_json::Value {
        let schema = schemars::schema_for!(GlobParams);
        serde_json::to_value(schema).unwrap_or_default()
    }
}

fn parse_glob_args(args: &ToolArgs) -> Result<GlobParams> {
    let pattern = args
        .get_named_arg("pattern")
        .cloned()
        .filter(|s| !s.is_empty())
        .or_else(|| args.args.first().cloned())
        .filter(|s| !s.is_empty())
        .ok_or_else(|| anyhow::anyhow!("pattern is required and must be non-empty"))?;

    if pattern.is_empty() {
        return Err(anyhow::anyhow!("pattern must not be empty or whitespace-only"));
    }

    let path = args
        .get_named_arg("path")
        .cloned()
        .filter(|s| !s.is_empty());

    Ok(GlobParams { pattern, path })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_glob_tool_creation() {
        let tool = GlobTool::new();
        assert_eq!(tool.name(), "glob");
    }

    #[test]
    fn test_glob_tool_find_files() {
        let temp_dir = TempDir::new().unwrap();

        // Create some test files
        fs::write(temp_dir.path().join("test1.txt"), "").unwrap();
        fs::write(temp_dir.path().join("test2.txt"), "").unwrap();
        fs::write(temp_dir.path().join("test.rs"), "").unwrap();

        let mut tool = GlobTool::new();
        let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
        let args = ToolArgs::with_named_args(
            vec!["*.txt".to_string()],
            vec![(
                "path".to_string(),
                temp_dir.path().to_str().unwrap().to_string(),
            )]
            .into_iter()
            .collect(),
        );

        let result = tool.execute(&args, &state).unwrap();
        assert!(result.success);
        assert!(result.message.contains("test1.txt"));
        assert!(result.message.contains("test2.txt"));
        assert!(!result.message.contains("test.rs"));
    }

    #[test]
    fn test_glob_tool_no_matches() {
        let temp_dir = TempDir::new().unwrap();

        let mut tool = GlobTool::new();
        let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
        let args = ToolArgs::with_named_args(
            vec!["*.nonexistent".to_string()],
            vec![(
                "path".to_string(),
                temp_dir.path().to_str().unwrap().to_string(),
            )]
            .into_iter()
            .collect(),
        );

        let result = tool.execute(&args, &state).unwrap();
        assert!(result.success);
        assert!(result.message.contains("No files found"));
    }

    #[test]
    fn test_glob_tool_nested_pattern() {
        let temp_dir = TempDir::new().unwrap();

        // Create nested structure
        fs::create_dir_all(temp_dir.path().join("src/nested")).unwrap();
        fs::write(temp_dir.path().join("src/main.rs"), "").unwrap();
        fs::write(temp_dir.path().join("src/nested/mod.rs"), "").unwrap();

        let mut tool = GlobTool::new();
        let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
        let args = ToolArgs::with_named_args(
            vec!["**/*.rs".to_string()],
            vec![(
                "path".to_string(),
                temp_dir.path().to_str().unwrap().to_string(),
            )]
            .into_iter()
            .collect(),
        );

        let result = tool.execute(&args, &state).unwrap();
        assert!(result.success);
        assert!(result.message.contains("main.rs"));
        assert!(result.message.contains("mod.rs"));
    }

    #[test]
    fn test_glob_wildmatch_dir_pattern() {
        let temp_dir = TempDir::new().unwrap();

        // Create nested structure with a named subdirectory
        fs::create_dir_all(temp_dir.path().join("nghr/src")).unwrap();
        fs::write(temp_dir.path().join("nghr/src/main.rs"), "").unwrap();
        fs::write(temp_dir.path().join("other.rs"), "").unwrap();

        let mut tool = GlobTool::new();
        let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
        let args = ToolArgs::with_named_args(
            vec!["**/nghr/**".to_string()],
            vec![("path".to_string(), temp_dir.path().to_str().unwrap().to_string())]
                .into_iter()
                .collect(),
        );

        let result = tool.execute(&args, &state).unwrap();
        assert!(result.success);
        assert!(result.message.contains("main.rs"));
        assert!(!result.message.contains("other.rs"));
    }

    #[test]
    fn test_glob_tool_validation() {
        // Glob tool has optional validation (args parsed in execute)
        // Verify it doesn't crash with empty args
        let tool = GlobTool::new();
        let args = ToolArgs::from_args(&[]);
        let _ = tool.validate_args(&args);
    }
}