use crate::core::{EntryKind, SymlinkInfo};
use anyhow::{Context, Result};
use std::{
collections::VecDeque,
fs,
path::{Path, PathBuf},
};
const SEARCH_NODE_VISIT_LIMIT: usize = 5_000_000;
const SEARCH_CANDIDATE_BATCH_SIZE: usize = 512;
const SEARCH_PROGRESS_NODE_INTERVAL: usize = 2_048;
#[derive(Clone, Debug)]
pub(crate) struct SearchCandidate {
pub path: PathBuf,
pub name: String,
pub name_key: String,
pub relative: String,
pub relative_key: String,
pub is_dir: bool,
pub symlink: Option<SymlinkInfo>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct SearchIndexStats {
pub(crate) visited_nodes: usize,
pub(crate) node_limit_reached: bool,
pub(crate) candidate_limit_reached: bool,
}
impl SearchIndexStats {
pub(crate) fn is_limited(self) -> bool {
self.node_limit_reached || self.candidate_limit_reached
}
}
#[derive(Clone, Debug)]
pub(crate) struct SearchIndex {
pub(crate) candidates: Vec<SearchCandidate>,
pub(crate) stats: SearchIndexStats,
}
#[derive(Clone, Debug)]
pub(crate) struct SearchIndexBatch {
pub(crate) candidates: Vec<SearchCandidate>,
pub(crate) stats: SearchIndexStats,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SearchCandidateScope {
Files,
Folders,
}
pub(crate) fn collect_candidates_streaming(
cwd: &Path,
show_hidden: bool,
scope: SearchCandidateScope,
is_canceled: impl Fn() -> bool,
emit_batch: impl FnMut(SearchIndexBatch) -> bool,
) -> Result<SearchIndex> {
collect_candidates_with_limits_and_emitter(
cwd,
show_hidden,
scope,
SearchCollectionLimits {
candidate_limit: usize::MAX,
node_visit_limit: SEARCH_NODE_VISIT_LIMIT,
batch_size: SEARCH_CANDIDATE_BATCH_SIZE,
},
is_canceled,
emit_batch,
)
}
struct PendingSearchNode {
path: PathBuf,
name: String,
name_key: String,
relative: Option<String>,
relative_key: Option<String>,
is_dir: bool,
symlink: Option<SymlinkInfo>,
enqueue_children: bool,
}
#[derive(Clone, Copy)]
struct SearchCollectionLimits {
candidate_limit: usize,
node_visit_limit: usize,
batch_size: usize,
}
impl PendingSearchNode {
fn into_candidate(self) -> Option<SearchCandidate> {
let relative = self.relative?;
let relative_key = self.relative_key?;
Some(SearchCandidate {
path: self.path,
name: self.name,
name_key: self.name_key,
relative,
relative_key,
is_dir: self.is_dir,
symlink: self.symlink,
})
}
}
#[cfg(test)]
fn collect_candidates_with_limits(
cwd: &Path,
show_hidden: bool,
scope: SearchCandidateScope,
candidate_limit: usize,
node_visit_limit: usize,
) -> Result<SearchIndex> {
collect_candidates_with_limits_and_emitter(
cwd,
show_hidden,
scope,
SearchCollectionLimits {
candidate_limit,
node_visit_limit,
batch_size: 0,
},
|| false,
|_| true,
)
}
fn collect_candidates_with_limits_and_emitter(
cwd: &Path,
show_hidden: bool,
scope: SearchCandidateScope,
limits: SearchCollectionLimits,
is_canceled: impl Fn() -> bool,
mut emit_batch: impl FnMut(SearchIndexBatch) -> bool,
) -> Result<SearchIndex> {
let mut queue = VecDeque::from([cwd.to_path_buf()]);
let mut visited_nodes = 0usize;
let mut node_limit_reached = false;
let mut candidates = Vec::new();
let emit_batches = limits.batch_size > 0;
let mut pending_candidates = Vec::with_capacity(limits.batch_size);
let mut next_progress_at = SEARCH_PROGRESS_NODE_INTERVAL;
while let Some(dir) = queue.pop_front() {
if is_canceled() {
return Ok(build_search_index(
candidates,
visited_nodes,
node_limit_reached,
limits.candidate_limit,
));
}
if search_scan_limit_reached(visited_nodes, limits.node_visit_limit) {
node_limit_reached = true;
break;
}
let read_dir = match fs::read_dir(&dir) {
Ok(read_dir) => read_dir,
Err(error) if dir == cwd => {
return Err(error).with_context(|| format!("failed to read {}", cwd.display()));
}
Err(_) => continue,
};
let mut nodes = Vec::new();
for entry in read_dir {
if is_canceled() {
return Ok(build_search_index(
candidates,
visited_nodes,
node_limit_reached,
limits.candidate_limit,
));
}
if search_scan_limit_reached(visited_nodes, limits.node_visit_limit) {
node_limit_reached = true;
break;
}
let Ok(entry) = entry else {
continue;
};
let file_name = entry.file_name();
if !show_hidden && super::is_hidden_entry(&entry) {
continue;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
let classified = classify_entry(&path, &file_type);
let Some(classified) = classified else {
continue;
};
let is_dir = classified.is_dir;
let is_symlink_entry = classified.symlink.is_some();
visited_nodes += 1;
if emit_batches && visited_nodes >= next_progress_at {
let stats = SearchIndexStats {
visited_nodes,
node_limit_reached: false,
candidate_limit_reached: false,
};
if !emit_search_batch(
&mut pending_candidates,
limits.batch_size,
stats,
true,
&mut emit_batch,
) {
return Ok(SearchIndex { candidates, stats });
}
next_progress_at = visited_nodes.saturating_add(SEARCH_PROGRESS_NODE_INTERVAL);
}
if is_canceled() {
return Ok(build_search_index(
candidates,
visited_nodes,
node_limit_reached,
limits.candidate_limit,
));
}
let include_candidate = should_include_candidate(is_dir, scope);
if !is_dir && !include_candidate {
continue;
}
let name = file_name.to_string_lossy().to_string();
let name_key = name.to_lowercase();
let enqueue_children = is_dir && !is_symlink_entry && !should_prune_dir(&name_key);
if !include_candidate && !enqueue_children {
continue;
}
let (relative, relative_key) = if include_candidate {
let Ok(relative_path) = path.strip_prefix(cwd) else {
continue;
};
let relative = relative_path.to_string_lossy().replace('\\', "/");
let relative_key = relative.to_lowercase();
(Some(relative), Some(relative_key))
} else {
(None, None)
};
nodes.push(PendingSearchNode {
path,
name,
name_key,
relative,
relative_key,
is_dir,
symlink: classified.symlink,
enqueue_children,
});
}
nodes.sort_by(|left, right| {
super::natural_cmp(&left.name_key, &right.name_key)
.then_with(|| left.name.cmp(&right.name))
});
for node in nodes {
if is_canceled() {
return Ok(build_search_index(
candidates,
visited_nodes,
node_limit_reached,
limits.candidate_limit,
));
}
if node.enqueue_children {
queue.push_back(node.path.clone());
}
if let Some(candidate) = node.into_candidate() {
candidates.push(candidate);
if emit_batches {
pending_candidates.push(
candidates
.last()
.expect("candidate was just pushed")
.clone(),
);
if pending_candidates.len() >= limits.batch_size {
let stats = SearchIndexStats {
visited_nodes,
node_limit_reached,
candidate_limit_reached: false,
};
if !emit_search_batch(
&mut pending_candidates,
limits.batch_size,
stats,
false,
&mut emit_batch,
) {
return Ok(SearchIndex { candidates, stats });
}
}
}
}
}
}
let index = build_search_index(
candidates,
visited_nodes,
node_limit_reached,
limits.candidate_limit,
);
if emit_batches {
let _ = emit_search_batch(
&mut pending_candidates,
limits.batch_size,
index.stats,
false,
&mut emit_batch,
);
}
Ok(index)
}
fn build_search_index(
mut candidates: Vec<SearchCandidate>,
visited_nodes: usize,
node_limit_reached: bool,
candidate_limit: usize,
) -> SearchIndex {
let candidate_limit_reached = candidates.len() > candidate_limit;
if candidate_limit_reached {
candidates.truncate(candidate_limit);
}
SearchIndex {
candidates,
stats: SearchIndexStats {
visited_nodes,
node_limit_reached,
candidate_limit_reached,
},
}
}
fn emit_search_batch(
pending_candidates: &mut Vec<SearchCandidate>,
batch_size: usize,
stats: SearchIndexStats,
force_progress: bool,
emit_batch: &mut impl FnMut(SearchIndexBatch) -> bool,
) -> bool {
if pending_candidates.is_empty() && !force_progress {
return true;
}
let candidates = std::mem::replace(pending_candidates, Vec::with_capacity(batch_size));
emit_batch(SearchIndexBatch { candidates, stats })
}
fn search_scan_limit_reached(visited_nodes: usize, node_visit_limit: usize) -> bool {
visited_nodes >= node_visit_limit
}
pub(crate) fn filter_candidates_in<I>(
candidates: &[SearchCandidate],
pool: I,
query: &str,
limit: usize,
) -> SearchFilterResult
where
I: IntoIterator<Item = usize>,
{
if query.trim().is_empty() {
let pool = pool.into_iter().collect::<Vec<_>>();
let matches = pool.iter().copied().take(limit).collect();
return SearchFilterResult { pool, matches };
}
let query_key = query.to_lowercase();
let needle = query_key.as_bytes();
let mut filtered_pool = Vec::new();
let mut top = Vec::<(usize, i64, usize)>::with_capacity(limit.min(64));
for index in pool {
let candidate = &candidates[index];
let exact_name_bonus = (candidate.name_key == query_key) as i64 * 220;
let name_score = fuzzy_score_bytes(needle, candidate.name_key.as_bytes())
.map(|score| score + 80 + i64::from(candidate.is_dir) * 12 + exact_name_bonus);
let path_score = fuzzy_score_bytes(needle, candidate.relative_key.as_bytes());
let score = match (name_score, path_score) {
(Some(name), Some(path)) => name.max(path),
(Some(name), None) => name,
(None, Some(path)) => path,
(None, None) => continue,
};
filtered_pool.push(index);
let entry = (index, score, candidate.relative.len());
let insert_at = top
.binary_search_by(|existing| compare_scored(candidates, existing, &entry))
.unwrap_or_else(|slot| slot);
if insert_at >= limit {
continue;
}
top.insert(insert_at, entry);
if top.len() > limit {
top.pop();
}
}
let matches = top.into_iter().map(|(index, _, _)| index).collect();
SearchFilterResult {
pool: filtered_pool,
matches,
}
}
pub(crate) struct SearchFilterResult {
pub(crate) pool: Vec<usize>,
pub(crate) matches: Vec<usize>,
}
struct ClassifiedSearchEntry {
is_dir: bool,
symlink: Option<SymlinkInfo>,
}
fn classify_entry(path: &Path, file_type: &fs::FileType) -> Option<ClassifiedSearchEntry> {
if file_type.is_symlink() {
let target = fs::read_link(path).ok();
let target_kind = fs::metadata(path).ok().map(|metadata| {
if metadata.is_dir() {
EntryKind::Directory
} else {
EntryKind::File
}
});
let is_dir = matches!(target_kind, Some(EntryKind::Directory));
Some(ClassifiedSearchEntry {
is_dir,
symlink: Some(SymlinkInfo {
target,
target_kind,
}),
})
} else if file_type.is_dir() {
Some(ClassifiedSearchEntry {
is_dir: true,
symlink: None,
})
} else if file_type.is_file() {
Some(ClassifiedSearchEntry {
is_dir: false,
symlink: None,
})
} else {
None
}
}
fn should_include_candidate(is_dir: bool, scope: SearchCandidateScope) -> bool {
match scope {
SearchCandidateScope::Files => !is_dir,
SearchCandidateScope::Folders => is_dir,
}
}
fn should_prune_dir(name_key: &str) -> bool {
matches!(name_key, ".git" | "node_modules" | "target")
}
fn compare_scored(
candidates: &[SearchCandidate],
left: &(usize, i64, usize),
right: &(usize, i64, usize),
) -> std::cmp::Ordering {
right
.1
.cmp(&left.1)
.then_with(|| left.2.cmp(&right.2))
.then_with(|| {
super::natural_cmp(
&candidates[left.0].relative_key,
&candidates[right.0].relative_key,
)
.then_with(|| {
candidates[left.0]
.relative
.cmp(&candidates[right.0].relative)
})
})
}
fn fuzzy_score_bytes(query: &[u8], text: &[u8]) -> Option<i64> {
if query.is_empty() {
return Some(0);
}
if text.is_empty() {
return None;
}
let mut score = 0i64;
let mut scan_at = 0usize;
let mut last_match = None;
let mut streak = 0i64;
for &byte in query {
let mut found = None;
for (index, &candidate) in text.iter().enumerate().skip(scan_at) {
if candidate == byte {
found = Some(index);
break;
}
}
let index = found?;
if index == 0
|| matches!(
text[index.saturating_sub(1)],
b'/' | b'-' | b'_' | b' ' | b'.'
)
{
score += 18;
}
if let Some(previous) = last_match {
if index == previous + 1 {
streak += 1;
score += 20 + streak * 6;
} else {
streak = 0;
score -= (index - previous - 1) as i64;
}
} else {
score += 12;
score -= index as i64;
}
score += 10;
scan_at = index + 1;
last_match = Some(index);
}
score -= (text.len().saturating_sub(scan_at)) as i64 / 3;
Some(score)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("elio-search-{label}-{unique}"))
}
#[test]
fn fuzzy_filter_prefers_tighter_name_match() {
let candidates = vec![
SearchCandidate {
path: PathBuf::from("/tmp/src/main.rs"),
name: "main.rs".to_string(),
name_key: "main.rs".to_string(),
relative: "src/main.rs".to_string(),
relative_key: "src/main.rs".to_string(),
is_dir: false,
symlink: None,
},
SearchCandidate {
path: PathBuf::from("/tmp/docs/readme.md"),
name: "readme.md".to_string(),
name_key: "readme.md".to_string(),
relative: "docs/readme.md".to_string(),
relative_key: "docs/readme.md".to_string(),
is_dir: false,
symlink: None,
},
];
let result = filter_candidates_in(&candidates, 0..candidates.len(), "mn", 10);
assert_eq!(result.matches.first().copied(), Some(0));
}
#[test]
fn collect_candidates_respects_hidden_toggle() {
let root = temp_path("hidden-toggle");
fs::create_dir_all(root.join(".hidden-root/needle")).expect("failed to create hidden dir");
fs::create_dir_all(root.join("projects/needle")).expect("failed to create visible dir");
let hidden_off =
collect_candidates_with_limits(&root, false, SearchCandidateScope::Folders, 100, 1_000)
.expect("failed to collect visible candidates")
.candidates;
assert!(
hidden_off
.iter()
.any(|candidate| candidate.relative == "projects")
);
assert!(
hidden_off
.iter()
.any(|candidate| candidate.relative == "projects/needle")
);
assert!(
!hidden_off
.iter()
.any(|candidate| candidate.relative == ".hidden-root/needle")
);
let hidden_on =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Folders, 100, 1_000)
.expect("failed to collect hidden candidates")
.candidates;
assert!(
hidden_on
.iter()
.any(|candidate| candidate.relative == ".hidden-root/needle")
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_follow_stable_breadth_first_order_under_limit() {
let root = temp_path("breadth-first-order");
fs::create_dir_all(root.join(".hidden-root/needle")).expect("failed to create target dir");
fs::create_dir_all(root.join("alpha")).expect("failed to create alpha dir");
fs::create_dir_all(root.join("beta")).expect("failed to create beta dir");
fs::create_dir_all(root.join("gamma")).expect("failed to create gamma dir");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Folders, 6, 1_000)
.expect("failed to collect candidates")
.candidates;
assert_eq!(candidates[0].relative, ".hidden-root");
assert_eq!(candidates[1].relative, "alpha");
assert_eq!(candidates[2].relative, "beta");
assert_eq!(candidates[3].relative, "gamma");
assert!(
candidates
.iter()
.any(|candidate| candidate.relative == ".hidden-root/needle")
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_prune_known_dirs_without_hiding_the_directory_itself() {
let root = temp_path("pruned-dirs");
fs::create_dir_all(root.join("node_modules/package"))
.expect("failed to create node_modules");
fs::create_dir_all(root.join("src/feature")).expect("failed to create src tree");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Folders, 100, 1_000)
.expect("failed to collect candidates")
.candidates;
let names = candidates
.iter()
.map(|candidate| candidate.relative.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"node_modules"));
assert!(!names.contains(&"node_modules/package"));
assert!(names.contains(&"src"));
assert!(names.contains(&"src/feature"));
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_still_descends_directories_when_searching_files() {
let root = temp_path("file-search-descend");
fs::create_dir_all(root.join("alpha")).expect("failed to create alpha");
fs::write(root.join("alpha/needle.txt"), "needle").expect("failed to write needle");
fs::write(root.join("top.txt"), "top").expect("failed to write top");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 100, 1_000)
.expect("failed to collect file candidates")
.candidates;
let names = candidates
.iter()
.map(|candidate| candidate.relative.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"top.txt"));
assert!(names.contains(&"alpha/needle.txt"));
assert!(!names.contains(&"alpha"));
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_reports_node_limit_truncation() {
let root = temp_path("node-limit");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "alpha").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "beta").expect("failed to write beta");
fs::write(root.join("gamma.txt"), "gamma").expect("failed to write gamma");
let index =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 100, 2)
.expect("failed to collect candidates");
assert_eq!(index.stats.visited_nodes, 2);
assert!(index.stats.node_limit_reached);
assert!(!index.stats.candidate_limit_reached);
assert!(index.stats.is_limited());
assert_eq!(index.candidates.len(), 2);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_reports_candidate_limit_truncation() {
let root = temp_path("candidate-limit");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "alpha").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "beta").expect("failed to write beta");
fs::write(root.join("gamma.txt"), "gamma").expect("failed to write gamma");
let index =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 2, 100)
.expect("failed to collect candidates");
assert_eq!(index.stats.visited_nodes, 3);
assert!(!index.stats.node_limit_reached);
assert!(index.stats.candidate_limit_reached);
assert!(index.stats.is_limited());
assert_eq!(index.candidates.len(), 2);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_streaming_emits_batches_before_final_index() {
let root = temp_path("streaming-batches");
fs::create_dir_all(&root).expect("failed to create temp root");
for index in 0..(SEARCH_CANDIDATE_BATCH_SIZE + 1) {
fs::write(root.join(format!("file-{index:04}.txt")), "data")
.expect("failed to write file");
}
let mut batches = Vec::new();
let index = collect_candidates_streaming(
&root,
true,
SearchCandidateScope::Files,
|| false,
|batch| {
batches.push((batch.candidates.len(), batch.stats.visited_nodes));
true
},
)
.expect("failed to collect streaming candidates");
assert_eq!(index.candidates.len(), SEARCH_CANDIDATE_BATCH_SIZE + 1);
assert!(
batches
.iter()
.any(|(candidate_count, _)| *candidate_count == SEARCH_CANDIDATE_BATCH_SIZE),
"expected at least one full candidate batch, got {batches:?}"
);
assert!(
batches
.iter()
.any(|(candidate_count, _)| *candidate_count == 1),
"expected the trailing candidate to be flushed, got {batches:?}"
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_streaming_stops_after_cancellation() {
use std::cell::Cell;
let root = temp_path("streaming-cancel");
fs::create_dir_all(&root).expect("failed to create temp root");
for index in 0..10 {
fs::write(root.join(format!("file-{index:04}.txt")), "data")
.expect("failed to write file");
}
let canceled = Cell::new(false);
let mut batches = 0usize;
let index = collect_candidates_with_limits_and_emitter(
&root,
true,
SearchCandidateScope::Files,
SearchCollectionLimits {
candidate_limit: usize::MAX,
node_visit_limit: 100,
batch_size: 1,
},
|| canceled.get(),
|batch| {
batches += 1;
assert_eq!(batch.candidates.len(), 1);
canceled.set(true);
true
},
)
.expect("failed to collect streaming candidates");
assert_eq!(batches, 1);
assert_eq!(index.candidates.len(), 1);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[cfg(unix)]
#[test]
fn collect_candidates_includes_linked_directory_in_folder_search() {
use std::os::unix::fs::symlink;
let root = temp_path("symlink-dir-folder");
fs::create_dir_all(root.join("real-dir/inner")).expect("failed to create real dir");
symlink(root.join("real-dir"), root.join("linked-dir"))
.expect("failed to create dir symlink");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Folders, 100, 1_000)
.expect("failed to collect folder candidates")
.candidates;
let linked = candidates
.iter()
.find(|candidate| candidate.relative == "linked-dir")
.expect("linked-dir should appear in folder search");
assert!(linked.is_dir, "linked dir should be classified as dir");
assert_eq!(
linked
.symlink
.as_ref()
.and_then(|symlink| symlink.target_kind),
Some(EntryKind::Directory)
);
let relatives = candidates
.iter()
.map(|candidate| candidate.relative.as_str())
.collect::<Vec<_>>();
assert!(
!relatives.contains(&"linked-dir/inner"),
"symlinked dir should not be descended into"
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[cfg(unix)]
#[test]
fn collect_candidates_includes_linked_file_in_file_search() {
use std::os::unix::fs::symlink;
let root = temp_path("symlink-file");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("real.txt"), "data").expect("failed to write real file");
symlink(root.join("real.txt"), root.join("linked.txt"))
.expect("failed to create file symlink");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 100, 1_000)
.expect("failed to collect file candidates")
.candidates;
let linked = candidates
.iter()
.find(|candidate| candidate.relative == "linked.txt")
.expect("linked.txt should appear in file search");
assert!(!linked.is_dir);
assert_eq!(
linked
.symlink
.as_ref()
.and_then(|symlink| symlink.target_kind),
Some(EntryKind::File)
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[cfg(unix)]
#[test]
fn collect_candidates_includes_broken_symlink_in_file_search() {
use std::os::unix::fs::symlink;
let root = temp_path("symlink-broken");
fs::create_dir_all(&root).expect("failed to create temp root");
symlink(root.join("missing-target"), root.join("dangling"))
.expect("failed to create broken symlink");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 100, 1_000)
.expect("failed to collect file candidates")
.candidates;
let broken = candidates
.iter()
.find(|candidate| candidate.relative == "dangling")
.expect("broken symlink should appear in file search");
assert!(!broken.is_dir);
let symlink_info = broken
.symlink
.as_ref()
.expect("broken candidate carries symlink info");
assert!(symlink_info.is_broken());
let folder_candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Folders, 100, 1_000)
.expect("failed to collect folder candidates")
.candidates;
assert!(
!folder_candidates
.iter()
.any(|candidate| candidate.relative == "dangling"),
"broken symlink should not appear in folder search"
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[cfg(unix)]
#[test]
fn collect_candidates_handles_symlink_cycle() {
use std::os::unix::fs::symlink;
let root = temp_path("symlink-cycle");
fs::create_dir_all(&root).expect("failed to create temp root");
symlink(root.join("loop"), root.join("loop"))
.expect("failed to create self-referential symlink");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 100, 1_000)
.expect("failed to collect candidates")
.candidates;
let loop_entry = candidates
.iter()
.find(|candidate| candidate.relative == "loop")
.expect("cycle symlink should appear in file search");
assert!(!loop_entry.is_dir);
let symlink_info = loop_entry
.symlink
.as_ref()
.expect("cycle symlink should carry symlink info");
assert!(symlink_info.is_broken());
assert!(candidates.len() < 50);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[cfg(unix)]
#[test]
fn collect_candidates_hides_dot_prefixed_symlink_when_hidden_off() {
use std::os::unix::fs::symlink;
let root = temp_path("symlink-hidden");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("visible.txt"), "data").expect("failed to write visible");
symlink(root.join("visible.txt"), root.join(".hidden-link"))
.expect("failed to create hidden symlink");
let visible =
collect_candidates_with_limits(&root, false, SearchCandidateScope::Files, 100, 1_000)
.expect("failed to collect non-hidden candidates")
.candidates;
assert!(
!visible
.iter()
.any(|candidate| candidate.relative == ".hidden-link"),
"dot-prefixed symlink must respect hidden toggle"
);
let all =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 100, 1_000)
.expect("failed to collect hidden candidates")
.candidates;
assert!(
all.iter()
.any(|candidate| candidate.relative == ".hidden-link"),
"dot-prefixed symlink must appear when hidden toggle is on"
);
fs::remove_dir_all(root).expect("failed to remove temp tree");
}
#[test]
fn collect_candidates_uses_natural_name_order() {
let root = temp_path("natural-order");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("chapter 10.txt"), "ten").expect("failed to write file");
fs::write(root.join("chapter 2.txt"), "two").expect("failed to write file");
fs::write(root.join("chapter 1.txt"), "one").expect("failed to write file");
let candidates =
collect_candidates_with_limits(&root, true, SearchCandidateScope::Files, 10, 1_000)
.expect("failed to collect candidates")
.candidates;
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["chapter 1.txt", "chapter 2.txt", "chapter 10.txt"]
);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
}