1use frizbee::Config as FrizbeeConfig;
2use ignore::WalkBuilder;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6#[derive(Debug, Clone)]
8pub struct FileMatch {
9 pub path: PathBuf,
11 pub is_dir: bool,
13 pub score: i64,
15}
16
17pub struct FileFinder {
20 working_dir: PathBuf,
22 cached_files: Vec<PathBuf>,
24 last_scan: Option<Instant>,
26 cache_ttl: Duration,
28 max_depth: usize,
30}
31
32impl FileFinder {
33 pub fn new(working_dir: PathBuf) -> Self {
36 Self {
37 working_dir,
38 cached_files: Vec::new(),
39 last_scan: None,
40 cache_ttl: Duration::from_secs(5),
41 max_depth: 10,
42 }
43 }
44
45 pub fn scan_files(&mut self) -> &Vec<PathBuf> {
48 if let Some(last_scan) = self.last_scan {
50 if last_scan.elapsed() < self.cache_ttl {
51 return &self.cached_files;
52 }
53 }
54
55 self.cached_files.clear();
57
58 for result in WalkBuilder::new(&self.working_dir)
60 .max_depth(Some(self.max_depth))
61 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .ignore(true) .build()
67 {
68 match result {
69 Ok(entry) => {
70 let path = entry.path();
71
72 if let Ok(rel_path) = path.strip_prefix(&self.working_dir) {
74 if rel_path.as_os_str().is_empty() {
76 continue;
77 }
78
79 self.cached_files.push(rel_path.to_path_buf());
80 }
81 }
82 Err(err) => {
83 tracing::debug!("Error scanning directory: {}", err);
84 }
85 }
86 }
87
88 self.cached_files.sort();
90
91 self.last_scan = Some(Instant::now());
92 &self.cached_files
93 }
94
95 pub fn filter_files(&self, files: &[PathBuf], query: &str) -> Vec<FileMatch> {
97 if query.is_empty() {
98 return files
100 .iter()
101 .take(20)
102 .map(|p| FileMatch {
103 path: p.clone(),
104 is_dir: p.to_string_lossy().ends_with('/'),
105 score: 0,
106 })
107 .collect();
108 }
109
110 let haystacks: Vec<String> = files
113 .iter()
114 .map(|p| p.to_string_lossy().to_string())
115 .collect();
116
117 let haystack_refs: Vec<&str> = haystacks.iter().map(|s| s.as_str()).collect();
119
120 let config = FrizbeeConfig::default();
122
123 let fuzzy_matches = frizbee::match_list(query, &haystack_refs, &config);
125
126 let mut matches: Vec<FileMatch> = fuzzy_matches
128 .into_iter()
129 .filter_map(|m| {
130 if (m.index as usize) < files.len() {
132 let path = files[m.index as usize].clone();
133 let path_str = haystacks[m.index as usize].clone();
134
135 Some(FileMatch {
136 path,
137 is_dir: path_str.ends_with('/'),
138 score: m.score as i64,
139 })
140 } else {
141 None
142 }
143 })
144 .collect();
145
146 matches.sort_by(|a, b| b.score.cmp(&a.score));
148
149 matches.truncate(20);
151 matches
152 }
153
154 pub fn working_dir(&self) -> &Path {
156 &self.working_dir
157 }
158
159 pub fn refresh_cache(&mut self) {
161 self.last_scan = None;
162 self.scan_files();
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn test_file_finder_basic() {
172 let dir = std::env::current_dir().unwrap();
173 let mut finder = FileFinder::new(dir);
174 let files = finder.scan_files();
175
176 assert!(files.iter().any(|p| p.to_string_lossy() == "Cargo.toml"));
178
179 assert!(!files
181 .iter()
182 .any(|p| p.to_string_lossy().starts_with(".git/")));
183
184 assert!(!files
186 .iter()
187 .any(|p| p.to_string_lossy().starts_with("target/")));
188 }
189
190 #[test]
191 fn test_filter_files() {
192 let dir = std::env::current_dir().unwrap();
193 let mut finder = FileFinder::new(dir);
194 let files = finder.scan_files().clone();
195
196 let matches = finder.filter_files(&files, "Cargo");
197 assert!(!matches.is_empty());
198 assert!(matches
199 .iter()
200 .any(|m| m.path.to_string_lossy() == "Cargo.toml"));
201 }
202
203 #[test]
204 fn test_cache_ttl() {
205 let dir = std::env::current_dir().unwrap();
206 let mut finder = FileFinder::new(dir);
207 finder.cache_ttl = std::time::Duration::from_millis(50);
208
209 let files1 = finder.scan_files().clone();
211
212 let files2 = finder.scan_files().clone();
214 assert_eq!(files1.len(), files2.len());
215
216 std::thread::sleep(std::time::Duration::from_millis(60));
218
219 let files3 = finder.scan_files().clone();
221 assert!(!files3.is_empty());
222 }
223
224 #[test]
225 fn test_gitignore_respected() {
226 let dir = std::env::current_dir().unwrap();
227 let mut finder = FileFinder::new(dir);
228 let files = finder.scan_files();
229
230 let file_paths: Vec<String> = files
232 .iter()
233 .map(|p| p.to_string_lossy().to_string())
234 .collect();
235
236 for path in &file_paths {
238 assert!(
239 !path.starts_with("target/"),
240 "Found target/ in results: {}",
241 path
242 );
243 assert!(
244 !path.starts_with(".git/"),
245 "Found .git/ in results: {}",
246 path
247 );
248 }
249 }
250}