Skip to main content

f00_core/
list.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use rayon::prelude::*;
6use walkdir::WalkDir;
7
8use crate::entry::Entry;
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/// Basic recursive listing using walkdir.
317pub fn list_recursive(path: &Path, opts: &ListOptions) -> Result<Listing> {
318    let collect_timing = opts.collect_timing;
319    let mut timing = ListTiming::default();
320    let t_all = if collect_timing {
321        Some(Instant::now())
322    } else {
323        None
324    };
325
326    let mut entries = Vec::new();
327    let mut minor_errors = 0usize;
328    let max_depth = opts.max_depth.unwrap_or(usize::MAX);
329
330    let root_ignore = if opts.use_ignore_files {
331        Some(load_ignore_set(path))
332    } else {
333        None
334    };
335    let mut ignore_by_dir: Vec<(PathBuf, IgnoreSet)> = Vec::new();
336
337    let fill = meta_fill_from(opts);
338    // Group by parent directory: emit header then children for each dir.
339    // First, collect all walkdir results.
340    // Recursive walk stays sequential: headers and section order must be stable.
341    // (Parallelism lives in non-recursive `list_directory` where readdir is flat.)
342    let walker = WalkDir::new(path)
343        .follow_links(opts.follow_links)
344        .max_depth(max_depth)
345        .sort_by_file_name();
346
347    // Track which directories we've seen to emit headers.
348    let mut current_dir: Option<PathBuf> = None;
349
350    for item in walker {
351        let item = match item {
352            Ok(i) => i,
353            Err(_) => {
354                minor_errors += 1;
355                continue;
356            }
357        };
358
359        let depth = item.depth();
360        let entry_path = item.path();
361
362        // Skip the root itself as an entry; we'll list its children under a header if needed.
363        if depth == 0 {
364            current_dir = Some(entry_path.to_path_buf());
365            // Emit header for root
366            entries.push(Entry::dir_header(entry_path, 0));
367            continue;
368        }
369
370        let parent = entry_path.parent().map(|p| p.to_path_buf());
371        if let Some(ref p) = parent {
372            if current_dir.as_ref() != Some(p) {
373                // New directory section
374                current_dir = Some(p.clone());
375            }
376        }
377
378        // When we visit a directory (depth > 0), emit a header before its children.
379        // Walkdir yields the directory node first.
380        if item.file_type().is_dir() && depth > 0 {
381            if let Ok(meta) = item.metadata() {
382                if let Ok(e) = Entry::from_path_and_meta_with(entry_path, &meta, depth, fill) {
383                    let show = crate::filter::should_show(&e, opts)
384                        && !ignored_by_sets(&e, opts, root_ignore.as_ref(), &mut ignore_by_dir);
385                    if show {
386                        entries.push(e);
387                    }
388                }
389            }
390            entries.push(Entry::dir_header(entry_path, depth));
391            continue;
392        }
393
394        let meta = match item.metadata() {
395            Ok(m) => m,
396            Err(_) => continue,
397        };
398        if let Ok(e) = Entry::from_path_and_meta_with(entry_path, &meta, depth, fill) {
399            if crate::filter::should_show(&e, opts)
400                && !ignored_by_sets(&e, opts, root_ignore.as_ref(), &mut ignore_by_dir)
401            {
402                entries.push(e);
403            }
404        }
405    }
406
407    if let Some(t0) = t_all {
408        // Attribute walk+stat wall time primarily to readdir/stat combined.
409        let ms = t0.elapsed().as_millis();
410        timing.readdir_ms = ms / 2;
411        timing.stat_ms = ms.saturating_sub(timing.readdir_ms);
412    }
413
414    let t_sort = if collect_timing {
415        Some(Instant::now())
416    } else {
417        None
418    };
419
420    // For recursive mode, sort within sections delimited by headers.
421    sort_recursive_sections(&mut entries, opts);
422
423    if let Some(t0) = t_sort {
424        timing.sort_ms = t0.elapsed().as_millis();
425    }
426
427    let mut listing = Listing::new(path.to_path_buf(), true, entries)
428        .with_timing(if collect_timing { Some(timing) } else { None });
429    listing.minor_errors = minor_errors;
430    Ok(listing)
431}
432
433/// Check root ignore set and the ignore file in the entry's parent directory.
434fn ignored_by_sets(
435    entry: &Entry,
436    opts: &ListOptions,
437    root_ignore: Option<&IgnoreSet>,
438    cache: &mut Vec<(PathBuf, IgnoreSet)>,
439) -> bool {
440    if !opts.use_ignore_files {
441        return false;
442    }
443    if let Some(root) = root_ignore {
444        if root.ignores(entry) {
445            return true;
446        }
447    }
448    let Some(parent) = entry.path.parent() else {
449        return false;
450    };
451    if let Some(root) = root_ignore {
452        if parent == root.base {
453            return false;
454        }
455    }
456    if let Some((_, set)) = cache.iter().find(|(p, _)| p == parent) {
457        return set.ignores(entry);
458    }
459    let set = load_ignore_set(parent);
460    let ignored = set.ignores(entry);
461    cache.push((parent.to_path_buf(), set));
462    ignored
463}
464
465fn sort_recursive_sections(entries: &mut [Entry], opts: &ListOptions) {
466    let mut start = 0;
467    while start < entries.len() {
468        // Find next header after start
469        if entries[start].is_dir_header {
470            let section_start = start + 1;
471            let mut end = section_start;
472            while end < entries.len() && !entries[end].is_dir_header {
473                end += 1;
474            }
475            sort_entries(&mut entries[section_start..end], opts);
476            start = end;
477        } else {
478            // Leading non-header run
479            let mut end = start;
480            while end < entries.len() && !entries[end].is_dir_header {
481                end += 1;
482            }
483            sort_entries(&mut entries[start..end], opts);
484            start = end;
485        }
486    }
487}
488
489/// Outcome of listing one or more path arguments.
490#[derive(Debug)]
491pub struct ListOutcome {
492    pub listings: Vec<Listing>,
493    /// Errors for command-line path arguments that could not be listed.
494    pub path_errors: Vec<Error>,
495    /// Sum of recoverable errors inside successful listings (e.g. unreadable subdirs).
496    pub minor_errors: usize,
497}
498
499impl ListOutcome {
500    /// GNU-aligned exit status: 0 ok, 1 minor problems, 2 serious (bad path args).
501    pub fn exit_code(&self) -> i32 {
502        if !self.path_errors.is_empty() {
503            2
504        } else if self.minor_errors > 0 || self.listings.iter().any(|l| l.minor_errors > 0) {
505            1
506        } else {
507            0
508        }
509    }
510
511    /// Aggregate phase timings across successful listings.
512    pub fn total_timing(&self) -> ListTiming {
513        let mut total = ListTiming::default();
514        for l in &self.listings {
515            if let Some(ref t) = l.timing {
516                total.add_assign(t);
517            }
518        }
519        total
520    }
521}
522
523/// List multiple paths, returning one Listing per successful path.
524///
525/// Failures on individual path arguments are collected (serious) rather than
526/// aborting the whole run, matching GNU `ls` multi-argument behavior.
527pub fn list_paths(paths: &[PathBuf], opts: &ListOptions) -> Result<Vec<Listing>> {
528    let outcome = list_paths_with_errors(paths, opts);
529    if let Some(err) = outcome.path_errors.into_iter().next() {
530        // Preserve previous fail-fast API for callers that expect `Result`.
531        return Err(err);
532    }
533    Ok(outcome.listings)
534}
535
536/// List paths, collecting per-argument errors instead of failing fast.
537pub fn list_paths_with_errors(paths: &[PathBuf], opts: &ListOptions) -> ListOutcome {
538    let owned: Vec<PathBuf>;
539    let paths: &[PathBuf] = if paths.is_empty() {
540        owned = vec![PathBuf::from(".")];
541        &owned
542    } else {
543        paths
544    };
545
546    let mut listings = Vec::with_capacity(paths.len());
547    let mut path_errors = Vec::new();
548    let mut minor_errors = 0usize;
549
550    for p in paths {
551        match list_path(p, opts) {
552            Ok(l) => {
553                minor_errors += l.minor_errors;
554                listings.push(l);
555            }
556            Err(e) => path_errors.push(e),
557        }
558    }
559
560    ListOutcome {
561        listings,
562        path_errors,
563        minor_errors,
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use std::fs;
571
572    fn temp_dir() -> PathBuf {
573        use std::sync::atomic::{AtomicU64, Ordering};
574        static N: AtomicU64 = AtomicU64::new(0);
575        let base = std::env::temp_dir().join(format!(
576            "f00-core-list-{}-{}-{}",
577            std::process::id(),
578            std::time::SystemTime::now()
579                .duration_since(std::time::UNIX_EPOCH)
580                .map(|d| d.as_nanos())
581                .unwrap_or(0),
582            N.fetch_add(1, Ordering::Relaxed)
583        ));
584        fs::create_dir_all(&base).unwrap();
585        base
586    }
587
588    #[test]
589    fn list_paths_with_errors_partial_success() {
590        let dir = temp_dir();
591        fs::write(dir.join("a.txt"), b"x").unwrap();
592        let missing = dir.join("gone");
593        let opts = ListOptions::default();
594        let outcome = list_paths_with_errors(&[dir.clone(), missing], &opts);
595        assert_eq!(outcome.listings.len(), 1);
596        assert_eq!(outcome.path_errors.len(), 1);
597        assert_eq!(outcome.exit_code(), 2);
598        let _ = fs::remove_dir_all(&dir);
599    }
600
601    #[test]
602    fn list_paths_ok_exit_0() {
603        let dir = temp_dir();
604        fs::write(dir.join("a.txt"), b"x").unwrap();
605        let opts = ListOptions::default();
606        let outcome = list_paths_with_errors(std::slice::from_ref(&dir), &opts);
607        assert!(outcome.path_errors.is_empty());
608        assert_eq!(outcome.exit_code(), 0);
609        let _ = fs::remove_dir_all(&dir);
610    }
611
612    #[test]
613    fn list_directory_honors_ignore_files() {
614        let dir = temp_dir();
615        fs::write(dir.join("keep.txt"), b"k").unwrap();
616        fs::write(dir.join("skip.o"), b"o").unwrap();
617        fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
618
619        let opts = ListOptions {
620            use_ignore_files: true,
621            ..Default::default()
622        };
623        let listing = list_directory(&dir, &opts).unwrap();
624        let names: Vec<_> = listing.entries.iter().map(|e| e.name.as_str()).collect();
625        assert!(names.contains(&"keep.txt"));
626        assert!(!names.contains(&"skip.o"));
627
628        let opts_off = ListOptions {
629            use_ignore_files: false,
630            ..Default::default()
631        };
632        let listing = list_directory(&dir, &opts_off).unwrap();
633        let names: Vec<_> = listing.entries.iter().map(|e| e.name.as_str()).collect();
634        assert!(names.contains(&"skip.o"));
635
636        let _ = fs::remove_dir_all(&dir);
637    }
638
639    #[test]
640    fn parallel_list_same_names_as_sequential() {
641        let dir = temp_dir();
642        // Well above PARALLEL_STAT_THRESHOLD so parallel path is taken.
643        let mut expected = Vec::new();
644        for i in 0..64 {
645            let name = format!("file_{i:03}.txt");
646            fs::write(dir.join(&name), b"x").unwrap();
647            expected.push(name);
648        }
649        fs::create_dir(dir.join("subdir")).unwrap();
650        expected.push("subdir".into());
651        expected.sort();
652
653        let sequential = ListOptions {
654            parallel: false,
655            threads: 1,
656            ..Default::default()
657        };
658        let seq = list_directory(&dir, &sequential).unwrap();
659        let mut seq_names: Vec<_> = seq.entries.iter().map(|e| e.name.clone()).collect();
660        seq_names.sort();
661        assert_eq!(
662            seq_names, expected,
663            "listing must include every created entry"
664        );
665
666        // Skip rayon parallel path on FreeBSD CI VMs (SIGSEGV under qemu).
667        if !cfg!(target_os = "freebsd") {
668            let parallel = ListOptions {
669                parallel: true,
670                threads: 0,
671                ..Default::default()
672            };
673            let par = list_directory(&dir, &parallel).unwrap();
674            let mut par_names: Vec<_> = par.entries.iter().map(|e| e.name.clone()).collect();
675            par_names.sort();
676            assert_eq!(
677                seq_names, par_names,
678                "parallel and sequential must produce identical ordered names"
679            );
680        }
681
682        let _ = fs::remove_dir_all(&dir);
683    }
684
685    #[test]
686    fn collect_timing_fills_phases() {
687        let dir = temp_dir();
688        for i in 0..8 {
689            fs::write(dir.join(format!("f{i}")), b"x").unwrap();
690        }
691        let opts = ListOptions {
692            collect_timing: true,
693            parallel: false,
694            threads: 1,
695            ..Default::default()
696        };
697        let listing = list_directory(&dir, &opts).unwrap();
698        let t = listing.timing.expect("timing present");
699        // Just ensure fields are populated (may be 0ms on very fast FS).
700        let _ = (t.readdir_ms, t.stat_ms, t.sort_ms);
701        let _ = fs::remove_dir_all(&dir);
702    }
703
704    #[test]
705    fn threads_one_forces_serial_path() {
706        let dir = temp_dir();
707        for i in 0..40 {
708            fs::write(dir.join(format!("n{i}")), b"x").unwrap();
709        }
710        let opts = ListOptions {
711            parallel: true,
712            threads: 1,
713            ..Default::default()
714        };
715        assert!(!opts.use_parallel_stat(40));
716        let listing = list_directory(&dir, &opts).unwrap();
717        assert_eq!(listing.entries.len(), 40);
718        let _ = fs::remove_dir_all(&dir);
719    }
720}