routa_server/api/
files.rs1use 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}