Skip to main content

f00_core/
list.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use rayon::prelude::*;
6// jwalk for parallel recursive walks.
7
8use crate::entry::{Entry, EntryKind};
9use crate::error::{Error, Result};
10use crate::filter::filter_entries;
11use crate::ignore::{apply_ignore_set, load_ignore_set, IgnoreSet};
12use crate::options::{CliSymlinkMode, ListOptions};
13use crate::sort::sort_entries;
14
15/// Phase timings for a single listing (milliseconds).
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct ListTiming {
18    /// Time spent in `readdir` / collecting directory entries.
19    pub readdir_ms: u128,
20    /// Time spent stating paths and building [`Entry`] values.
21    pub stat_ms: u128,
22    /// Time spent filtering and sorting.
23    pub sort_ms: u128,
24}
25
26impl ListTiming {
27    /// Sum another timing into this one (for multi-path runs).
28    pub fn add_assign(&mut self, other: &ListTiming) {
29        self.readdir_ms = self.readdir_ms.saturating_add(other.readdir_ms);
30        self.stat_ms = self.stat_ms.saturating_add(other.stat_ms);
31        self.sort_ms = self.sort_ms.saturating_add(other.sort_ms);
32    }
33}
34
35/// A listing produced for one path argument (or recursive tree).
36#[derive(Debug, Clone)]
37pub struct Listing {
38    /// The path that was requested.
39    pub root: PathBuf,
40    /// Whether `root` itself is a directory.
41    pub root_is_dir: bool,
42    /// Entries to display. For recursive mode, may include dir headers.
43    pub entries: Vec<Entry>,
44    /// Recoverable problems while listing (e.g. unreadable subdirectory).
45    /// Surfaces as process exit code 1 when non-zero.
46    pub minor_errors: usize,
47    /// Optional phase timings when [`ListOptions::collect_timing`] is set.
48    pub timing: Option<ListTiming>,
49}
50
51impl Listing {
52    fn new(root: PathBuf, root_is_dir: bool, entries: Vec<Entry>) -> Self {
53        Self {
54            root,
55            root_is_dir,
56            entries,
57            minor_errors: 0,
58            timing: None,
59        }
60    }
61
62    fn with_timing(mut self, timing: Option<ListTiming>) -> Self {
63        self.timing = timing;
64        self
65    }
66}
67
68/// Decide whether to follow a CLI path argument that is a symlink.
69fn follow_cli_path(path: &Path, opts: &ListOptions) -> bool {
70    if opts.follow_links {
71        return true;
72    }
73    match opts.cli_symlink {
74        CliSymlinkMode::Never => false,
75        CliSymlinkMode::Always => {
76            // `-H`: follow command-line symlinks.
77            fs::symlink_metadata(path)
78                .map(|m| m.file_type().is_symlink())
79                .unwrap_or(false)
80        }
81        CliSymlinkMode::DirOnly => {
82            // Follow only when the symlink resolves to a directory.
83            let is_link = fs::symlink_metadata(path)
84                .map(|m| m.file_type().is_symlink())
85                .unwrap_or(false);
86            if !is_link {
87                return false;
88            }
89            fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false)
90        }
91    }
92}
93
94/// List a single path: if file, return that entry alone; if dir, list children.
95///
96/// With `-d` / `directory`, list the path itself even when it is a directory.
97pub fn list_path(path: &Path, opts: &ListOptions) -> Result<Listing> {
98    let path = if path.as_os_str().is_empty() {
99        Path::new(".")
100    } else {
101        path
102    };
103
104    let follow = follow_cli_path(path, opts);
105
106    let meta = if follow {
107        fs::metadata(path)
108    } else {
109        fs::symlink_metadata(path)
110    }
111    .map_err(|source| {
112        if source.kind() == std::io::ErrorKind::NotFound {
113            Error::NotFound(path.to_path_buf())
114        } else {
115            Error::Metadata {
116                path: path.to_path_buf(),
117                source,
118            }
119        }
120    })?;
121
122    // `-d`: list the directory entry itself, not its contents.
123    if opts.directory || !meta.is_dir() {
124        let fill = meta_fill_from(opts);
125        let entry = if follow {
126            Entry::from_path_follow_with(path, 0, fill)?
127        } else {
128            Entry::from_path_and_meta_with(path, &meta, 0, fill)?
129        };
130        return Ok(Listing::new(path.to_path_buf(), meta.is_dir(), vec![entry]));
131    }
132
133    if opts.recursive {
134        list_recursive(path, opts)
135    } else {
136        list_directory(path, opts)
137    }
138}
139
140fn meta_fill_from(opts: &ListOptions) -> crate::entry::MetaFill {
141    crate::entry::MetaFill {
142        resolve_names: opts.resolve_owner_group,
143        read_context: opts.read_selinux,
144    }
145}
146
147/// Build an [`Entry`] from a readdir item.
148fn entry_from_dir_entry(
149    item: &fs::DirEntry,
150    follow_links: bool,
151    fill: crate::entry::MetaFill,
152    prefer_statx: bool,
153) -> Option<Entry> {
154    let entry_path = item.path();
155    if follow_links {
156        return Entry::from_path_follow_with(&entry_path, 0, fill).ok();
157    }
158    #[cfg(target_os = "linux")]
159    {
160        if prefer_statx {
161            if let Ok(e) = crate::linux_statx::entry_from_statx(&entry_path, 0, fill) {
162                return Some(e);
163            }
164        }
165    }
166    #[cfg(not(target_os = "linux"))]
167    {
168        let _ = prefer_statx;
169    }
170    let meta = item
171        .metadata()
172        .or_else(|_| fs::symlink_metadata(&entry_path));
173    match meta {
174        Ok(m) => Entry::from_path_and_meta_with(&entry_path, &m, 0, fill).ok(),
175        Err(_) => None,
176    }
177}
178
179/// Stat directory children, optionally in parallel with rayon (or io_uring).
180fn stat_dir_entries(dir_entries: &[fs::DirEntry], opts: &ListOptions) -> Vec<Entry> {
181    let follow = opts.follow_links;
182    let fill = meta_fill_from(opts);
183    let prefer_statx = opts.linux_statx;
184    let map_one = |item: &fs::DirEntry| entry_from_dir_entry(item, follow, fill, prefer_statx);
185
186    // Linux + feature: batch statx via io_uring for large dirs (no follow).
187    #[cfg(all(target_os = "linux", feature = "io-uring"))]
188    {
189        if opts.io_uring
190            && !follow
191            && dir_entries.len() >= crate::io_uring_stat::IO_URING_THRESHOLD
192            && !fill.resolve_names
193            && !fill.read_context
194        {
195            let paths: Vec<_> = dir_entries.iter().map(|d| d.path()).collect();
196            if let Some(entries) = crate::io_uring_stat::entries_from_paths_uring(&paths, fill) {
197                // Preserve "same count roughly" — if many failures, fall back.
198                if entries.len() * 10 >= paths.len() * 8 {
199                    return entries;
200                }
201            }
202        }
203    }
204
205    if !opts.use_parallel_stat(dir_entries.len()) {
206        return dir_entries.iter().filter_map(map_one).collect();
207    }
208
209    let collect = || dir_entries.par_iter().filter_map(map_one).collect();
210
211    if opts.threads > 1 {
212        match rayon::ThreadPoolBuilder::new()
213            .num_threads(opts.threads)
214            .build()
215        {
216            Ok(pool) => pool.install(collect),
217            Err(_) => collect(),
218        }
219    } else {
220        collect()
221    }
222}
223
224/// Non-recursive directory listing.
225pub fn list_directory(path: &Path, opts: &ListOptions) -> Result<Listing> {
226    let collect_timing = opts.collect_timing;
227    let mut timing = ListTiming::default();
228
229    let mut entries = Vec::new();
230
231    let fill = meta_fill_from(opts);
232    // Synthesize `.` and `..` sequentially (must not race with parallel stat).
233    if opts.all {
234        if let Ok(dot) = Entry::from_path_with(path, 0, fill) {
235            let mut e = dot;
236            e.name = ".".to_string();
237            entries.push(e);
238        }
239        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
240            if let Ok(mut e) = Entry::from_path_with(parent, 0, fill) {
241                e.name = "..".to_string();
242                e.path = parent.to_path_buf();
243                entries.push(e);
244            }
245        } else if let Ok(mut e) = Entry::from_path_with(path, 0, fill) {
246            // path is like `.` or `/` — still emit `..` best-effort
247            e.name = "..".to_string();
248            entries.push(e);
249        }
250    }
251
252    let t_readdir = if collect_timing {
253        Some(Instant::now())
254    } else {
255        None
256    };
257
258    let read = fs::read_dir(path).map_err(|source| Error::ReadDir {
259        path: path.to_path_buf(),
260        source,
261    })?;
262
263    let mut dir_entries = Vec::new();
264    for item in read {
265        let item = item.map_err(|source| Error::ReadDir {
266            path: path.to_path_buf(),
267            source,
268        })?;
269        dir_entries.push(item);
270    }
271
272    if let Some(t0) = t_readdir {
273        timing.readdir_ms = t0.elapsed().as_millis();
274    }
275
276    let t_stat = if collect_timing {
277        Some(Instant::now())
278    } else {
279        None
280    };
281
282    let children = stat_dir_entries(&dir_entries, opts);
283    entries.extend(children);
284
285    if let Some(t0) = t_stat {
286        timing.stat_ms = t0.elapsed().as_millis();
287    }
288
289    let t_sort = if collect_timing {
290        Some(Instant::now())
291    } else {
292        None
293    };
294
295    filter_entries(&mut entries, opts);
296    if opts.use_ignore_files {
297        let set = load_ignore_set(path);
298        apply_ignore_set(&mut entries, &set);
299    }
300    // Deterministic order: sort always runs after parallel collect.
301    sort_entries(&mut entries, opts);
302
303    if let Some(t0) = t_sort {
304        timing.sort_ms = t0.elapsed().as_millis();
305    }
306
307    Ok(
308        Listing::new(path.to_path_buf(), true, entries).with_timing(if collect_timing {
309            Some(timing)
310        } else {
311            None
312        }),
313    )
314}
315
316/// Lightweight walk record (owned paths so we can parallelize metadata).
317struct Walked {
318    path: PathBuf,
319    depth: usize,
320    is_dir: bool,
321}
322
323/// Basic recursive listing using parallel walk (jwalk) + parallel / io_uring metadata.
324pub fn list_recursive(path: &Path, opts: &ListOptions) -> Result<Listing> {
325    let collect_timing = opts.collect_timing;
326    let mut timing = ListTiming::default();
327
328    let mut minor_errors = 0usize;
329    let max_depth = opts.max_depth.unwrap_or(usize::MAX);
330
331    let root_ignore = if opts.use_ignore_files {
332        Some(load_ignore_set(path))
333    } else {
334        None
335    };
336
337    let t_walk = if collect_timing {
338        Some(Instant::now())
339    } else {
340        None
341    };
342
343    // Phase 1: parallel directory walk (jwalk). sort(true) keeps name order per dir.
344    // We avoid metadata() during the walk so stat can be batched / parallel next.
345    let mut walked: Vec<Walked> = Vec::new();
346    // skip_hidden(false): f00 applies its own -a/-A/hide rules after the walk.
347    let mut jwalker = jwalk::WalkDir::new(path)
348        .follow_links(opts.follow_links)
349        .skip_hidden(false)
350        .sort(true);
351    if max_depth != usize::MAX {
352        jwalker = jwalker.max_depth(max_depth);
353    }
354
355    for item in jwalker {
356        let item = match item {
357            Ok(i) => i,
358            Err(_) => {
359                minor_errors += 1;
360                continue;
361            }
362        };
363        let depth = item.depth();
364        if depth == 0 {
365            continue; // root is not a listed entry
366        }
367        let is_dir = item.file_type().is_dir();
368        walked.push(Walked {
369            path: item.path(),
370            depth,
371            is_dir,
372        });
373    }
374
375    // jwalk may not surface every unreadable-directory error on the iterator;
376    // probe dirs so `-R` still yields exit code 1 (GNU ls-compatible minor error).
377    for w in &walked {
378        if w.is_dir {
379            if let Err(err) = fs::read_dir(&w.path) {
380                let kind = err.kind();
381                if matches!(
382                    kind,
383                    std::io::ErrorKind::PermissionDenied
384                        | std::io::ErrorKind::NotFound
385                        | std::io::ErrorKind::Other
386                ) {
387                    minor_errors += 1;
388                }
389            }
390        }
391    }
392
393    if let Some(t0) = t_walk {
394        timing.readdir_ms = t0.elapsed().as_millis();
395    }
396
397    let fill = meta_fill_from(opts);
398    let t_stat = if collect_timing {
399        Some(Instant::now())
400    } else {
401        None
402    };
403
404    // Phase 2: metadata → Entry.
405    // Prefer io_uring batch statx for large cheap listings (Linux + feature).
406    let built: Vec<Option<Entry>> = {
407        #[cfg(all(target_os = "linux", feature = "io-uring"))]
408        {
409            if opts.io_uring
410                && !opts.follow_links
411                && walked.len() >= crate::io_uring_stat::IO_URING_THRESHOLD
412                && !fill.resolve_names
413                && !fill.read_context
414            {
415                let paths: Vec<_> = walked.iter().map(|w| w.path.clone()).collect();
416                if let Some(mut ents) = crate::io_uring_stat::entries_from_paths_uring(&paths, fill)
417                {
418                    if ents.len() * 10 >= paths.len() * 8 {
419                        // Restore walk depths by path (uring builds depth=0).
420                        use std::collections::HashMap;
421                        let depth_by: HashMap<&Path, usize> =
422                            walked.iter().map(|w| (w.path.as_path(), w.depth)).collect();
423                        for e in &mut ents {
424                            if let Some(&d) = depth_by.get(e.path.as_path()) {
425                                e.depth = d;
426                            }
427                        }
428                        // Re-order to walk order and filter.
429                        let mut by_path: HashMap<PathBuf, Entry> =
430                            ents.into_iter().map(|e| (e.path.clone(), e)).collect();
431                        let ordered: Vec<Option<Entry>> = walked
432                            .iter()
433                            .map(|w| {
434                                by_path
435                                    .remove(&w.path)
436                                    .filter(|e| crate::filter::should_show(e, opts))
437                            })
438                            .collect();
439                        if let Some(t0) = t_stat {
440                            timing.stat_ms = t0.elapsed().as_millis();
441                        }
442                        // Jump to phase 3 with ordered.
443                        return finish_recursive(
444                            path,
445                            opts,
446                            walked,
447                            ordered,
448                            root_ignore,
449                            minor_errors,
450                            timing,
451                            collect_timing,
452                        );
453                    }
454                }
455            }
456        }
457
458        let use_parallel = opts.use_parallel_stat(walked.len().max(1));
459        let map = |w: &Walked| {
460            Entry::from_path_with(&w.path, w.depth, fill)
461                .ok()
462                .filter(|e| crate::filter::should_show(e, opts))
463        };
464        if use_parallel {
465            if opts.threads > 1 {
466                match rayon::ThreadPoolBuilder::new()
467                    .num_threads(opts.threads)
468                    .build()
469                {
470                    Ok(pool) => pool.install(|| walked.par_iter().map(map).collect()),
471                    Err(_) => walked.par_iter().map(map).collect(),
472                }
473            } else {
474                walked.par_iter().map(map).collect()
475            }
476        } else {
477            walked.iter().map(map).collect()
478        }
479    };
480
481    if let Some(t0) = t_stat {
482        timing.stat_ms = t0.elapsed().as_millis();
483    }
484
485    finish_recursive(
486        path,
487        opts,
488        walked,
489        built,
490        root_ignore,
491        minor_errors,
492        timing,
493        collect_timing,
494    )
495}
496
497#[allow(clippy::too_many_arguments)]
498fn finish_recursive(
499    path: &Path,
500    opts: &ListOptions,
501    walked: Vec<Walked>,
502    built: Vec<Option<Entry>>,
503    root_ignore: Option<IgnoreSet>,
504    minor_errors: usize,
505    mut timing: ListTiming,
506    collect_timing: bool,
507) -> Result<Listing> {
508    // Phase 3: sequential ignore filter. For `-R` (emit_dir_headers) rebuild into
509    // GNU section order: all siblings of a directory, then each subdir section —
510    // never DFS-interleave children mid-sibling-list (that broke drop-in `-R`).
511    let mut ignore_by_dir: Vec<(PathBuf, IgnoreSet)> = Vec::new();
512    let mut plain: Vec<Entry> = Vec::with_capacity(built.len());
513
514    for (_w, maybe) in walked.iter().zip(built) {
515        let Some(e) = maybe else {
516            continue;
517        };
518        if ignored_by_sets(&e, opts, root_ignore.as_ref(), &mut ignore_by_dir) {
519            continue;
520        }
521        plain.push(e);
522    }
523
524    let t_sort = if collect_timing {
525        Some(Instant::now())
526    } else {
527        None
528    };
529
530    let entries = if opts.emit_dir_headers {
531        rebuild_gnu_recursive_sections(path, plain, opts)
532    } else {
533        // Tree path: walk order already name-sorted within each directory (WalkDir).
534        // Re-sort siblings only if a non-name primary sort was requested.
535        let mut entries = plain;
536        if !matches!(opts.sort_by, crate::options::SortBy::Name) || opts.reverse {
537            sort_tree_preorder(&mut entries, opts);
538        }
539        entries
540    };
541
542    if let Some(t0) = t_sort {
543        timing.sort_ms = t0.elapsed().as_millis();
544    }
545
546    let mut listing = Listing::new(path.to_path_buf(), true, entries)
547        .with_timing(if collect_timing { Some(timing) } else { None });
548    listing.minor_errors = minor_errors;
549    Ok(listing)
550}
551
552/// Re-sort a preorder tree listing by re-grouping siblings after a non-name sort.
553///
554/// Keeps a valid preorder: each directory’s children stay contiguous under it.
555fn sort_tree_preorder(entries: &mut [Entry], opts: &ListOptions) {
556    if entries.is_empty() {
557        return;
558    }
559    // Build groups by parent path; stable relative to walk.
560    // Simple approach: sort by (parent, sort_key) while preserving depth structure
561    // via full path component sort with custom comparator for the leaf name only.
562    // For size/time sorts, sort siblings that share the same parent.
563    let mut i = 0;
564    while i < entries.len() {
565        let depth = entries[i].depth;
566        // Find run of direct children of the same parent starting at i… actually
567        // at depth `depth`, a sibling run is contiguous until depth < depth.
568        let parent = entries[i].path.parent().map(Path::to_path_buf);
569        let mut j = i + 1;
570        while j < entries.len() {
571            if entries[j].depth < depth {
572                break;
573            }
574            if entries[j].depth == depth {
575                let p = entries[j].path.parent().map(Path::to_path_buf);
576                if p != parent {
577                    break;
578                }
579            }
580            j += 1;
581        }
582        // Within [i, j), extract sibling indices at exactly `depth`.
583        let sib: Vec<usize> = (i..j).filter(|&k| entries[k].depth == depth).collect();
584        if sib.len() > 1 {
585            // Sort sibling subtrees by moving whole subtree blocks.
586            // Build blocks: each sibling at depth owns [start, next_sibling).
587            let mut blocks: Vec<Vec<Entry>> = Vec::with_capacity(sib.len());
588            for (bi, &start) in sib.iter().enumerate() {
589                let end = sib.get(bi + 1).copied().unwrap_or(j);
590                blocks.push(entries[start..end].to_vec());
591            }
592            // cmp_entry already applies reverse when set.
593            blocks.sort_by(|a, b| crate::sort::cmp_entry(&a[0], &b[0], opts));
594            let mut out = Vec::with_capacity(j - i);
595            for b in blocks {
596                out.extend(b);
597            }
598            entries[i..j].clone_from_slice(&out);
599        }
600        i = if j > i { j } else { i + 1 };
601    }
602}
603
604/// Check root ignore set and the ignore file in the entry's parent directory.
605fn ignored_by_sets(
606    entry: &Entry,
607    opts: &ListOptions,
608    root_ignore: Option<&IgnoreSet>,
609    cache: &mut Vec<(PathBuf, IgnoreSet)>,
610) -> bool {
611    if !opts.use_ignore_files {
612        return false;
613    }
614    if let Some(root) = root_ignore {
615        if root.ignores(entry) {
616            return true;
617        }
618    }
619    let Some(parent) = entry.path.parent() else {
620        return false;
621    };
622    if let Some(root) = root_ignore {
623        if parent == root.base {
624            return false;
625        }
626    }
627    if let Some((_, set)) = cache.iter().find(|(p, _)| p == parent) {
628        return set.ignores(entry);
629    }
630    let set = load_ignore_set(parent);
631    let ignored = set.ignores(entry);
632    cache.push((parent.to_path_buf(), set));
633    ignored
634}
635
636/// Build GNU `ls -R` layout: `dir:` header, sorted immediate children, then recurse
637/// into each subdirectory in that same sorted order.
638fn rebuild_gnu_recursive_sections(
639    root: &Path,
640    plain: Vec<Entry>,
641    opts: &ListOptions,
642) -> Vec<Entry> {
643    use std::collections::HashMap;
644
645    let mut by_parent: HashMap<PathBuf, Vec<Entry>> = HashMap::new();
646    for e in plain {
647        let parent = e
648            .path
649            .parent()
650            .map(Path::to_path_buf)
651            .unwrap_or_else(|| root.to_path_buf());
652        by_parent.entry(parent).or_default().push(e);
653    }
654
655    let mut out = Vec::new();
656    fn emit(
657        dir: &Path,
658        depth: usize,
659        by_parent: &mut HashMap<PathBuf, Vec<Entry>>,
660        opts: &ListOptions,
661        out: &mut Vec<Entry>,
662    ) {
663        out.push(Entry::dir_header(dir, depth));
664        let mut kids = by_parent.remove(dir).unwrap_or_default();
665        sort_entries(&mut kids, opts);
666        let subdirs: Vec<PathBuf> = kids
667            .iter()
668            .filter(|e| e.kind == EntryKind::Directory)
669            .map(|e| e.path.clone())
670            .collect();
671        out.extend(kids);
672        for sub in subdirs {
673            emit(&sub, depth.saturating_add(1), by_parent, opts, out);
674        }
675    }
676    emit(root, 0, &mut by_parent, opts, &mut out);
677    out
678}
679
680/// Outcome of listing one or more path arguments.
681#[derive(Debug)]
682pub struct ListOutcome {
683    pub listings: Vec<Listing>,
684    /// Errors for command-line path arguments that could not be listed.
685    pub path_errors: Vec<Error>,
686    /// Sum of recoverable errors inside successful listings (e.g. unreadable subdirs).
687    pub minor_errors: usize,
688}
689
690impl ListOutcome {
691    /// GNU-aligned exit status: 0 ok, 1 minor problems, 2 serious (bad path args).
692    pub fn exit_code(&self) -> i32 {
693        if !self.path_errors.is_empty() {
694            2
695        } else if self.minor_errors > 0 || self.listings.iter().any(|l| l.minor_errors > 0) {
696            1
697        } else {
698            0
699        }
700    }
701
702    /// Aggregate phase timings across successful listings.
703    pub fn total_timing(&self) -> ListTiming {
704        let mut total = ListTiming::default();
705        for l in &self.listings {
706            if let Some(ref t) = l.timing {
707                total.add_assign(t);
708            }
709        }
710        total
711    }
712}
713
714/// List multiple paths, returning one Listing per successful path.
715///
716/// Failures on individual path arguments are collected (serious) rather than
717/// aborting the whole run, matching GNU `ls` multi-argument behavior.
718pub fn list_paths(paths: &[PathBuf], opts: &ListOptions) -> Result<Vec<Listing>> {
719    let outcome = list_paths_with_errors(paths, opts);
720    if let Some(err) = outcome.path_errors.into_iter().next() {
721        // Preserve previous fail-fast API for callers that expect `Result`.
722        return Err(err);
723    }
724    Ok(outcome.listings)
725}
726
727/// List paths, collecting per-argument errors instead of failing fast.
728pub fn list_paths_with_errors(paths: &[PathBuf], opts: &ListOptions) -> ListOutcome {
729    let owned: Vec<PathBuf>;
730    let paths: &[PathBuf] = if paths.is_empty() {
731        owned = vec![PathBuf::from(".")];
732        &owned
733    } else {
734        paths
735    };
736
737    let mut listings = Vec::with_capacity(paths.len());
738    let mut path_errors = Vec::new();
739    let mut minor_errors = 0usize;
740
741    for p in paths {
742        match list_path(p, opts) {
743            Ok(l) => {
744                minor_errors += l.minor_errors;
745                listings.push(l);
746            }
747            Err(e) => path_errors.push(e),
748        }
749    }
750
751    ListOutcome {
752        listings,
753        path_errors,
754        minor_errors,
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use std::fs;
762
763    fn temp_dir() -> PathBuf {
764        use std::sync::atomic::{AtomicU64, Ordering};
765        static N: AtomicU64 = AtomicU64::new(0);
766        let base = std::env::temp_dir().join(format!(
767            "f00-core-list-{}-{}-{}",
768            std::process::id(),
769            std::time::SystemTime::now()
770                .duration_since(std::time::UNIX_EPOCH)
771                .map(|d| d.as_nanos())
772                .unwrap_or(0),
773            N.fetch_add(1, Ordering::Relaxed)
774        ));
775        fs::create_dir_all(&base).unwrap();
776        base
777    }
778
779    #[test]
780    fn list_paths_with_errors_partial_success() {
781        let dir = temp_dir();
782        fs::write(dir.join("a.txt"), b"x").unwrap();
783        let missing = dir.join("gone");
784        let opts = ListOptions::default();
785        let outcome = list_paths_with_errors(&[dir.clone(), missing], &opts);
786        assert_eq!(outcome.listings.len(), 1);
787        assert_eq!(outcome.path_errors.len(), 1);
788        assert_eq!(outcome.exit_code(), 2);
789        let _ = fs::remove_dir_all(&dir);
790    }
791
792    #[test]
793    fn list_paths_ok_exit_0() {
794        let dir = temp_dir();
795        fs::write(dir.join("a.txt"), b"x").unwrap();
796        let opts = ListOptions::default();
797        let outcome = list_paths_with_errors(std::slice::from_ref(&dir), &opts);
798        assert!(outcome.path_errors.is_empty());
799        assert_eq!(outcome.exit_code(), 0);
800        let _ = fs::remove_dir_all(&dir);
801    }
802
803    #[test]
804    fn list_directory_honors_ignore_files() {
805        let dir = temp_dir();
806        fs::write(dir.join("keep.txt"), b"k").unwrap();
807        fs::write(dir.join("skip.o"), b"o").unwrap();
808        fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
809
810        let opts = ListOptions {
811            use_ignore_files: true,
812            ..Default::default()
813        };
814        let listing = list_directory(&dir, &opts).unwrap();
815        let names: Vec<_> = listing.entries.iter().map(|e| e.name.as_str()).collect();
816        assert!(names.contains(&"keep.txt"));
817        assert!(!names.contains(&"skip.o"));
818
819        let opts_off = ListOptions {
820            use_ignore_files: false,
821            ..Default::default()
822        };
823        let listing = list_directory(&dir, &opts_off).unwrap();
824        let names: Vec<_> = listing.entries.iter().map(|e| e.name.as_str()).collect();
825        assert!(names.contains(&"skip.o"));
826
827        let _ = fs::remove_dir_all(&dir);
828    }
829
830    #[test]
831    fn parallel_list_same_names_as_sequential() {
832        let dir = temp_dir();
833        // Well above PARALLEL_STAT_THRESHOLD so parallel path is taken.
834        let mut expected = Vec::new();
835        for i in 0..64 {
836            let name = format!("file_{i:03}.txt");
837            fs::write(dir.join(&name), b"x").unwrap();
838            expected.push(name);
839        }
840        fs::create_dir(dir.join("subdir")).unwrap();
841        expected.push("subdir".into());
842        expected.sort();
843
844        let sequential = ListOptions {
845            parallel: false,
846            threads: 1,
847            ..Default::default()
848        };
849        let seq = list_directory(&dir, &sequential).unwrap();
850        let mut seq_names: Vec<_> = seq.entries.iter().map(|e| e.name.clone()).collect();
851        seq_names.sort();
852        assert_eq!(
853            seq_names, expected,
854            "listing must include every created entry"
855        );
856
857        // Skip rayon parallel path on FreeBSD CI VMs (SIGSEGV under qemu).
858        if !cfg!(target_os = "freebsd") {
859            let parallel = ListOptions {
860                parallel: true,
861                threads: 0,
862                ..Default::default()
863            };
864            let par = list_directory(&dir, &parallel).unwrap();
865            let mut par_names: Vec<_> = par.entries.iter().map(|e| e.name.clone()).collect();
866            par_names.sort();
867            assert_eq!(
868                seq_names, par_names,
869                "parallel and sequential must produce identical ordered names"
870            );
871        }
872
873        let _ = fs::remove_dir_all(&dir);
874    }
875
876    #[test]
877    fn collect_timing_fills_phases() {
878        let dir = temp_dir();
879        for i in 0..8 {
880            fs::write(dir.join(format!("f{i}")), b"x").unwrap();
881        }
882        let opts = ListOptions {
883            collect_timing: true,
884            parallel: false,
885            threads: 1,
886            ..Default::default()
887        };
888        let listing = list_directory(&dir, &opts).unwrap();
889        let t = listing.timing.expect("timing present");
890        // Just ensure fields are populated (may be 0ms on very fast FS).
891        let _ = (t.readdir_ms, t.stat_ms, t.sort_ms);
892        let _ = fs::remove_dir_all(&dir);
893    }
894
895    #[test]
896    fn threads_one_forces_serial_path() {
897        let dir = temp_dir();
898        for i in 0..40 {
899            fs::write(dir.join(format!("n{i}")), b"x").unwrap();
900        }
901        let opts = ListOptions {
902            parallel: true,
903            threads: 1,
904            ..Default::default()
905        };
906        assert!(!opts.use_parallel_stat(40));
907        let listing = list_directory(&dir, &opts).unwrap();
908        assert_eq!(listing.entries.len(), 40);
909        let _ = fs::remove_dir_all(&dir);
910    }
911}