1use std::cmp::Reverse;
23use std::collections::BinaryHeap;
24use std::collections::HashMap;
25use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use arc_swap::ArcSwap;
30use ignore::WalkBuilder;
31use ignore::gitignore::GitignoreBuilder;
32
33use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
34use nucleo_matcher::{Config, Matcher, Utf32String};
35use notify::Watcher;
36
37
38pub struct FileEntry {
41 pub path: PathBuf,
43 utf32: Utf32String,
45}
46
47#[derive(Debug, Clone)]
49pub struct DirEntry {
50 pub path: PathBuf,
52 pub is_dir: bool,
53}
54
55pub struct FileIndex {
56 files: Vec<FileEntry>,
57 trigrams: HashMap<[u8; 3], Vec<usize>>,
60}
61
62impl std::fmt::Debug for FileIndex {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 write!(f, "FileIndex({} files)", self.files.len())
65 }
66}
67
68impl FileIndex {
69 pub fn build_with_max_depth(root: &Path, max_depth: Option<usize>) -> Self {
73 let mut files = Vec::with_capacity(4096);
74 let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
75
76 let mut builder = WalkBuilder::new(root);
77 builder
78 .hidden(false) .git_ignore(true) .git_exclude(true)
81 .parents(true);
82
83 if let Some(d) = max_depth {
84 builder.max_depth(Some(d));
85 }
86
87 for result in builder.build() {
88 let Ok(entry) = result else { continue };
89 let Some(ft) = entry.file_type() else {
90 continue;
91 };
92 if !ft.is_file() {
93 continue;
94 }
95
96 let path = entry.path();
97 let rel = path.strip_prefix(root).unwrap_or(path);
98
99 if rel.components().any(|c| {
102 c.as_os_str()
103 .to_str()
104 .map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
105 .unwrap_or(false)
106 }) {
107 continue;
108 }
109
110 let s = rel.to_string_lossy().replace('\\', "/");
111 let idx = files.len();
112 index_trigrams(s.as_bytes(), idx, &mut trigrams);
113 files.push(FileEntry {
114 path: rel.to_path_buf(),
115 utf32: Utf32String::from(s.as_str()),
116 });
117 }
118
119 FileIndex { files, trigrams }
120 }
121
122 pub fn build(root: &Path) -> Self {
124 Self::build_with_max_depth(root, None)
125 }
126
127 #[allow(dead_code)]
130 pub fn from_paths(paths: Vec<PathBuf>) -> Self {
131 let mut files = Vec::with_capacity(paths.len());
132 let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
133
134 for path in paths {
135 let s = path.to_string_lossy().replace('\\', "/");
136 let idx = files.len();
137 index_trigrams(s.as_bytes(), idx, &mut trigrams);
138 files.push(FileEntry {
139 utf32: Utf32String::from(s.as_str()),
140 path,
141 });
142 }
143
144 FileIndex { files, trigrams }
145 }
146
147 #[allow(dead_code)]
148 pub fn files(&self) -> &[FileEntry] {
149 &self.files
150 }
151
152 fn trigram_candidate_indices(&self, query: &str) -> Option<Vec<usize>> {
158 let q: Vec<u8> = query.bytes().filter(|&b| b != b' ').collect();
162 if q.len() < 3 {
163 return None;
164 }
165
166 let first = [
167 q[0].to_ascii_lowercase(),
168 q[1].to_ascii_lowercase(),
169 q[2].to_ascii_lowercase(),
170 ];
171
172 let a = self.trigrams.get(&first)?;
173
174 if q.len() < 6 {
176 return Some(a.clone());
177 }
178
179 let last = [
180 q[q.len() - 3].to_ascii_lowercase(),
181 q[q.len() - 2].to_ascii_lowercase(),
182 q[q.len() - 1].to_ascii_lowercase(),
183 ];
184
185 let b = match self.trigrams.get(&last) {
186 Some(v) => v,
187 None => return Some(vec![]),
188 };
189
190 let mut out = Vec::with_capacity(a.len().min(b.len()));
192 let set: std::collections::HashSet<_> = b.iter().copied().collect();
193
194 for &idx in a {
195 if set.contains(&idx) {
196 out.push(idx);
197 }
198 }
199
200 Some(out)
201 }
202}
203
204fn index_trigrams(bytes: &[u8], idx: usize, map: &mut HashMap<[u8; 3], Vec<usize>>) {
206 use std::collections::HashSet;
207 let mut seen = HashSet::new();
208 for tri in bytes.windows(3) {
209 let key = [
210 tri[0].to_ascii_lowercase(),
211 tri[1].to_ascii_lowercase(),
212 tri[2].to_ascii_lowercase(),
213 ];
214 if seen.insert(key) {
215 map.entry(key).or_default().push(idx);
216 }
217 }
218}
219
220pub type SharedFileIndex = Arc<ArcSwap<Option<FileIndex>>>;
227
228pub struct ProjectFileRegistry {
237 inner: FileIndex,
238 dir_children: HashMap<PathBuf, Vec<DirEntry>>,
241 file_set: HashSet<PathBuf>,
243}
244
245impl std::fmt::Debug for ProjectFileRegistry {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 write!(f, "ProjectFileRegistry({} files)", self.inner.files.len())
248 }
249}
250
251impl Default for ProjectFileRegistry {
252 fn default() -> Self {
253 ProjectFileRegistry {
254 inner: FileIndex { files: vec![], trigrams: HashMap::new() },
255 dir_children: HashMap::new(),
256 file_set: HashSet::new(),
257 }
258 }
259}
260
261impl ProjectFileRegistry {
262 pub fn build(root: &Path) -> Self {
264 let mut files = Vec::with_capacity(4096);
265 let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
266 let mut dir_map: HashMap<PathBuf, Vec<DirEntry>> = HashMap::new();
267 let mut file_set: HashSet<PathBuf> = HashSet::new();
268
269 for result in WalkBuilder::new(root)
270 .hidden(false)
271 .git_ignore(true)
272 .git_exclude(true)
273 .parents(true)
274 .build()
275 {
276 let Ok(entry) = result else { continue };
277 let Some(ft) = entry.file_type() else { continue };
278
279 let path = entry.path();
280 let rel = path.strip_prefix(root).unwrap_or(path);
281
282 if rel.as_os_str().is_empty() {
285 continue;
286 }
287
288 if rel.components().any(|c| {
290 c.as_os_str()
291 .to_str()
292 .map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
293 .unwrap_or(false)
294 }) {
295 continue;
296 }
297
298 let is_dir = ft.is_dir();
299
300 let parent = rel.parent().unwrap_or(Path::new(""));
304 dir_map.entry(parent.to_path_buf()).or_default().push(DirEntry { path: rel.to_path_buf(), is_dir });
305
306 if !is_dir {
307 let s = rel.to_string_lossy().replace('\\', "/");
308 let idx = files.len();
309 index_trigrams(s.as_bytes(), idx, &mut trigrams);
310 file_set.insert(rel.to_path_buf());
311 files.push(FileEntry {
312 path: rel.to_path_buf(),
313 utf32: Utf32String::from(s.as_str()),
314 });
315 }
316 }
317
318 for children in dir_map.values_mut() {
320 children.sort_unstable_by(|a, b| {
321 b.is_dir.cmp(&a.is_dir).then_with(|| a.path.cmp(&b.path))
322 });
323 }
324
325 let file_count = files.len();
326 let reg = ProjectFileRegistry {
327 inner: FileIndex { files, trigrams },
328 dir_children: dir_map,
329 file_set,
330 };
331 log::info!("project registry built ({file_count} files)");
332 reg
333 }
334
335 pub fn files(&self) -> &[FileEntry] {
337 &self.inner.files
338 }
339
340 pub fn file_index(&self) -> &FileIndex {
342 &self.inner
343 }
344
345 pub fn children_of(&self, rel_dir: &Path) -> &[DirEntry] {
350 self.dir_children.get(rel_dir).map(|v| v.as_slice()).unwrap_or(&[])
351 }
352
353 pub fn root_children(&self) -> &[DirEntry] {
355 self.children_of(Path::new(""))
356 }
357
358 pub fn files_under(&self, rel_dir: &Path) -> Vec<&Path> {
362 self.inner
363 .files
364 .iter()
365 .filter(|e| e.path.starts_with(rel_dir))
366 .map(|e| e.path.as_path())
367 .collect()
368 }
369
370 pub fn contains(&self, rel: &Path) -> bool {
372 self.file_set.contains(rel)
373 }
374}
375
376pub type SharedRegistry = Arc<ArcSwap<Option<ProjectFileRegistry>>>;
381
382pub fn spawn_registry(root: PathBuf) -> SharedRegistry {
385 let shared: SharedRegistry = Arc::new(ArcSwap::from_pointee(None));
386
387 {
389 let out = shared.clone();
390 let r = root.clone();
391 tokio::task::spawn_blocking(move || {
392 out.store(Arc::new(Some(ProjectFileRegistry::build(&r))));
393 });
394 }
395
396 let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
398 {
399 let root_clone = root.clone();
400 let trigger_tx_clone = trigger_tx.clone();
401
402 fn build_gitignore_registry(root: &Path) -> ignore::gitignore::Gitignore {
403 let mut gbuilder = GitignoreBuilder::new(root);
404 let mut walker = WalkBuilder::new(root);
405 walker.hidden(false).git_ignore(false).git_exclude(false);
406 for entry in walker.build().filter_map(|r| r.ok()) {
407 if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
408 let _ = gbuilder.add(entry.path());
409 }
410 }
411 let git_info_exclude = root.join(".git").join("info").join("exclude");
412 if git_info_exclude.is_file() {
413 let _ = gbuilder.add(git_info_exclude);
414 }
415 match gbuilder.build() {
416 Ok(g) => g,
417 Err(_) => ignore::gitignore::Gitignore::empty(),
418 }
419 }
420
421 let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(
422 build_gitignore_registry(&root_clone),
423 ));
424
425 std::thread::spawn(move || {
426 let watcher_root = root_clone.clone();
427 let cached_gi_clone = cached_gi.clone();
428 let mut watcher = match notify::RecommendedWatcher::new(
429 move |res: Result<notify::Event, notify::Error>| match res {
430 Ok(event) => {
431 let mut rebuild_gi = false;
432 for p in &event.paths {
433 if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore")
434 || p.to_string_lossy().ends_with(".git/info/exclude")
435 {
436 rebuild_gi = true;
437 break;
438 }
439 }
440 if rebuild_gi {
441 let new_gi = build_gitignore_registry(&watcher_root);
442 if let Ok(mut g) = cached_gi_clone.lock() {
443 *g = new_gi;
444 }
445 }
446 let mut any_relevant = false;
447 for p in &event.paths {
448 let is_dir = p.metadata().map(|md| md.is_dir()).unwrap_or(false);
449 let g = cached_gi_clone.lock().unwrap();
450 if !g.matched(p, is_dir).is_ignore() {
451 any_relevant = true;
452 break;
453 }
454 }
455 if any_relevant && should_trigger_notify_event(&event.kind) {
456 let _ = trigger_tx_clone.send(());
457 }
458 }
459 Err(e) => log::warn!("registry watcher error: {e}"),
460 },
461 notify::Config::default(),
462 ) {
463 Ok(w) => w,
464 Err(e) => {
465 log::warn!("failed to create registry watcher: {e}");
466 return;
467 }
468 };
469
470 if let Err(e) = watcher.watch(&root_clone, notify::RecursiveMode::Recursive) {
471 log::warn!("registry watcher failed to watch {root_clone:?}: {e}");
472 return;
473 }
474
475 let (_tx, rx) = std::sync::mpsc::channel::<()>();
477 let _ = rx.recv();
478 });
479 }
480
481 let index = shared.clone();
482 tokio::spawn(async move {
483 let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
484 while trigger_rx.recv().await.is_some() {
485 while trigger_rx.try_recv().is_ok() {}
486 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
487 while trigger_rx.try_recv().is_ok() {}
488 if let Some(h) = rebuild.take() {
489 h.abort();
490 }
491 let reg = index.clone();
492 let r = root.clone();
493 rebuild = Some(tokio::task::spawn_blocking(move || {
494 reg.store(Arc::new(Some(ProjectFileRegistry::build(&r))));
495 }));
496 }
497 });
498
499 shared
500}
501
502pub fn spawn_indexer(root: PathBuf) -> SharedFileIndex {
507 let shared: SharedFileIndex = Arc::new(ArcSwap::from_pointee(None));
508 let out = shared.clone();
509 tokio::task::spawn_blocking(move || {
510 let idx = FileIndex::build(&root);
511 log::info!("file index built ({} files)", idx.files.len());
512 out.store(Arc::new(Some(idx)));
513 });
514 shared
515}
516
517fn should_trigger_notify_event(kind: ¬ify::EventKind) -> bool {
518 match kind {
519 notify::EventKind::Create(_) => true,
520 notify::EventKind::Remove(_) => true,
521 notify::EventKind::Modify(mod_kind) => {
522 matches!(mod_kind, notify::event::ModifyKind::Name(_))
524 }
525 _ => false,
526 }
527}
528
529#[cfg(test)]
536fn is_path_relevant(root: &Path, path: &Path) -> bool {
537 if let Ok(_rel) = path.strip_prefix(root) {
540 let mut gbuilder = GitignoreBuilder::new(root);
545
546 let mut walker = WalkBuilder::new(root);
547 walker.hidden(false).git_ignore(false).git_exclude(false);
548
549 for result in walker.build() {
550 if let Ok(entry) = result
551 && let Some(name_os) = entry.path().file_name()
552 && let Some(name) = name_os.to_str()
553 && name == ".gitignore" {
554 let _ = gbuilder.add(entry.path());
555 }
556 }
557
558 let git_info_exclude = root.join(".git").join("info").join("exclude");
560 if git_info_exclude.is_file() {
561 let _ = gbuilder.add(git_info_exclude);
562 }
563
564 let gi = match gbuilder.build() {
565 Ok(g) => g,
566 Err(_) => ignore::gitignore::Gitignore::empty(),
567 };
568
569 let is_dir = match path.metadata() {
570 Ok(md) => md.is_dir(),
571 Err(_) => path.extension().is_none(),
572 };
573
574 !gi.matched(path, is_dir).is_ignore()
575 } else {
576 true
577 }
578}
579
580#[cfg(test)]
582fn is_event_relevant(root: &Path, event: ¬ify::Event) -> bool {
583 event.paths.iter().any(|p| is_path_relevant(root, p))
584}
585
586pub fn spawn_watcher(root: PathBuf, index: SharedFileIndex) {
594 let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
595
596 {
601 let root = root.clone();
602 let trigger_tx_clone = trigger_tx.clone();
603
604 fn build_gitignore_for_root(root: &Path) -> ignore::gitignore::Gitignore {
608 let mut gbuilder = GitignoreBuilder::new(root);
609 let mut walker = WalkBuilder::new(root);
610 walker.hidden(false).git_ignore(false).git_exclude(false);
611 for entry in walker.build().filter_map(|r| r.ok()) {
612 if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
613 let _ = gbuilder.add(entry.path());
614 }
615 }
616 let git_info_exclude = root.join(".git").join("info").join("exclude");
618 if git_info_exclude.is_file() {
619 let _ = gbuilder.add(git_info_exclude);
620 }
621 match gbuilder.build() {
622 Ok(g) => g,
623 Err(_) => ignore::gitignore::Gitignore::empty(),
624 }
625 }
626
627 let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(build_gitignore_for_root(&root)));
628
629 std::thread::spawn(move || {
630 let watcher_root = root.clone();
635 let cached_gi = cached_gi.clone();
636 let mut watcher = match notify::RecommendedWatcher::new(
637 move |res: Result<notify::Event, notify::Error>| {
638 match res {
639 Ok(event) => {
640 let mut rebuild = false;
642 for p in &event.paths {
643 if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
644 rebuild = true;
645 break;
646 }
647 if p.to_string_lossy().ends_with(".git/info/exclude") {
648 rebuild = true;
649 break;
650 }
651 }
652 if rebuild {
653 let new_gi = build_gitignore_for_root(&watcher_root);
654 if let Ok(mut guard) = cached_gi.lock() {
655 *guard = new_gi;
656 }
657 }
658
659 let mut any_relevant = false;
661 for p in &event.paths {
662 let is_dir = match p.metadata() {
663 Ok(md) => md.is_dir(),
664 Err(_) => p.extension().is_none(),
665 };
666 let guard = cached_gi.lock().unwrap();
667 if !guard.matched(p, is_dir).is_ignore() {
668 any_relevant = true;
669 break;
670 }
671 }
672 if !any_relevant {
673 return;
674 }
675
676 if should_trigger_notify_event(&event.kind) {
677 let _ = trigger_tx_clone.send(());
679 }
680 }
681 Err(e) => log::warn!("file watcher error: {e}"),
682 }
683 },
684 notify::Config::default(),
685 ) {
686 Ok(w) => w,
687 Err(e) => {
688 log::warn!("file watcher: failed to create watcher: {e}");
689 return;
690 }
691 };
692
693 if let Err(e) = watcher.watch(&root, notify::RecursiveMode::Recursive) {
694 log::warn!("file watcher: failed to watch {root:?}: {e}");
695 return;
696 }
697
698 let (_tx_keepalive, rx_keepalive) = std::sync::mpsc::channel::<()>();
701 let _ = rx_keepalive.recv();
702 });
703 }
704
705 tokio::spawn(async move {
710 let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
711 while trigger_rx.recv().await.is_some() {
712 while trigger_rx.try_recv().is_ok() {}
714
715 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
718
719 while trigger_rx.try_recv().is_ok() {}
721
722 if let Some(h) = rebuild.take() {
725 h.abort();
726 }
727 let idx = index.clone();
728 let r = root.clone();
729 rebuild = Some(tokio::task::spawn_blocking(move || {
730 idx.store(Arc::new(Some(FileIndex::build(&r))));
731 }));
732 }
733 });
734}
735
736pub struct NucleoSearch {
743 matcher: Matcher,
744}
745
746impl std::fmt::Debug for NucleoSearch {
747 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748 f.write_str("NucleoSearch")
749 }
750}
751
752impl Default for NucleoSearch {
753 fn default() -> Self {
754 Self::new()
755 }
756}
757
758impl NucleoSearch {
759 pub fn new() -> Self {
760 Self {
761 matcher: Matcher::new(Config::DEFAULT),
762 }
763 }
764
765 pub fn search_top<'a>(
772 &mut self,
773 index: &'a FileIndex,
774 query: &str,
775 max: usize,
776 ) -> Vec<&'a FileEntry> {
777 if query.is_empty() {
778 return index.files.iter().take(max).collect();
779 }
780
781 let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
782
783 let mut heap: BinaryHeap<(Reverse<u32>, usize)> = BinaryHeap::with_capacity(max);
784
785 let mut process_candidate = |fi: usize| {
787 let entry = &index.files[fi];
788
789 let Some(score) = pattern.score(entry.utf32.slice(..), &mut self.matcher) else {
790 return;
791 };
792
793 if heap.len() < max {
794 heap.push((Reverse(score), fi));
795 } else if let Some(&(Reverse(min_score), _)) = heap.peek()
796 && score > min_score {
797 heap.pop();
798 heap.push((Reverse(score), fi));
799 }
800 };
801
802 if let Some(indices) = index.trigram_candidate_indices(query) {
804 for fi in indices {
805 process_candidate(fi);
806 }
807 } else {
808 for fi in 0..index.files.len() {
809 process_candidate(fi);
810 }
811 }
812
813 let mut results: Vec<(u32, usize)> = heap
815 .into_iter()
816 .map(|(Reverse(score), idx)| (score, idx))
817 .collect();
818
819 results.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
820
821 results
822 .into_iter()
823 .map(|(_, idx)| &index.files[idx])
824 .collect()
825 }
826
827 #[allow(dead_code)]
829 pub fn search<'a>(&mut self, index: &'a FileIndex, query: &str) -> Vec<&'a FileEntry> {
830 self.search_top(index, query, usize::MAX)
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use notify::event::{CreateKind, DataChange, ModifyKind, RenameMode};
838 use notify::Event;
839 use std::path::PathBuf;
840 use tempfile::tempdir;
841 use std::fs::{create_dir_all, write};
842
843 #[test]
844 fn should_trigger_on_create() {
845 let ev = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![PathBuf::from("a")], attrs: Default::default() };
846 assert!(should_trigger_notify_event(&ev.kind));
847 }
848
849 #[test]
850 fn should_ignore_modify_data() {
851 let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
852 assert!(!should_trigger_notify_event(&ev.kind));
853 }
854
855 #[test]
856 fn should_trigger_rename() {
857 let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
858 assert!(should_trigger_notify_event(&ev.kind));
859 }
860
861 #[test]
862 fn shallow_build_limits_depth() {
863 let td = tempdir().unwrap();
864 let root = td.path();
865 create_dir_all(root.join("a/b")).unwrap();
866 write(root.join("file1.txt"), b"").unwrap();
867 write(root.join("a").join("file2.txt"), b"").unwrap();
868 write(root.join("a").join("b").join("file3.txt"), b"").unwrap();
869
870 let idx_full = FileIndex::build_with_max_depth(root, None);
871 let paths_full: Vec<String> = idx_full.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
872 assert!(paths_full.iter().any(|p| p == "file1.txt"));
873 assert!(paths_full.iter().any(|p| p == "a/file2.txt"));
874 assert!(paths_full.iter().any(|p| p == "a/b/file3.txt"));
875
876 let idx_shallow = FileIndex::build_with_max_depth(root, Some(1));
877 let paths_shallow: Vec<String> = idx_shallow.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
878 assert!(paths_shallow.iter().any(|p| p == "file1.txt"));
879 assert!(!paths_shallow.iter().any(|p| p == "a/file2.txt"));
880 assert!(!paths_shallow.iter().any(|p| p == "a/b/file3.txt"));
881 }
882
883 #[test]
884 fn walkbuilder_respects_gitignore() {
885 let td = tempdir().unwrap();
886 let root = td.path();
887 write(root.join(".gitignore"), b"ignored.txt\n").unwrap();
889 write(root.join("ignored.txt"), b"").unwrap();
890 write(root.join("not_ignored.txt"), b"").unwrap();
891
892 let ev_ignored = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("ignored.txt")], attrs: Default::default() };
894 assert!(!is_event_relevant(root, &ev_ignored));
895
896 let ev_not = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("not_ignored.txt")], attrs: Default::default() };
898 assert!(is_event_relevant(root, &ev_not));
899 }
900
901 fn make_registry_fixture(root: &std::path::Path) {
904 create_dir_all(root.join(".git")).unwrap(); create_dir_all(root.join("src")).unwrap();
918 create_dir_all(root.join("docs")).unwrap();
919 create_dir_all(root.join("ignored")).unwrap();
920 write(root.join("src/main.rs"), b"fn main() {}").unwrap();
921 write(root.join("src/lib.rs"), b"").unwrap();
922 write(root.join("docs/guide.md"), b"# guide").unwrap();
923 write(root.join("Cargo.toml"), b"[package]").unwrap();
924 write(root.join(".gitignore"), b"ignored/\n").unwrap();
925 write(root.join("ignored/secret.txt"), b"").unwrap();
926 }
927
928 #[test]
929 fn registry_build_single_pass() {
930 let td = tempdir().unwrap();
931 let root = td.path();
932 make_registry_fixture(root);
933
934 let reg = ProjectFileRegistry::build(root);
935
936 let file_paths: Vec<String> = reg
938 .files()
939 .iter()
940 .map(|e| e.path.to_string_lossy().replace('\\', "/").to_string())
941 .collect();
942
943 assert!(file_paths.iter().any(|p| p == "src/main.rs"), "expected src/main.rs");
944 assert!(file_paths.iter().any(|p| p == "src/lib.rs"), "expected src/lib.rs");
945 assert!(file_paths.iter().any(|p| p == "docs/guide.md"), "expected docs/guide.md");
946 assert!(file_paths.iter().any(|p| p == "Cargo.toml"), "expected Cargo.toml");
947
948 assert!(
950 !file_paths.iter().any(|p| p.contains("secret")),
951 "gitignored file must not appear"
952 );
953 }
954
955 #[test]
956 fn registry_children_of_returns_sorted_entries() {
957 let td = tempdir().unwrap();
958 let root = td.path();
959 make_registry_fixture(root);
960
961 let reg = ProjectFileRegistry::build(root);
962
963 let src_children = reg.children_of(Path::new("src"));
965 assert_eq!(src_children.len(), 2, "src should have 2 children");
966 assert!(!src_children[0].is_dir);
967 assert!(!src_children[1].is_dir);
968 let names: Vec<&str> = src_children
969 .iter()
970 .map(|e| e.path.file_name().unwrap().to_str().unwrap())
971 .collect();
972 assert_eq!(names, vec!["lib.rs", "main.rs"], "should be alphabetically sorted");
973 }
974
975 #[test]
976 fn registry_root_children_dirs_before_files() {
977 let td = tempdir().unwrap();
978 let root = td.path();
979 make_registry_fixture(root);
980
981 let reg = ProjectFileRegistry::build(root);
982 let root_ch = reg.root_children();
983
984 let mut saw_file = false;
986 for entry in root_ch {
987 if entry.is_dir {
988 assert!(!saw_file, "all dirs must appear before any file");
989 } else {
990 saw_file = true;
991 }
992 }
993 assert!(saw_file, "root should contain at least one file");
994
995 let dir_names: Vec<&str> = root_ch
997 .iter()
998 .filter(|e| e.is_dir)
999 .map(|e| e.path.file_name().unwrap().to_str().unwrap())
1000 .collect();
1001 assert!(dir_names.contains(&"src"));
1002 assert!(dir_names.contains(&"docs"));
1003 assert!(!dir_names.contains(&"ignored"), "gitignored dir must not appear");
1004 }
1005
1006 #[test]
1007 fn registry_files_under_returns_only_prefix_matches() {
1008 let td = tempdir().unwrap();
1009 let root = td.path();
1010 make_registry_fixture(root);
1011
1012 let reg = ProjectFileRegistry::build(root);
1013
1014 let under_src = reg.files_under(Path::new("src"));
1015 assert_eq!(under_src.len(), 2, "only src/* files");
1016 for p in &under_src {
1017 assert!(p.starts_with("src"), "all paths must start with src");
1018 }
1019
1020 let under_docs = reg.files_under(Path::new("docs"));
1021 assert_eq!(under_docs.len(), 1);
1022 assert!(under_docs[0].ends_with("guide.md"));
1023
1024 assert!(!under_src.iter().any(|p| p.starts_with("docs")));
1026 }
1027
1028 #[test]
1029 fn registry_children_of_nonexistent_dir_returns_empty() {
1030 let td = tempdir().unwrap();
1031 let root = td.path();
1032 make_registry_fixture(root);
1033
1034 let reg = ProjectFileRegistry::build(root);
1035 let ch = reg.children_of(Path::new("nonexistent"));
1037 assert!(ch.is_empty(), "must return empty slice, not panic");
1038 }
1039
1040 #[test]
1041 fn registry_contains_is_o1() {
1042 let td = tempdir().unwrap();
1043 let root = td.path();
1044 make_registry_fixture(root);
1045
1046 let reg = ProjectFileRegistry::build(root);
1047 assert!(reg.contains(Path::new("src/main.rs")));
1048 assert!(reg.contains(Path::new("Cargo.toml")));
1049 assert!(!reg.contains(Path::new("does/not/exist.rs")));
1050 assert!(!reg.contains(Path::new("ignored/secret.txt")));
1052 }
1053}