collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Search endpoint using ripgrep subprocess.

use std::sync::Arc;

use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::get;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::time::{Duration, timeout};

use super::state::AppState;

pub fn router() -> Router<Arc<AppState>> {
    Router::new().route("/api/search", get(search))
}

// ── Query params ─────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct SearchQuery {
    q: String,
    #[serde(default)]
    glob: Option<String>,
    #[serde(default = "default_max")]
    max: usize,
}

fn default_max() -> usize {
    50
}

// ── Response ─────────────────────────────────────────────────────────────

#[derive(Serialize)]
struct SearchResponse {
    results: Vec<SearchMatch>,
    total: usize,
}

#[derive(Serialize)]
struct SearchMatch {
    file: String,
    line: usize,
    content: String,
}

// ── Handler ──────────────────────────────────────────────────────────────

async fn search(
    State(state): State<Arc<AppState>>,
    Query(params): Query<SearchQuery>,
) -> (StatusCode, Json<SearchResponse>) {
    if params.q.trim().is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(SearchResponse {
                results: vec![],
                total: 0,
            }),
        );
    }

    let working_dir = state.working_dir().await;

    match run_ripgrep(&params.q, params.glob.as_deref(), params.max, &working_dir).await {
        Ok(matches) => {
            let total = matches.len();
            (
                StatusCode::OK,
                Json(SearchResponse {
                    results: matches,
                    total,
                }),
            )
        }
        Err(e) => {
            tracing::error!("Search failed: {e}");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(SearchResponse {
                    results: vec![],
                    total: 0,
                }),
            )
        }
    }
}

/// Run ripgrep, falling back to grep if rg is unavailable.
async fn run_ripgrep(
    pattern: &str,
    glob: Option<&str>,
    max: usize,
    working_dir: &str,
) -> anyhow::Result<Vec<SearchMatch>> {
    let mut cmd = Command::new("rg");
    cmd.arg("--line-number")
        .arg("--no-heading")
        .arg("--color=never")
        .arg("--max-count")
        .arg(max.to_string());

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

    cmd.arg(pattern).arg(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);
            Ok(parse_rg_output(&stdout))
        }
        Ok(Err(_)) => {
            // ripgrep not available, fall back to grep
            let mut cmd = Command::new("grep");
            cmd.arg("-rn")
                .arg("--color=never")
                .arg(pattern)
                .arg(working_dir);

            let output = cmd.output().await?;
            let stdout = String::from_utf8_lossy(&output.stdout);
            let matches: Vec<SearchMatch> =
                parse_rg_output(&stdout).into_iter().take(max).collect();
            Ok(matches)
        }
        Err(_) => anyhow::bail!("search timed out after 30s"),
    }
}

/// Parse `file:line:content` output from rg/grep.
fn parse_rg_output(stdout: &str) -> Vec<SearchMatch> {
    stdout
        .lines()
        .filter_map(|line| {
            let (file, rest) = line.split_once(':')?;
            let (line_no, content) = rest.split_once(':')?;
            Some(SearchMatch {
                file: file.to_string(),
                line: line_no.parse().ok()?,
                content: content.to_string(),
            })
        })
        .collect()
}