use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use arc_swap::ArcSwap;
use ignore::WalkBuilder;
use ignore::gitignore::GitignoreBuilder;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32String};
use notify::Watcher;
pub struct FileEntry {
pub path: PathBuf,
utf32: Utf32String,
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub path: PathBuf,
pub is_dir: bool,
}
pub struct FileIndex {
files: Vec<FileEntry>,
trigrams: HashMap<[u8; 3], Vec<usize>>,
}
impl std::fmt::Debug for FileIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FileIndex({} files)", self.files.len())
}
}
impl FileIndex {
pub fn build_with_max_depth(root: &Path, max_depth: Option<usize>) -> Self {
let mut files = Vec::with_capacity(4096);
let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
let mut builder = WalkBuilder::new(root);
builder
.hidden(false) .git_ignore(true) .git_exclude(true)
.parents(true);
if let Some(d) = max_depth {
builder.max_depth(Some(d));
}
for result in builder.build() {
let Ok(entry) = result else { continue };
let Some(ft) = entry.file_type() else {
continue;
};
if !ft.is_file() {
continue;
}
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path);
if rel.components().any(|c| {
c.as_os_str()
.to_str()
.map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
.unwrap_or(false)
}) {
continue;
}
let s = rel.to_string_lossy().replace('\\', "/");
let idx = files.len();
index_trigrams(s.as_bytes(), idx, &mut trigrams);
files.push(FileEntry {
path: rel.to_path_buf(),
utf32: Utf32String::from(s.as_str()),
});
}
FileIndex { files, trigrams }
}
pub fn build(root: &Path) -> Self {
Self::build_with_max_depth(root, None)
}
#[allow(dead_code)]
pub fn from_paths(paths: Vec<PathBuf>) -> Self {
let mut files = Vec::with_capacity(paths.len());
let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
for path in paths {
let s = path.to_string_lossy().replace('\\', "/");
let idx = files.len();
index_trigrams(s.as_bytes(), idx, &mut trigrams);
files.push(FileEntry {
utf32: Utf32String::from(s.as_str()),
path,
});
}
FileIndex { files, trigrams }
}
#[allow(dead_code)]
pub fn files(&self) -> &[FileEntry] {
&self.files
}
fn trigram_candidate_indices(&self, query: &str) -> Option<Vec<usize>> {
let q: Vec<u8> = query.bytes().filter(|&b| b != b' ').collect();
if q.len() < 3 {
return None;
}
let first = [
q[0].to_ascii_lowercase(),
q[1].to_ascii_lowercase(),
q[2].to_ascii_lowercase(),
];
let a = self.trigrams.get(&first)?;
if q.len() < 6 {
return Some(a.clone());
}
let last = [
q[q.len() - 3].to_ascii_lowercase(),
q[q.len() - 2].to_ascii_lowercase(),
q[q.len() - 1].to_ascii_lowercase(),
];
let b = match self.trigrams.get(&last) {
Some(v) => v,
None => return Some(vec![]),
};
let mut out = Vec::with_capacity(a.len().min(b.len()));
let set: std::collections::HashSet<_> = b.iter().copied().collect();
for &idx in a {
if set.contains(&idx) {
out.push(idx);
}
}
Some(out)
}
}
fn index_trigrams(bytes: &[u8], idx: usize, map: &mut HashMap<[u8; 3], Vec<usize>>) {
use std::collections::HashSet;
let mut seen = HashSet::new();
for tri in bytes.windows(3) {
let key = [
tri[0].to_ascii_lowercase(),
tri[1].to_ascii_lowercase(),
tri[2].to_ascii_lowercase(),
];
if seen.insert(key) {
map.entry(key).or_default().push(idx);
}
}
}
pub type SharedFileIndex = Arc<ArcSwap<Option<FileIndex>>>;
pub struct ProjectFileRegistry {
inner: FileIndex,
dir_children: HashMap<PathBuf, Vec<DirEntry>>,
file_set: HashSet<PathBuf>,
}
impl std::fmt::Debug for ProjectFileRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ProjectFileRegistry({} files)", self.inner.files.len())
}
}
impl Default for ProjectFileRegistry {
fn default() -> Self {
ProjectFileRegistry {
inner: FileIndex { files: vec![], trigrams: HashMap::new() },
dir_children: HashMap::new(),
file_set: HashSet::new(),
}
}
}
impl ProjectFileRegistry {
pub fn build(root: &Path) -> Self {
let mut files = Vec::with_capacity(4096);
let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
let mut dir_map: HashMap<PathBuf, Vec<DirEntry>> = HashMap::new();
let mut file_set: HashSet<PathBuf> = HashSet::new();
for result in WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.parents(true)
.build()
{
let Ok(entry) = result else { continue };
let Some(ft) = entry.file_type() else { continue };
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path);
if rel.as_os_str().is_empty() {
continue;
}
if rel.components().any(|c| {
c.as_os_str()
.to_str()
.map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
.unwrap_or(false)
}) {
continue;
}
let is_dir = ft.is_dir();
let parent = rel.parent().unwrap_or(Path::new(""));
dir_map.entry(parent.to_path_buf()).or_default().push(DirEntry { path: rel.to_path_buf(), is_dir });
if !is_dir {
let s = rel.to_string_lossy().replace('\\', "/");
let idx = files.len();
index_trigrams(s.as_bytes(), idx, &mut trigrams);
file_set.insert(rel.to_path_buf());
files.push(FileEntry {
path: rel.to_path_buf(),
utf32: Utf32String::from(s.as_str()),
});
}
}
for children in dir_map.values_mut() {
children.sort_unstable_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| a.path.cmp(&b.path))
});
}
let file_count = files.len();
let reg = ProjectFileRegistry {
inner: FileIndex { files, trigrams },
dir_children: dir_map,
file_set,
};
log::info!("project registry built ({file_count} files)");
reg
}
pub fn files(&self) -> &[FileEntry] {
&self.inner.files
}
pub fn file_index(&self) -> &FileIndex {
&self.inner
}
pub fn children_of(&self, rel_dir: &Path) -> &[DirEntry] {
self.dir_children.get(rel_dir).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn root_children(&self) -> &[DirEntry] {
self.children_of(Path::new(""))
}
pub fn files_under(&self, rel_dir: &Path) -> Vec<&Path> {
self.inner
.files
.iter()
.filter(|e| e.path.starts_with(rel_dir))
.map(|e| e.path.as_path())
.collect()
}
pub fn contains(&self, rel: &Path) -> bool {
self.file_set.contains(rel)
}
}
pub type SharedRegistry = Arc<ArcSwap<Option<ProjectFileRegistry>>>;
pub fn spawn_registry(root: PathBuf) -> SharedRegistry {
let shared: SharedRegistry = Arc::new(ArcSwap::from_pointee(None));
{
let out = shared.clone();
let r = root.clone();
tokio::task::spawn_blocking(move || {
out.store(Arc::new(Some(ProjectFileRegistry::build(&r))));
});
}
let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
{
let root_clone = root.clone();
let trigger_tx_clone = trigger_tx.clone();
fn build_gitignore_registry(root: &Path) -> ignore::gitignore::Gitignore {
let mut gbuilder = GitignoreBuilder::new(root);
let mut walker = WalkBuilder::new(root);
walker.hidden(false).git_ignore(false).git_exclude(false);
for entry in walker.build().filter_map(|r| r.ok()) {
if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
let _ = gbuilder.add(entry.path());
}
}
let git_info_exclude = root.join(".git").join("info").join("exclude");
if git_info_exclude.is_file() {
let _ = gbuilder.add(git_info_exclude);
}
match gbuilder.build() {
Ok(g) => g,
Err(_) => ignore::gitignore::Gitignore::empty(),
}
}
let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(
build_gitignore_registry(&root_clone),
));
std::thread::spawn(move || {
let watcher_root = root_clone.clone();
let cached_gi_clone = cached_gi.clone();
let mut watcher = match notify::RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| match res {
Ok(event) => {
let mut rebuild_gi = false;
for p in &event.paths {
if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore")
|| p.to_string_lossy().ends_with(".git/info/exclude")
{
rebuild_gi = true;
break;
}
}
if rebuild_gi {
let new_gi = build_gitignore_registry(&watcher_root);
if let Ok(mut g) = cached_gi_clone.lock() {
*g = new_gi;
}
}
let mut any_relevant = false;
for p in &event.paths {
let is_dir = p.metadata().map(|md| md.is_dir()).unwrap_or(false);
let g = cached_gi_clone.lock().unwrap();
if !g.matched(p, is_dir).is_ignore() {
any_relevant = true;
break;
}
}
if any_relevant && should_trigger_notify_event(&event.kind) {
let _ = trigger_tx_clone.send(());
}
}
Err(e) => log::warn!("registry watcher error: {e}"),
},
notify::Config::default(),
) {
Ok(w) => w,
Err(e) => {
log::warn!("failed to create registry watcher: {e}");
return;
}
};
if let Err(e) = watcher.watch(&root_clone, notify::RecursiveMode::Recursive) {
log::warn!("registry watcher failed to watch {root_clone:?}: {e}");
return;
}
let (_tx, rx) = std::sync::mpsc::channel::<()>();
let _ = rx.recv();
});
}
let index = shared.clone();
tokio::spawn(async move {
let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
while trigger_rx.recv().await.is_some() {
while trigger_rx.try_recv().is_ok() {}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
while trigger_rx.try_recv().is_ok() {}
if let Some(h) = rebuild.take() {
h.abort();
}
let reg = index.clone();
let r = root.clone();
rebuild = Some(tokio::task::spawn_blocking(move || {
reg.store(Arc::new(Some(ProjectFileRegistry::build(&r))));
}));
}
});
shared
}
pub fn spawn_indexer(root: PathBuf) -> SharedFileIndex {
let shared: SharedFileIndex = Arc::new(ArcSwap::from_pointee(None));
let out = shared.clone();
tokio::task::spawn_blocking(move || {
let idx = FileIndex::build(&root);
log::info!("file index built ({} files)", idx.files.len());
out.store(Arc::new(Some(idx)));
});
shared
}
fn should_trigger_notify_event(kind: ¬ify::EventKind) -> bool {
match kind {
notify::EventKind::Create(_) => true,
notify::EventKind::Remove(_) => true,
notify::EventKind::Modify(mod_kind) => {
matches!(mod_kind, notify::event::ModifyKind::Name(_))
}
_ => false,
}
}
#[cfg(test)]
fn is_path_relevant(root: &Path, path: &Path) -> bool {
if let Ok(_rel) = path.strip_prefix(root) {
let mut gbuilder = GitignoreBuilder::new(root);
let mut walker = WalkBuilder::new(root);
walker.hidden(false).git_ignore(false).git_exclude(false);
for result in walker.build() {
if let Ok(entry) = result
&& let Some(name_os) = entry.path().file_name()
&& let Some(name) = name_os.to_str()
&& name == ".gitignore" {
let _ = gbuilder.add(entry.path());
}
}
let git_info_exclude = root.join(".git").join("info").join("exclude");
if git_info_exclude.is_file() {
let _ = gbuilder.add(git_info_exclude);
}
let gi = match gbuilder.build() {
Ok(g) => g,
Err(_) => ignore::gitignore::Gitignore::empty(),
};
let is_dir = match path.metadata() {
Ok(md) => md.is_dir(),
Err(_) => path.extension().is_none(),
};
!gi.matched(path, is_dir).is_ignore()
} else {
true
}
}
#[cfg(test)]
fn is_event_relevant(root: &Path, event: ¬ify::Event) -> bool {
event.paths.iter().any(|p| is_path_relevant(root, p))
}
pub fn spawn_watcher(root: PathBuf, index: SharedFileIndex) {
let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
{
let root = root.clone();
let trigger_tx_clone = trigger_tx.clone();
fn build_gitignore_for_root(root: &Path) -> ignore::gitignore::Gitignore {
let mut gbuilder = GitignoreBuilder::new(root);
let mut walker = WalkBuilder::new(root);
walker.hidden(false).git_ignore(false).git_exclude(false);
for entry in walker.build().filter_map(|r| r.ok()) {
if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
let _ = gbuilder.add(entry.path());
}
}
let git_info_exclude = root.join(".git").join("info").join("exclude");
if git_info_exclude.is_file() {
let _ = gbuilder.add(git_info_exclude);
}
match gbuilder.build() {
Ok(g) => g,
Err(_) => ignore::gitignore::Gitignore::empty(),
}
}
let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(build_gitignore_for_root(&root)));
std::thread::spawn(move || {
let watcher_root = root.clone();
let cached_gi = cached_gi.clone();
let mut watcher = match notify::RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
match res {
Ok(event) => {
let mut rebuild = false;
for p in &event.paths {
if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
rebuild = true;
break;
}
if p.to_string_lossy().ends_with(".git/info/exclude") {
rebuild = true;
break;
}
}
if rebuild {
let new_gi = build_gitignore_for_root(&watcher_root);
if let Ok(mut guard) = cached_gi.lock() {
*guard = new_gi;
}
}
let mut any_relevant = false;
for p in &event.paths {
let is_dir = match p.metadata() {
Ok(md) => md.is_dir(),
Err(_) => p.extension().is_none(),
};
let guard = cached_gi.lock().unwrap();
if !guard.matched(p, is_dir).is_ignore() {
any_relevant = true;
break;
}
}
if !any_relevant {
return;
}
if should_trigger_notify_event(&event.kind) {
let _ = trigger_tx_clone.send(());
}
}
Err(e) => log::warn!("file watcher error: {e}"),
}
},
notify::Config::default(),
) {
Ok(w) => w,
Err(e) => {
log::warn!("file watcher: failed to create watcher: {e}");
return;
}
};
if let Err(e) = watcher.watch(&root, notify::RecursiveMode::Recursive) {
log::warn!("file watcher: failed to watch {root:?}: {e}");
return;
}
let (_tx_keepalive, rx_keepalive) = std::sync::mpsc::channel::<()>();
let _ = rx_keepalive.recv();
});
}
tokio::spawn(async move {
let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
while trigger_rx.recv().await.is_some() {
while trigger_rx.try_recv().is_ok() {}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
while trigger_rx.try_recv().is_ok() {}
if let Some(h) = rebuild.take() {
h.abort();
}
let idx = index.clone();
let r = root.clone();
rebuild = Some(tokio::task::spawn_blocking(move || {
idx.store(Arc::new(Some(FileIndex::build(&r))));
}));
}
});
}
pub struct NucleoSearch {
matcher: Matcher,
}
impl std::fmt::Debug for NucleoSearch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("NucleoSearch")
}
}
impl Default for NucleoSearch {
fn default() -> Self {
Self::new()
}
}
impl NucleoSearch {
pub fn new() -> Self {
Self {
matcher: Matcher::new(Config::DEFAULT),
}
}
pub fn search_top<'a>(
&mut self,
index: &'a FileIndex,
query: &str,
max: usize,
) -> Vec<&'a FileEntry> {
if query.is_empty() {
return index.files.iter().take(max).collect();
}
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut heap: BinaryHeap<(Reverse<u32>, usize)> = BinaryHeap::with_capacity(max);
let mut process_candidate = |fi: usize| {
let entry = &index.files[fi];
let Some(score) = pattern.score(entry.utf32.slice(..), &mut self.matcher) else {
return;
};
if heap.len() < max {
heap.push((Reverse(score), fi));
} else if let Some(&(Reverse(min_score), _)) = heap.peek()
&& score > min_score {
heap.pop();
heap.push((Reverse(score), fi));
}
};
if let Some(indices) = index.trigram_candidate_indices(query) {
for fi in indices {
process_candidate(fi);
}
} else {
for fi in 0..index.files.len() {
process_candidate(fi);
}
}
let mut results: Vec<(u32, usize)> = heap
.into_iter()
.map(|(Reverse(score), idx)| (score, idx))
.collect();
results.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
results
.into_iter()
.map(|(_, idx)| &index.files[idx])
.collect()
}
#[allow(dead_code)]
pub fn search<'a>(&mut self, index: &'a FileIndex, query: &str) -> Vec<&'a FileEntry> {
self.search_top(index, query, usize::MAX)
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, DataChange, ModifyKind, RenameMode};
use notify::Event;
use std::path::PathBuf;
use tempfile::tempdir;
use std::fs::{create_dir_all, write};
#[test]
fn should_trigger_on_create() {
let ev = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![PathBuf::from("a")], attrs: Default::default() };
assert!(should_trigger_notify_event(&ev.kind));
}
#[test]
fn should_ignore_modify_data() {
let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
assert!(!should_trigger_notify_event(&ev.kind));
}
#[test]
fn should_trigger_rename() {
let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
assert!(should_trigger_notify_event(&ev.kind));
}
#[test]
fn shallow_build_limits_depth() {
let td = tempdir().unwrap();
let root = td.path();
create_dir_all(root.join("a/b")).unwrap();
write(root.join("file1.txt"), b"").unwrap();
write(root.join("a").join("file2.txt"), b"").unwrap();
write(root.join("a").join("b").join("file3.txt"), b"").unwrap();
let idx_full = FileIndex::build_with_max_depth(root, None);
let paths_full: Vec<String> = idx_full.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
assert!(paths_full.iter().any(|p| p == "file1.txt"));
assert!(paths_full.iter().any(|p| p == "a/file2.txt"));
assert!(paths_full.iter().any(|p| p == "a/b/file3.txt"));
let idx_shallow = FileIndex::build_with_max_depth(root, Some(1));
let paths_shallow: Vec<String> = idx_shallow.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
assert!(paths_shallow.iter().any(|p| p == "file1.txt"));
assert!(!paths_shallow.iter().any(|p| p == "a/file2.txt"));
assert!(!paths_shallow.iter().any(|p| p == "a/b/file3.txt"));
}
#[test]
fn walkbuilder_respects_gitignore() {
let td = tempdir().unwrap();
let root = td.path();
write(root.join(".gitignore"), b"ignored.txt\n").unwrap();
write(root.join("ignored.txt"), b"").unwrap();
write(root.join("not_ignored.txt"), b"").unwrap();
let ev_ignored = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("ignored.txt")], attrs: Default::default() };
assert!(!is_event_relevant(root, &ev_ignored));
let ev_not = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("not_ignored.txt")], attrs: Default::default() };
assert!(is_event_relevant(root, &ev_not));
}
fn make_registry_fixture(root: &std::path::Path) {
create_dir_all(root.join(".git")).unwrap(); create_dir_all(root.join("src")).unwrap();
create_dir_all(root.join("docs")).unwrap();
create_dir_all(root.join("ignored")).unwrap();
write(root.join("src/main.rs"), b"fn main() {}").unwrap();
write(root.join("src/lib.rs"), b"").unwrap();
write(root.join("docs/guide.md"), b"# guide").unwrap();
write(root.join("Cargo.toml"), b"[package]").unwrap();
write(root.join(".gitignore"), b"ignored/\n").unwrap();
write(root.join("ignored/secret.txt"), b"").unwrap();
}
#[test]
fn registry_build_single_pass() {
let td = tempdir().unwrap();
let root = td.path();
make_registry_fixture(root);
let reg = ProjectFileRegistry::build(root);
let file_paths: Vec<String> = reg
.files()
.iter()
.map(|e| e.path.to_string_lossy().replace('\\', "/").to_string())
.collect();
assert!(file_paths.iter().any(|p| p == "src/main.rs"), "expected src/main.rs");
assert!(file_paths.iter().any(|p| p == "src/lib.rs"), "expected src/lib.rs");
assert!(file_paths.iter().any(|p| p == "docs/guide.md"), "expected docs/guide.md");
assert!(file_paths.iter().any(|p| p == "Cargo.toml"), "expected Cargo.toml");
assert!(
!file_paths.iter().any(|p| p.contains("secret")),
"gitignored file must not appear"
);
}
#[test]
fn registry_children_of_returns_sorted_entries() {
let td = tempdir().unwrap();
let root = td.path();
make_registry_fixture(root);
let reg = ProjectFileRegistry::build(root);
let src_children = reg.children_of(Path::new("src"));
assert_eq!(src_children.len(), 2, "src should have 2 children");
assert!(!src_children[0].is_dir);
assert!(!src_children[1].is_dir);
let names: Vec<&str> = src_children
.iter()
.map(|e| e.path.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["lib.rs", "main.rs"], "should be alphabetically sorted");
}
#[test]
fn registry_root_children_dirs_before_files() {
let td = tempdir().unwrap();
let root = td.path();
make_registry_fixture(root);
let reg = ProjectFileRegistry::build(root);
let root_ch = reg.root_children();
let mut saw_file = false;
for entry in root_ch {
if entry.is_dir {
assert!(!saw_file, "all dirs must appear before any file");
} else {
saw_file = true;
}
}
assert!(saw_file, "root should contain at least one file");
let dir_names: Vec<&str> = root_ch
.iter()
.filter(|e| e.is_dir)
.map(|e| e.path.file_name().unwrap().to_str().unwrap())
.collect();
assert!(dir_names.contains(&"src"));
assert!(dir_names.contains(&"docs"));
assert!(!dir_names.contains(&"ignored"), "gitignored dir must not appear");
}
#[test]
fn registry_files_under_returns_only_prefix_matches() {
let td = tempdir().unwrap();
let root = td.path();
make_registry_fixture(root);
let reg = ProjectFileRegistry::build(root);
let under_src = reg.files_under(Path::new("src"));
assert_eq!(under_src.len(), 2, "only src/* files");
for p in &under_src {
assert!(p.starts_with("src"), "all paths must start with src");
}
let under_docs = reg.files_under(Path::new("docs"));
assert_eq!(under_docs.len(), 1);
assert!(under_docs[0].ends_with("guide.md"));
assert!(!under_src.iter().any(|p| p.starts_with("docs")));
}
#[test]
fn registry_children_of_nonexistent_dir_returns_empty() {
let td = tempdir().unwrap();
let root = td.path();
make_registry_fixture(root);
let reg = ProjectFileRegistry::build(root);
let ch = reg.children_of(Path::new("nonexistent"));
assert!(ch.is_empty(), "must return empty slice, not panic");
}
#[test]
fn registry_contains_is_o1() {
let td = tempdir().unwrap();
let root = td.path();
make_registry_fixture(root);
let reg = ProjectFileRegistry::build(root);
assert!(reg.contains(Path::new("src/main.rs")));
assert!(reg.contains(Path::new("Cargo.toml")));
assert!(!reg.contains(Path::new("does/not/exist.rs")));
assert!(!reg.contains(Path::new("ignored/secret.txt")));
}
}