Skip to main content

routa_server/api/
files.rs

1//! File Search API - /api/files/search
2//!
3//! GET /api/files/search?q=query&repoPath=/path/to/repo&limit=20
4//!   Search files in a repository using fuzzy matching
5
6use axum::{extract::Query, routing::get, Json, Router};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10use crate::error::ServerError;
11use crate::state::AppState;
12
13pub fn router() -> Router<AppState> {
14    Router::new().route("/search", get(search_files))
15}
16
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct SearchQuery {
20    q: Option<String>,
21    repo_path: Option<String>,
22    limit: Option<usize>,
23}
24
25#[derive(Debug, Serialize)]
26struct FileMatch {
27    path: String,
28    #[serde(rename = "fullPath")]
29    full_path: String,
30    name: String,
31    score: i32,
32}
33
34#[derive(Debug, Serialize)]
35struct SearchResult {
36    files: Vec<FileMatch>,
37    total: usize,
38    query: String,
39    scanned: usize,
40}
41
42const IGNORE_PATTERNS: &[&str] = &[
43    "node_modules",
44    ".git",
45    ".next",
46    "dist",
47    "build",
48    ".cache",
49    "coverage",
50    ".turbo",
51    "target",
52    "__pycache__",
53    ".venv",
54    "venv",
55];
56
57fn fuzzy_match(query: &str, target: &str) -> i32 {
58    let query_lower = query.to_lowercase();
59    let target_lower = target.to_lowercase();
60
61    if target_lower == query_lower {
62        return 1000;
63    }
64    if target_lower.contains(&query_lower) {
65        let file_name = Path::new(&target_lower)
66            .file_name()
67            .map(|n| n.to_string_lossy().to_string())
68            .unwrap_or_default();
69        if file_name.starts_with(&query_lower) {
70            return 900;
71        }
72        if file_name.contains(&query_lower) {
73            return 800;
74        }
75        return 700;
76    }
77
78    let mut score = 0i32;
79    let mut query_idx = 0;
80    let mut consecutive_bonus = 0i32;
81    let query_chars: Vec<char> = query_lower.chars().collect();
82
83    for c in target_lower.chars() {
84        if query_idx < query_chars.len() && c == query_chars[query_idx] {
85            score += 10 + consecutive_bonus;
86            consecutive_bonus += 5;
87            query_idx += 1;
88        } else {
89            consecutive_bonus = 0;
90        }
91    }
92
93    if query_idx < query_chars.len() {
94        return 0;
95    }
96    score += (100 - target.len() as i32).max(0);
97    score
98}
99
100fn should_ignore(name: &str) -> bool {
101    IGNORE_PATTERNS.contains(&name)
102}
103
104fn walk_directory(dir: &Path, root: &Path, max_files: usize) -> Vec<String> {
105    let mut files = Vec::new();
106    walk_recursive(dir, root, &mut files, max_files);
107    files
108}
109
110fn walk_recursive(dir: &Path, root: &Path, files: &mut Vec<String>, max_files: usize) {
111    if files.len() >= max_files {
112        return;
113    }
114    let entries = match std::fs::read_dir(dir) {
115        Ok(e) => e,
116        Err(_) => return,
117    };
118    for entry in entries.flatten() {
119        if files.len() >= max_files {
120            return;
121        }
122        let name = entry.file_name().to_string_lossy().to_string();
123        if should_ignore(&name) {
124            continue;
125        }
126        let path = entry.path();
127        if path.is_dir() {
128            walk_recursive(&path, root, files, max_files);
129        } else if path.is_file() {
130            if let Ok(rel) = path.strip_prefix(root) {
131                files.push(rel.to_string_lossy().to_string());
132            }
133        }
134    }
135}
136
137async fn search_files(
138    Query(params): Query<SearchQuery>,
139) -> Result<Json<SearchResult>, ServerError> {
140    let query = params.q.unwrap_or_default();
141    let repo_path = params
142        .repo_path
143        .ok_or_else(|| ServerError::BadRequest("Missing repoPath parameter".into()))?;
144    let limit = params.limit.unwrap_or(20);
145
146    let repo_dir = PathBuf::from(&repo_path);
147    if !repo_dir.exists() {
148        return Err(ServerError::NotFound(
149            "Repository path does not exist".into(),
150        ));
151    }
152
153    let files = tokio::task::spawn_blocking({
154        let repo_dir = repo_dir.clone();
155        move || walk_directory(&repo_dir, &repo_dir, 10000)
156    })
157    .await
158    .map_err(|e| ServerError::Internal(e.to_string()))?;
159
160    let scanned = files.len();
161
162    if query.trim().is_empty() {
163        let default_files: Vec<FileMatch> = files
164            .into_iter()
165            .take(limit)
166            .map(|file_path| {
167                let full_path = repo_dir.join(&file_path).to_string_lossy().to_string();
168                let name = Path::new(&file_path)
169                    .file_name()
170                    .map(|n| n.to_string_lossy().to_string())
171                    .unwrap_or_else(|| file_path.clone());
172                FileMatch {
173                    path: file_path,
174                    full_path,
175                    name,
176                    score: 0,
177                }
178            })
179            .collect();
180        return Ok(Json(SearchResult {
181            files: default_files,
182            total: scanned,
183            query: String::new(),
184            scanned,
185        }));
186    }
187
188    let mut scored: Vec<FileMatch> = files
189        .into_iter()
190        .filter_map(|file_path| {
191            let score = fuzzy_match(&query, &file_path);
192            if score > 0 {
193                let full_path = repo_dir.join(&file_path).to_string_lossy().to_string();
194                let name = Path::new(&file_path)
195                    .file_name()
196                    .map(|n| n.to_string_lossy().to_string())
197                    .unwrap_or_else(|| file_path.clone());
198                Some(FileMatch {
199                    path: file_path,
200                    full_path,
201                    name,
202                    score,
203                })
204            } else {
205                None
206            }
207        })
208        .collect();
209
210    scored.sort_by(|a, b| b.score.cmp(&a.score));
211    let total = scored.len();
212    scored.truncate(limit);
213
214    Ok(Json(SearchResult {
215        files: scored,
216        total,
217        query,
218        scanned,
219    }))
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::fs;
226    use tempfile::tempdir;
227
228    #[test]
229    fn fuzzy_match_prefers_exact_match() {
230        let exact = fuzzy_match("readme.md", "readme.md");
231        let prefix = fuzzy_match("readme", "docs/readme.md");
232        let contains = fuzzy_match("eadm", "docs/readme.md");
233        let miss = fuzzy_match("xyz", "docs/readme.md");
234
235        assert_eq!(exact, 1000);
236        assert!(prefix > contains);
237        assert_eq!(miss, 0);
238    }
239
240    #[test]
241    fn should_ignore_uses_known_patterns() {
242        assert!(should_ignore("node_modules"));
243        assert!(should_ignore(".git"));
244        assert!(!should_ignore("src"));
245        assert!(!should_ignore("README.md"));
246    }
247
248    #[test]
249    fn walk_directory_skips_ignored_dirs_and_applies_limit() {
250        let temp = tempdir().expect("tempdir should be created");
251        let root = temp.path();
252
253        fs::create_dir_all(root.join("src")).expect("create src");
254        fs::create_dir_all(root.join(".git")).expect("create .git");
255        fs::create_dir_all(root.join("node_modules/pkg")).expect("create node_modules");
256
257        fs::write(root.join("src/a.rs"), "a").expect("write a.rs");
258        fs::write(root.join("src/b.rs"), "b").expect("write b.rs");
259        fs::write(root.join(".git/config"), "ignored").expect("write git config");
260        fs::write(root.join("node_modules/pkg/index.js"), "ignored").expect("write node_modules");
261
262        let files = walk_directory(root, root, 1);
263        assert_eq!(files.len(), 1);
264        assert!(files[0].starts_with("src") && files[0].contains("a.rs"));
265
266        let all = walk_directory(root, root, 10);
267        assert!(all.iter().any(|p| p.contains("src") && p.contains("a.rs")));
268        assert!(all.iter().any(|p| p.contains("src") && p.contains("b.rs")));
269        assert!(!all.iter().any(|p| p.contains(".git")));
270        assert!(!all.iter().any(|p| p.contains("node_modules")));
271    }
272}