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))
}
#[derive(Deserialize)]
struct SearchQuery {
q: String,
#[serde(default)]
glob: Option<String>,
#[serde(default = "default_max")]
max: usize,
}
fn default_max() -> usize {
50
}
#[derive(Serialize)]
struct SearchResponse {
results: Vec<SearchMatch>,
total: usize,
}
#[derive(Serialize)]
struct SearchMatch {
file: String,
line: usize,
content: String,
}
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(¶ms.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,
}),
)
}
}
}
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(_)) => {
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"),
}
}
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()
}