collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use crate::common::Result;
use serde::Deserialize;
use tokio::process::Command;
use tokio::time::{Duration, timeout};

#[derive(Debug, Deserialize)]
pub struct SearchInput {
    pub pattern: String,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub glob: Option<String>,
    #[serde(default = "default_max_results")]
    pub max_results: usize,
}

fn default_max_results() -> usize {
    50
}

pub fn definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "search",
            "description": "Search for a regex pattern in files using ripgrep. Returns matching lines with file paths and line numbers.",
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Regex pattern to search for"
                    },
                    "path": {
                        "type": "string",
                        "description": "Directory or file to search in (default: working directory)"
                    },
                    "glob": {
                        "type": "string",
                        "description": "Glob pattern to filter files (e.g., '*.rs', '*.py')"
                    },
                    "max_results": {
                        "type": "integer",
                        "description": "Maximum number of results (default: 50)"
                    }
                },
                "required": ["pattern"]
            }
        }
    })
}

pub async fn execute(input: SearchInput, working_dir: &str) -> Result<String> {
    let search_path = input.path.unwrap_or_else(|| working_dir.to_string());

    // Try ripgrep first, fall back to grep
    let mut cmd = Command::new("rg");
    cmd.arg("--line-number")
        .arg("--no-heading")
        .arg("--color=never")
        .arg("--max-count")
        .arg(input.max_results.to_string());

    if let Some(ref glob) = input.glob {
        cmd.arg("--glob").arg(glob);
    }

    cmd.arg(&input.pattern).arg(&search_path);

    // Set working directory for rg process
    cmd.current_dir(working_dir);

    let result = timeout(Duration::from_secs(30), cmd.output()).await;

    match result {
        Ok(Ok(output)) => {
            let stdout = String::from_utf8_lossy(&output.stdout);
            if stdout.is_empty() {
                Ok("No matches found.".to_string())
            } else {
                Ok(stdout.to_string())
            }
        }
        Ok(Err(_)) => {
            // ripgrep not available, fall back to grep
            let mut cmd = Command::new("grep");
            cmd.arg("-rn").arg("--color=never");

            if let Some(ref glob) = input.glob {
                cmd.arg("--include").arg(glob);
            }

            cmd.arg(&input.pattern).arg(&search_path);

            // Set working directory for grep process
            cmd.current_dir(working_dir);

            let output = cmd.output().await?;
            let stdout = String::from_utf8_lossy(&output.stdout);
            if stdout.is_empty() {
                Ok("No matches found.".to_string())
            } else {
                // Truncate to max_results lines
                let lines: Vec<&str> = stdout.lines().take(input.max_results).collect();
                Ok(lines.join("\n"))
            }
        }
        Err(_) => Err(crate::common::AgentError::Timeout(30)),
    }
}