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 a kind-only entry from readdir (uses `d_type` on Linux when available;
148/// may fall back to an internal `lstat` only when the type is unknown).
149fn entry_kind_only(item: &fs::DirEntry) -> Option<Entry> {
150    let file_type = item.file_type().ok()?;
151    let kind = EntryKind::from_file_type(file_type);
152    let name = item.file_name().to_string_lossy().into_owned();
153    let path = item.path();
154    Some(Entry {
155        path,
156        name,
157        kind,
158        size: 0,
159        modified: None,
160        created: None,
161        accessed: None,
162        changed: None,
163        mode: 0,
164        readonly: false,
165        symlink_target: None,
166        depth: 0,
167        git_status: crate::entry::GitStatus::Clean,
168        is_dir_header: false,
169        nlink: 0,
170        uid: 0,
171        gid: 0,
172        inode: 0,
173        blocks: 0,
174        dev: 0,
175        rdev: 0,
176        blksize: 0,
177        owner: String::new(),
178        group: String::new(),
179        author: String::new(),
180        context: String::new(),
181    })
182}
183
184/// Build an [`Entry`] from a readdir item (absolute path fallback).
185fn entry_from_dir_entry(
186    item: &fs::DirEntry,
187    follow_links: bool,
188    fill: crate::entry::MetaFill,
189    prefer_statx: bool,
190) -> Option<Entry> {
191    let entry_path = item.path();
192    if follow_links {
193        return Entry::from_path_follow_with(&entry_path, 0, fill).ok();
194    }
195    #[cfg(target_os = "linux")]
196    {
197        if prefer_statx {
198            if let Ok(e) = crate::linux_statx::entry_from_statx(&entry_path, 0, fill) {
199                return Some(e);
200            }
201        }
202    }
203    #[cfg(not(target_os = "linux"))]
204    {
205        let _ = prefer_statx;
206    }
207    let meta = item
208        .metadata()
209        .or_else(|_| fs::symlink_metadata(&entry_path));
210    match meta {
211        Ok(m) => Entry::from_path_and_meta_with(&entry_path, &m, 0, fill).ok(),
212        Err(_) => None,
213    }
214}
215
216/// After cheap batch stats, resolve owner/group/SELinux when needed.
217///
218/// Serial on purpose: name resolution uses a process-wide `Mutex` cache; parallel
219/// fill thrashes the lock on large directories and is slower overall.
220fn apply_expensive_fill(entries: &mut [Entry], fill: crate::entry::MetaFill) {
221    if !fill.resolve_names && !fill.read_context {
222        return;
223    }
224    for e in entries.iter_mut() {
225        e.fill_expensive(fill);
226    }
227}
228
229/// Stat directory children, optionally in parallel with rayon (or io_uring).
230///
231/// Linux hot path: open the directory once and use **dirfd + relative** `statx`
232/// (and io_uring batching when enabled). Expensive name/context fill runs after.
233///
234/// When [`ListOptions::use_kind_only`] is true, skip `stat` entirely and use
235/// readdir file type (`d_type` / `file_type()`).
236fn stat_dir_entries(dir_entries: &[fs::DirEntry], opts: &ListOptions) -> Vec<Entry> {
237    let follow = opts.follow_links;
238    let fill = meta_fill_from(opts);
239    let prefer_statx = opts.linux_statx;
240
241    // ── Kind-only fast path (readdir d_type; no statx) ────────────────
242    if opts.use_kind_only() && !follow {
243        if !opts.use_parallel_stat(dir_entries.len()) {
244            return dir_entries.iter().filter_map(entry_kind_only).collect();
245        }
246        let collect = || dir_entries.par_iter().filter_map(entry_kind_only).collect();
247        return if opts.threads > 1 {
248            match rayon::ThreadPoolBuilder::new()
249                .num_threads(opts.threads)
250                .build()
251            {
252                Ok(pool) => pool.install(collect),
253                Err(_) => collect(),
254            }
255        } else {
256            collect()
257        };
258    }
259
260    // ── Linux: dirfd-relative batch path ─────────────────────────────
261    #[cfg(target_os = "linux")]
262    {
263        if !follow && prefer_statx && !dir_entries.is_empty() {
264            if let Some(parent) = dir_entries[0].path().parent() {
265                if let Ok(dir_file) = fs::File::open(parent) {
266                    use std::os::fd::AsFd;
267
268                    let names: Vec<std::ffi::OsString> =
269                        dir_entries.iter().map(|d| d.file_name()).collect();
270                    let full_paths: Vec<PathBuf> = dir_entries.iter().map(|d| d.path()).collect();
271                    let use_par = opts.use_parallel_stat(dir_entries.len());
272
273                    // Multi-threaded: dirfd + rayon `statx` wins on modern SSDs.
274                    // Single-threaded / forced serial: prefer io_uring batching.
275                    #[cfg(feature = "io-uring")]
276                    {
277                        if opts.io_uring
278                            && !use_par
279                            && dir_entries.len() >= crate::io_uring_stat::IO_URING_THRESHOLD
280                        {
281                            if let Some(mut entries) = crate::io_uring_stat::entries_from_dir_uring(
282                                dir_file.as_fd(),
283                                &names,
284                                &full_paths,
285                                fill,
286                            ) {
287                                if entries.len() * 10 >= dir_entries.len() * 8 {
288                                    apply_expensive_fill(&mut entries, fill);
289                                    return entries;
290                                }
291                            }
292                        }
293                    }
294
295                    // Relative `statx` via dirfd (parallel when allowed).
296                    let dir_fd = dir_file.as_fd();
297                    let map_rel = |i: usize| {
298                        let name = &names[i];
299                        let path = &full_paths[i];
300                        crate::linux_statx::entry_from_statx_at(
301                            dir_fd,
302                            name,
303                            path,
304                            0,
305                            crate::entry::MetaFill {
306                                resolve_names: false,
307                                read_context: false,
308                            },
309                        )
310                        .ok()
311                    };
312
313                    let mut entries: Vec<Entry> = if use_par {
314                        let collect = || {
315                            (0..dir_entries.len())
316                                .into_par_iter()
317                                .filter_map(map_rel)
318                                .collect()
319                        };
320                        if opts.threads > 1 {
321                            match rayon::ThreadPoolBuilder::new()
322                                .num_threads(opts.threads)
323                                .build()
324                            {
325                                Ok(pool) => pool.install(collect),
326                                Err(_) => collect(),
327                            }
328                        } else {
329                            collect()
330                        }
331                    } else {
332                        (0..dir_entries.len()).filter_map(map_rel).collect()
333                    };
334
335                    if entries.len() * 10 >= dir_entries.len() * 8 {
336                        apply_expensive_fill(&mut entries, fill);
337                        return entries;
338                    }
339                    // else fall through to absolute-path path
340                }
341            }
342        }
343    }
344
345    // ── Portable / follow / fallback ─────────────────────────────────
346    let cheap = crate::entry::MetaFill {
347        resolve_names: false,
348        read_context: false,
349    };
350    let map_one = |item: &fs::DirEntry| entry_from_dir_entry(item, follow, cheap, prefer_statx);
351
352    // Absolute-path io_uring fallback (serial only).
353    #[cfg(all(target_os = "linux", feature = "io-uring"))]
354    {
355        if opts.io_uring
356            && !follow
357            && !opts.use_parallel_stat(dir_entries.len())
358            && dir_entries.len() >= crate::io_uring_stat::IO_URING_THRESHOLD
359        {
360            let paths: Vec<_> = dir_entries.iter().map(|d| d.path()).collect();
361            if let Some(mut entries) = crate::io_uring_stat::entries_from_paths_uring(&paths, fill)
362            {
363                if entries.len() * 10 >= paths.len() * 8 {
364                    apply_expensive_fill(&mut entries, fill);
365                    return entries;
366                }
367            }
368        }
369    }
370
371    let mut entries: Vec<Entry> = if !opts.use_parallel_stat(dir_entries.len()) {
372        dir_entries.iter().filter_map(map_one).collect()
373    } else {
374        let collect = || dir_entries.par_iter().filter_map(map_one).collect();
375        if opts.threads > 1 {
376            match rayon::ThreadPoolBuilder::new()
377                .num_threads(opts.threads)
378                .build()
379            {
380                Ok(pool) => pool.install(collect),
381                Err(_) => collect(),
382            }
383        } else {
384            collect()
385        }
386    };
387    // Always used (including non-Linux) so the helper is not dead_code on macOS.
388    apply_expensive_fill(&mut entries, fill);
389    entries
390}
391
392/// Non-recursive directory listing.
393pub fn list_directory(path: &Path, opts: &ListOptions) -> Result<Listing> {
394    let collect_timing = opts.collect_timing;
395    let mut timing = ListTiming::default();
396
397    let mut entries = Vec::new();
398
399    let fill = meta_fill_from(opts);
400    // Synthesize `.` and `..` sequentially (must not race with parallel stat).
401    if opts.all {
402        if let Ok(dot) = Entry::from_path_with(path, 0, fill) {
403            let mut e = dot;
404            e.name = ".".to_string();
405            entries.push(e);
406        }
407        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
408            if let Ok(mut e) = Entry::from_path_with(parent, 0, fill) {
409                e.name = "..".to_string();
410                e.path = parent.to_path_buf();
411                entries.push(e);
412            }
413        } else if let Ok(mut e) = Entry::from_path_with(path, 0, fill) {
414            // path is like `.` or `/` — still emit `..` best-effort
415            e.name = "..".to_string();
416            entries.push(e);
417        }
418    }
419
420    let t_readdir = if collect_timing {
421        Some(Instant::now())
422    } else {
423        None
424    };
425
426    let read = fs::read_dir(path).map_err(|source| Error::ReadDir {
427        path: path.to_path_buf(),
428        source,
429    })?;
430
431    // Hint capacity from common large-dir case; readdir does not expose size.
432    let mut dir_entries = Vec::with_capacity(256);
433    for item in read {
434        let item = item.map_err(|source| Error::ReadDir {
435            path: path.to_path_buf(),
436            source,
437        })?;
438        dir_entries.push(item);
439    }
440    dir_entries.shrink_to_fit();
441
442    if let Some(t0) = t_readdir {
443        timing.readdir_ms = t0.elapsed().as_millis();
444    }
445
446    let t_stat = if collect_timing {
447        Some(Instant::now())
448    } else {
449        None
450    };
451
452    let children = stat_dir_entries(&dir_entries, opts);
453    entries.extend(children);
454
455    if let Some(t0) = t_stat {
456        timing.stat_ms = t0.elapsed().as_millis();
457    }
458
459    let t_sort = if collect_timing {
460        Some(Instant::now())
461    } else {
462        None
463    };
464
465    filter_entries(&mut entries, opts);
466    if opts.use_ignore_files {
467        let set = load_ignore_set(path);
468        apply_ignore_set(&mut entries, &set);
469    }
470    // Deterministic order: sort always runs after parallel collect.
471    sort_entries(&mut entries, opts);
472
473    if let Some(t0) = t_sort {
474        timing.sort_ms = t0.elapsed().as_millis();
475    }
476
477    Ok(
478        Listing::new(path.to_path_buf(), true, entries).with_timing(if collect_timing {
479            Some(timing)
480        } else {
481            None
482        }),
483    )
484}
485
486/// Lightweight walk record (owned paths so we can parallelize metadata).
487struct Walked {
488    path: PathBuf,
489    depth: usize,
490    is_dir: bool,
491}
492
493/// Basic recursive listing using parallel walk (jwalk) + parallel / io_uring metadata.
494pub fn list_recursive(path: &Path, opts: &ListOptions) -> Result<Listing> {
495    let collect_timing = opts.collect_timing;
496    let mut timing = ListTiming::default();
497
498    let mut minor_errors = 0usize;
499    let max_depth = opts.max_depth.unwrap_or(usize::MAX);
500
501    let root_ignore = if opts.use_ignore_files {
502        Some(load_ignore_set(path))
503    } else {
504        None
505    };
506
507    let t_walk = if collect_timing {
508        Some(Instant::now())
509    } else {
510        None
511    };
512
513    // Phase 1: parallel directory walk (jwalk). sort(true) keeps name order per dir.
514    // We avoid metadata() during the walk so stat can be batched / parallel next.
515    let mut walked: Vec<Walked> = Vec::new();
516    // skip_hidden(false): f00 applies its own -a/-A/hide rules after the walk.
517    let mut jwalker = jwalk::WalkDir::new(path)
518        .follow_links(opts.follow_links)
519        .skip_hidden(false)
520        .sort(true);
521    if max_depth != usize::MAX {
522        jwalker = jwalker.max_depth(max_depth);
523    }
524
525    for item in jwalker {
526        let item = match item {
527            Ok(i) => i,
528            Err(_) => {
529                minor_errors += 1;
530                continue;
531            }
532        };
533        let depth = item.depth();
534        if depth == 0 {
535            continue; // root is not a listed entry
536        }
537        let is_dir = item.file_type().is_dir();
538        walked.push(Walked {
539            path: item.path(),
540            depth,
541            is_dir,
542        });
543    }
544
545    // jwalk may not surface every unreadable-directory error on the iterator;
546    // probe dirs so `-R` still yields exit code 1 (GNU ls-compatible minor error).
547    for w in &walked {
548        if w.is_dir {
549            if let Err(err) = fs::read_dir(&w.path) {
550                let kind = err.kind();
551                if matches!(
552                    kind,
553                    std::io::ErrorKind::PermissionDenied
554                        | std::io::ErrorKind::NotFound
555                        | std::io::ErrorKind::Other
556                ) {
557                    minor_errors += 1;
558                }
559            }
560        }
561    }
562
563    if let Some(t0) = t_walk {
564        timing.readdir_ms = t0.elapsed().as_millis();
565    }
566
567    let fill = meta_fill_from(opts);
568    let t_stat = if collect_timing {
569        Some(Instant::now())
570    } else {
571        None
572    };
573
574    // Phase 2: metadata → Entry.
575    // Prefer io_uring batch statx for large cheap listings (Linux + feature).
576    let built: Vec<Option<Entry>> = {
577        #[cfg(all(target_os = "linux", feature = "io-uring"))]
578        {
579            if opts.io_uring
580                && !opts.follow_links
581                && walked.len() >= crate::io_uring_stat::IO_URING_THRESHOLD
582                && !fill.resolve_names
583                && !fill.read_context
584            {
585                let paths: Vec<_> = walked.iter().map(|w| w.path.clone()).collect();
586                if let Some(mut ents) = crate::io_uring_stat::entries_from_paths_uring(&paths, fill)
587                {
588                    if ents.len() * 10 >= paths.len() * 8 {
589                        // Restore walk depths by path (uring builds depth=0).
590                        use std::collections::HashMap;
591                        let depth_by: HashMap<&Path, usize> =
592                            walked.iter().map(|w| (w.path.as_path(), w.depth)).collect();
593                        for e in &mut ents {
594                            if let Some(&d) = depth_by.get(e.path.as_path()) {
595                                e.depth = d;
596                            }
597                        }
598                        // Re-order to walk order and filter.
599                        let mut by_path: HashMap<PathBuf, Entry> =
600                            ents.into_iter().map(|e| (e.path.clone(), e)).collect();
601                        let ordered: Vec<Option<Entry>> = walked
602                            .iter()
603                            .map(|w| {
604                                by_path
605                                    .remove(&w.path)
606                                    .filter(|e| crate::filter::should_show(e, opts))
607                            })
608                            .collect();
609                        if let Some(t0) = t_stat {
610                            timing.stat_ms = t0.elapsed().as_millis();
611                        }
612                        // Jump to phase 3 with ordered.
613                        return finish_recursive(
614                            path,
615                            opts,
616                            walked,
617                            ordered,
618                            root_ignore,
619                            minor_errors,
620                            timing,
621                            collect_timing,
622                        );
623                    }
624                }
625            }
626        }
627
628        let use_parallel = opts.use_parallel_stat(walked.len().max(1));
629        let map = |w: &Walked| {
630            Entry::from_path_with(&w.path, w.depth, fill)
631                .ok()
632                .filter(|e| crate::filter::should_show(e, opts))
633        };
634        if use_parallel {
635            if opts.threads > 1 {
636                match rayon::ThreadPoolBuilder::new()
637                    .num_threads(opts.threads)
638                    .build()
639                {
640                    Ok(pool) => pool.install(|| walked.par_iter().map(map).collect()),
641                    Err(_) => walked.par_iter().map(map).collect(),
642                }
643            } else {
644                walked.par_iter().map(map).collect()
645            }
646        } else {
647            walked.iter().map(map).collect()
648        }
649    };
650
651    if let Some(t0) = t_stat {
652        timing.stat_ms = t0.elapsed().as_millis();
653    }
654
655    finish_recursive(
656        path,
657        opts,
658        walked,
659        built,
660        root_ignore,
661        minor_errors,
662        timing,
663        collect_timing,
664    )
665}
666
667#[allow(clippy::too_many_arguments)]
668fn finish_recursive(
669    path: &Path,
670    opts: &ListOptions,
671    walked: Vec<Walked>,
672    built: Vec<Option<Entry>>,
673    root_ignore: Option<IgnoreSet>,
674    minor_errors: usize,
675    mut timing: ListTiming,
676    collect_timing: bool,
677) -> Result<Listing> {
678    // Phase 3: sequential ignore filter. For `-R` (emit_dir_headers) rebuild into
679    // GNU section order: all siblings of a directory, then each subdir section —
680    // never DFS-interleave children mid-sibling-list (that broke drop-in `-R`).
681    let mut ignore_by_dir: Vec<(PathBuf, IgnoreSet)> = Vec::new();
682    let mut plain: Vec<Entry> = Vec::with_capacity(built.len());
683
684    for (_w, maybe) in walked.iter().zip(built) {
685        let Some(e) = maybe else {
686            continue;
687        };
688        if ignored_by_sets(&e, opts, root_ignore.as_ref(), &mut ignore_by_dir) {
689            continue;
690        }
691        plain.push(e);
692    }
693
694    let t_sort = if collect_timing {
695        Some(Instant::now())
696    } else {
697        None
698    };
699
700    let entries = if opts.emit_dir_headers {
701        rebuild_gnu_recursive_sections(path, plain, opts)
702    } else {
703        // Tree path: walk order already name-sorted within each directory (WalkDir).
704        // Re-sort siblings only if a non-name primary sort was requested.
705        let mut entries = plain;
706        if !matches!(opts.sort_by, crate::options::SortBy::Name) || opts.reverse {
707            sort_tree_preorder(&mut entries, opts);
708        }
709        entries
710    };
711
712    if let Some(t0) = t_sort {
713        timing.sort_ms = t0.elapsed().as_millis();
714    }
715
716    let mut listing = Listing::new(path.to_path_buf(), true, entries)
717        .with_timing(if collect_timing { Some(timing) } else { None });
718    listing.minor_errors = minor_errors;
719    Ok(listing)
720}
721
722/// Re-sort a preorder tree listing by re-grouping siblings after a non-name sort.
723///
724/// Keeps a valid preorder: each directory’s children stay contiguous under it.
725fn sort_tree_preorder(entries: &mut [Entry], opts: &ListOptions) {
726    if entries.is_empty() {
727        return;
728    }
729    // Build groups by parent path; stable relative to walk.
730    // Simple approach: sort by (parent, sort_key) while preserving depth structure
731    // via full path component sort with custom comparator for the leaf name only.
732    // For size/time sorts, sort siblings that share the same parent.
733    let mut i = 0;
734    while i < entries.len() {
735        let depth = entries[i].depth;
736        // Find run of direct children of the same parent starting at i… actually
737        // at depth `depth`, a sibling run is contiguous until depth < depth.
738        let parent = entries[i].path.parent().map(Path::to_path_buf);
739        let mut j = i + 1;
740        while j < entries.len() {
741            if entries[j].depth < depth {
742                break;
743            }
744            if entries[j].depth == depth {
745                let p = entries[j].path.parent().map(Path::to_path_buf);
746                if p != parent {
747                    break;
748                }
749            }
750            j += 1;
751        }
752        // Within [i, j), extract sibling indices at exactly `depth`.
753        let sib: Vec<usize> = (i..j).filter(|&k| entries[k].depth == depth).collect();
754        if sib.len() > 1 {
755            // Sort sibling subtrees by moving whole subtree blocks.
756            // Build blocks: each sibling at depth owns [start, next_sibling).
757            let mut blocks: Vec<Vec<Entry>> = Vec::with_capacity(sib.len());
758            for (bi, &start) in sib.iter().enumerate() {
759                let end = sib.get(bi + 1).copied().unwrap_or(j);
760                blocks.push(entries[start..end].to_vec());
761            }
762            // cmp_entry already applies reverse when set.
763            blocks.sort_by(|a, b| crate::sort::cmp_entry(&a[0], &b[0], opts));
764            let mut out = Vec::with_capacity(j - i);
765            for b in blocks {
766                out.extend(b);
767            }
768            entries[i..j].clone_from_slice(&out);
769        }
770        i = if j > i { j } else { i + 1 };
771    }
772}
773
774/// Check root ignore set and the ignore file in the entry's parent directory.
775fn ignored_by_sets(
776    entry: &Entry,
777    opts: &ListOptions,
778    root_ignore: Option<&IgnoreSet>,
779    cache: &mut Vec<(PathBuf, IgnoreSet)>,
780) -> bool {
781    if !opts.use_ignore_files {
782        return false;
783    }
784    if let Some(root) = root_ignore {
785        if root.ignores(entry) {
786            return true;
787        }
788    }
789    let Some(parent) = entry.path.parent() else {
790        return false;
791    };
792    if let Some(root) = root_ignore {
793        if parent == root.base {
794            return false;
795        }
796    }
797    if let Some((_, set)) = cache.iter().find(|(p, _)| p == parent) {
798        return set.ignores(entry);
799    }
800    let set = load_ignore_set(parent);
801    let ignored = set.ignores(entry);
802    cache.push((parent.to_path_buf(), set));
803    ignored
804}
805
806/// Build GNU `ls -R` layout: `dir:` header, sorted immediate children, then recurse
807/// into each subdirectory in that same sorted order.
808fn rebuild_gnu_recursive_sections(
809    root: &Path,
810    plain: Vec<Entry>,
811    opts: &ListOptions,
812) -> Vec<Entry> {
813    use std::collections::HashMap;
814
815    let mut by_parent: HashMap<PathBuf, Vec<Entry>> = HashMap::new();
816    for e in plain {
817        let parent = e
818            .path
819            .parent()
820            .map(Path::to_path_buf)
821            .unwrap_or_else(|| root.to_path_buf());
822        by_parent.entry(parent).or_default().push(e);
823    }
824
825    let mut out = Vec::new();
826    fn emit(
827        dir: &Path,
828        depth: usize,
829        by_parent: &mut HashMap<PathBuf, Vec<Entry>>,
830        opts: &ListOptions,
831        out: &mut Vec<Entry>,
832    ) {
833        out.push(Entry::dir_header(dir, depth));
834        let mut kids = by_parent.remove(dir).unwrap_or_default();
835        sort_entries(&mut kids, opts);
836        let subdirs: Vec<PathBuf> = kids
837            .iter()
838            .filter(|e| e.kind == EntryKind::Directory)
839            .map(|e| e.path.clone())
840            .collect();
841        out.extend(kids);
842        for sub in subdirs {
843            emit(&sub, depth.saturating_add(1), by_parent, opts, out);
844        }
845    }
846    emit(root, 0, &mut by_parent, opts, &mut out);
847    out
848}
849
850/// Outcome of listing one or more path arguments.
851#[derive(Debug)]
852pub struct ListOutcome {
853    pub listings: Vec<Listing>,
854    /// Errors for command-line path arguments that could not be listed.
855    pub path_errors: Vec<Error>,
856    /// Sum of recoverable errors inside successful listings (e.g. unreadable subdirs).
857    pub minor_errors: usize,
858}
859
860impl ListOutcome {
861    /// GNU-aligned exit status: 0 ok, 1 minor problems, 2 serious (bad path args).
862    pub fn exit_code(&self) -> i32 {
863        if !self.path_errors.is_empty() {
864            2
865        } else if self.minor_errors > 0 || self.listings.iter().any(|l| l.minor_errors > 0) {
866            1
867        } else {
868            0
869        }
870    }
871
872    /// Aggregate phase timings across successful listings.
873    pub fn total_timing(&self) -> ListTiming {
874        let mut total = ListTiming::default();
875        for l in &self.listings {
876            if let Some(ref t) = l.timing {
877                total.add_assign(t);
878            }
879        }
880        total
881    }
882}
883
884/// List multiple paths, returning one Listing per successful path.
885///
886/// Failures on individual path arguments are collected (serious) rather than
887/// aborting the whole run, matching GNU `ls` multi-argument behavior.
888pub fn list_paths(paths: &[PathBuf], opts: &ListOptions) -> Result<Vec<Listing>> {
889    let outcome = list_paths_with_errors(paths, opts);
890    if let Some(err) = outcome.path_errors.into_iter().next() {
891        // Preserve previous fail-fast API for callers that expect `Result`.
892        return Err(err);
893    }
894    Ok(outcome.listings)
895}
896
897/// List paths, collecting per-argument errors instead of failing fast.
898pub fn list_paths_with_errors(paths: &[PathBuf], opts: &ListOptions) -> ListOutcome {
899    let owned: Vec<PathBuf>;
900    let paths: &[PathBuf] = if paths.is_empty() {
901        owned = vec![PathBuf::from(".")];
902        &owned
903    } else {
904        paths
905    };
906
907    let mut listings = Vec::with_capacity(paths.len());
908    let mut path_errors = Vec::new();
909    let mut minor_errors = 0usize;
910
911    for p in paths {
912        match list_path(p, opts) {
913            Ok(l) => {
914                minor_errors += l.minor_errors;
915                listings.push(l);
916            }
917            Err(e) => path_errors.push(e),
918        }
919    }
920
921    ListOutcome {
922        listings,
923        path_errors,
924        minor_errors,
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use std::fs;
932
933    fn temp_dir() -> PathBuf {
934        use std::sync::atomic::{AtomicU64, Ordering};
935        static N: AtomicU64 = AtomicU64::new(0);
936        let base = std::env::temp_dir().join(format!(
937            "f00-core-list-{}-{}-{}",
938            std::process::id(),
939            std::time::SystemTime::now()
940                .duration_since(std::time::UNIX_EPOCH)
941                .map(|d| d.as_nanos())
942                .unwrap_or(0),
943            N.fetch_add(1, Ordering::Relaxed)
944        ));
945        fs::create_dir_all(&base).unwrap();
946        base
947    }
948
949    #[test]
950    fn list_paths_with_errors_partial_success() {
951        let dir = temp_dir();
952        fs::write(dir.join("a.txt"), b"x").unwrap();
953        let missing = dir.join("gone");
954        let opts = ListOptions::default();
955        let outcome = list_paths_with_errors(&[dir.clone(), missing], &opts);
956        assert_eq!(outcome.listings.len(), 1);
957        assert_eq!(outcome.path_errors.len(), 1);
958        assert_eq!(outcome.exit_code(), 2);
959        let _ = fs::remove_dir_all(&dir);
960    }
961
962    #[test]
963    fn list_paths_ok_exit_0() {
964        let dir = temp_dir();
965        fs::write(dir.join("a.txt"), b"x").unwrap();
966        let opts = ListOptions::default();
967        let outcome = list_paths_with_errors(std::slice::from_ref(&dir), &opts);
968        assert!(outcome.path_errors.is_empty());
969        assert_eq!(outcome.exit_code(), 0);
970        let _ = fs::remove_dir_all(&dir);
971    }
972
973    #[test]
974    fn list_directory_honors_ignore_files() {
975        let dir = temp_dir();
976        fs::write(dir.join("keep.txt"), b"k").unwrap();
977        fs::write(dir.join("skip.o"), b"o").unwrap();
978        fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
979
980        let opts = ListOptions {
981            use_ignore_files: true,
982            ..Default::default()
983        };
984        let listing = list_directory(&dir, &opts).unwrap();
985        let names: Vec<_> = listing.entries.iter().map(|e| e.name.as_str()).collect();
986        assert!(names.contains(&"keep.txt"));
987        assert!(!names.contains(&"skip.o"));
988
989        let opts_off = ListOptions {
990            use_ignore_files: false,
991            ..Default::default()
992        };
993        let listing = list_directory(&dir, &opts_off).unwrap();
994        let names: Vec<_> = listing.entries.iter().map(|e| e.name.as_str()).collect();
995        assert!(names.contains(&"skip.o"));
996
997        let _ = fs::remove_dir_all(&dir);
998    }
999
1000    #[test]
1001    fn parallel_list_same_names_as_sequential() {
1002        let dir = temp_dir();
1003        // Well above PARALLEL_STAT_THRESHOLD so parallel path is taken.
1004        let mut expected = Vec::new();
1005        for i in 0..64 {
1006            let name = format!("file_{i:03}.txt");
1007            fs::write(dir.join(&name), b"x").unwrap();
1008            expected.push(name);
1009        }
1010        fs::create_dir(dir.join("subdir")).unwrap();
1011        expected.push("subdir".into());
1012        expected.sort();
1013
1014        let sequential = ListOptions {
1015            parallel: false,
1016            threads: 1,
1017            ..Default::default()
1018        };
1019        let seq = list_directory(&dir, &sequential).unwrap();
1020        let mut seq_names: Vec<_> = seq.entries.iter().map(|e| e.name.clone()).collect();
1021        seq_names.sort();
1022        assert_eq!(
1023            seq_names, expected,
1024            "listing must include every created entry"
1025        );
1026
1027        // Skip rayon parallel path on FreeBSD CI VMs (SIGSEGV under qemu).
1028        if !cfg!(target_os = "freebsd") {
1029            let parallel = ListOptions {
1030                parallel: true,
1031                threads: 0,
1032                ..Default::default()
1033            };
1034            let par = list_directory(&dir, &parallel).unwrap();
1035            let mut par_names: Vec<_> = par.entries.iter().map(|e| e.name.clone()).collect();
1036            par_names.sort();
1037            assert_eq!(
1038                seq_names, par_names,
1039                "parallel and sequential must produce identical ordered names"
1040            );
1041        }
1042
1043        let _ = fs::remove_dir_all(&dir);
1044    }
1045
1046    #[test]
1047    fn collect_timing_fills_phases() {
1048        let dir = temp_dir();
1049        for i in 0..8 {
1050            fs::write(dir.join(format!("f{i}")), b"x").unwrap();
1051        }
1052        let opts = ListOptions {
1053            collect_timing: true,
1054            parallel: false,
1055            threads: 1,
1056            ..Default::default()
1057        };
1058        let listing = list_directory(&dir, &opts).unwrap();
1059        let t = listing.timing.expect("timing present");
1060        // Just ensure fields are populated (may be 0ms on very fast FS).
1061        let _ = (t.readdir_ms, t.stat_ms, t.sort_ms);
1062        let _ = fs::remove_dir_all(&dir);
1063    }
1064
1065    #[test]
1066    fn threads_one_forces_serial_path() {
1067        let dir = temp_dir();
1068        for i in 0..40 {
1069            fs::write(dir.join(format!("n{i}")), b"x").unwrap();
1070        }
1071        let opts = ListOptions {
1072            parallel: true,
1073            threads: 1,
1074            ..Default::default()
1075        };
1076        assert!(!opts.use_parallel_stat(40));
1077        let listing = list_directory(&dir, &opts).unwrap();
1078        assert_eq!(listing.entries.len(), 40);
1079        let _ = fs::remove_dir_all(&dir);
1080    }
1081}