Skip to main content

gravityfile_scan/
scanner.rs

1//! Parallel directory scanner built on jwalk.
2//!
3//! Metadata is read on jwalk's worker threads (`process_read_dir`), so the
4//! single consumer thread makes no per-entry system calls. jwalk yields
5//! entries in depth-first pre-order, which lets the consumer build the tree
6//! directly with a stack of open directories.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Instant, SystemTime};
11
12#[cfg(unix)]
13use std::os::unix::fs::MetadataExt;
14
15use jwalk::{Parallelism, WalkDirGeneric};
16use tokio::sync::broadcast;
17use tokio_util::sync::CancellationToken;
18
19use gravityfile_core::{
20    FileNode, FileTree, InodeInfo, NodeKind, ScanConfig, ScanError, ScanWarning, Timestamps,
21    TreeStats, WarningKind,
22};
23
24use crate::inode::InodeTracker;
25use crate::progress::ScanProgress;
26
27/// Entries between progress messages.
28const PROGRESS_INTERVAL: u64 = 1024;
29
30/// Charge yielded descendants before retention.
31struct EntryBudget {
32    limit: Option<u64>,
33    consumed: u64,
34}
35
36impl EntryBudget {
37    fn new(limit: Option<u64>) -> Self {
38        Self { limit, consumed: 0 }
39    }
40
41    fn consume(&mut self) -> Result<(), ScanError> {
42        if let Some(limit) = self.limit
43            && self.consumed >= limit
44        {
45            return Err(ScanError::EntryLimitExceeded { limit });
46        }
47        self.consumed = self.consumed.saturating_add(1);
48        Ok(())
49    }
50}
51
52/// Retain bounded warning detail without misreporting scan completeness.
53#[derive(Default)]
54struct WarningAccumulator {
55    details: Vec<ScanWarning>,
56    count: u64,
57    omitted: u64,
58}
59
60impl WarningAccumulator {
61    fn push(&mut self, warning: ScanWarning) {
62        let represented = warning.represented_count();
63        self.count = self.count.saturating_add(represented);
64        let bytes = warning
65            .path
66            .as_os_str()
67            .as_encoded_bytes()
68            .len()
69            .saturating_add(warning.message.len());
70        if self.details.len() < 999
71            && bytes <= 8 * 1024
72            && !matches!(warning.kind, WarningKind::Omitted { .. })
73        {
74            self.details.push(warning);
75        } else {
76            self.omitted = self.omitted.saturating_add(represented);
77        }
78    }
79
80    fn into_vec(mut self) -> Vec<ScanWarning> {
81        if self.omitted > 0 {
82            self.details.push(ScanWarning::new(
83                PathBuf::new(),
84                WarningKind::Omitted {
85                    count: self.omitted,
86                },
87                format!(
88                    "{} scan warnings omitted from retained detail (999-record / 8 KiB payload limits)",
89                    self.omitted
90                ),
91            ));
92        }
93        self.details
94    }
95}
96
97/// Append enrichment diagnostics without bypassing scanner warning limits.
98pub(crate) fn append_scan_warning(tree: &mut FileTree, warning: ScanWarning) {
99    let mut warnings = WarningAccumulator::default();
100    for retained in std::mem::take(&mut tree.warnings) {
101        warnings.push(retained);
102    }
103    warnings.push(warning);
104    tree.warnings = warnings.into_vec();
105}
106
107/// What a worker thread learned about one entry.
108#[derive(Debug, Default)]
109enum Probe {
110    #[default]
111    Unprobed,
112    Entry(Entry),
113    Failed(std::io::Error),
114}
115
116type Client = ((), Probe);
117
118#[derive(Debug)]
119struct Entry {
120    kind: EntryKind,
121    len: u64,
122    blocks: u64,
123    nlink: u64,
124    identity: Option<InodeInfo>,
125    modified: Option<SystemTime>,
126}
127
128#[derive(Debug)]
129enum EntryKind {
130    File { executable: bool },
131    Dir,
132    Symlink { target: Box<str>, broken: bool },
133    Other,
134}
135
136impl Entry {
137    /// `path` is only consulted for symlinks, to read their target.
138    fn from_metadata(metadata: &std::fs::Metadata, path: impl FnOnce() -> PathBuf) -> Self {
139        let file_type = metadata.file_type();
140        let kind = if file_type.is_dir() {
141            EntryKind::Dir
142        } else if file_type.is_file() {
143            EntryKind::File {
144                executable: is_executable(metadata),
145            }
146        } else if file_type.is_symlink() {
147            let (target, broken) = resolve_symlink(&path());
148            EntryKind::Symlink { target, broken }
149        } else {
150            EntryKind::Other
151        };
152        Self {
153            kind,
154            len: metadata.len(),
155            blocks: get_blocks(metadata),
156            nlink: get_nlink(metadata),
157            identity: inode_info(metadata),
158            modified: metadata.modified().ok(),
159        }
160    }
161
162    /// Display timestamps; an unreadable mtime shows as the Unix epoch.
163    fn timestamps(&self) -> Timestamps {
164        Timestamps::with_modified(self.modified.unwrap_or(std::time::UNIX_EPOCH))
165    }
166
167    /// Node for this entry; directories start empty and are sealed later.
168    fn node(&self, name: &std::ffi::OsStr, size: u64) -> FileNode {
169        let timestamps = self.timestamps();
170        let mut node = match &self.kind {
171            EntryKind::Dir => FileNode::new_directory("", timestamps),
172            EntryKind::File { executable } => {
173                FileNode::new_file("", size, self.blocks, timestamps, *executable)
174            }
175            EntryKind::Symlink { target, broken } => {
176                FileNode::new_symlink("", target.clone(), *broken, timestamps)
177            }
178            EntryKind::Other => {
179                let mut node = FileNode::new_file("", size, self.blocks, timestamps, false);
180                node.kind = NodeKind::Other;
181                node
182            }
183        };
184        node.set_file_name(name);
185        node.size = size;
186        node.len = self.len;
187        node.blocks = self.blocks;
188        node.inode = self.identity.filter(|_| self.nlink > 1 && !self.is_dir());
189        node
190    }
191
192    fn is_dir(&self) -> bool {
193        matches!(self.kind, EntryKind::Dir)
194    }
195
196    /// Size this entry contributes, counting each multiply-linked file (and,
197    /// when following symlinks, each aliased object) once.
198    fn counted_size(&self, config: &ScanConfig, tracker: &mut InodeTracker) -> u64 {
199        if matches!(self.kind, EntryKind::Symlink { .. }) {
200            return 0;
201        }
202        // Same measure as `FileNode::own_size`, so a scan and a remeasure agree.
203        let size = if config.apparent_size {
204            self.len
205        } else {
206            self.blocks.saturating_mul(512)
207        };
208        // Directory link counts reflect subdirectories, not aliases; they can
209        // only be reached twice by following symlinks.
210        let dedup = !config.count_hard_links && (!self.is_dir() || config.follow_symlinks);
211        match self.identity {
212            Some(identity) if dedup && !tracker.track(identity, self.nlink) => 0,
213            _ => size,
214        }
215    }
216}
217
218/// A directory whose contents are still arriving.
219struct Frame {
220    /// `None` below the retained depth: counted, but not kept in the tree.
221    node: Option<FileNode>,
222    size: u64,
223    files: u64,
224    dirs: u64,
225}
226
227impl Frame {
228    fn new(node: Option<FileNode>, own_size: u64) -> Self {
229        Self {
230            node,
231            size: own_size,
232            files: 0,
233            dirs: 0,
234        }
235    }
236
237    fn add_leaf(&mut self, size: u64, node: Option<FileNode>) {
238        self.size = self.size.saturating_add(size);
239        self.files += 1;
240        if let (Some(parent), Some(node)) = (&mut self.node, node) {
241            parent.children.push(node);
242        }
243    }
244
245    /// Seal the directory: final size, counts and size-ordered children.
246    fn finish(self) -> Option<FileNode> {
247        let mut node = self.node?;
248        node.size = self.size;
249        node.kind = NodeKind::Directory {
250            file_count: self.files,
251            dir_count: self.dirs,
252        };
253        node.children
254            .sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.compare_name(b)));
255        Some(node)
256    }
257}
258
259/// Close the innermost open directory into its parent.
260fn close_frame(stack: &mut Vec<Frame>) {
261    let frame = stack.pop().expect("open directory");
262    let parent = stack.last_mut().expect("parent directory");
263    parent.size = parent.size.saturating_add(frame.size);
264    parent.files += frame.files;
265    parent.dirs += frame.dirs + 1;
266    if let Some(node) = frame.finish()
267        && let Some(parent) = &mut parent.node
268    {
269        parent.children.push(node);
270    }
271}
272
273/// High-performance scanner using jwalk for parallel traversal.
274pub struct JwalkScanner {
275    progress_tx: broadcast::Sender<ScanProgress>,
276}
277
278impl JwalkScanner {
279    /// Create a new scanner.
280    pub fn new() -> Self {
281        let (progress_tx, _) = broadcast::channel(100);
282        Self { progress_tx }
283    }
284
285    /// Subscribe to scan progress updates.
286    pub fn subscribe(&self) -> broadcast::Receiver<ScanProgress> {
287        self.progress_tx.subscribe()
288    }
289
290    /// Scan `config.root` into a tree.
291    ///
292    /// Sizes cover the whole subtree even when `max_depth` limits which nodes
293    /// are retained. Hard links count once unless `count_hard_links` is set,
294    /// and directories include their own allocation.
295    pub fn scan(
296        &self,
297        config: &ScanConfig,
298        token: CancellationToken,
299    ) -> Result<FileTree, ScanError> {
300        if token.is_cancelled() {
301            return Err(ScanError::Interrupted);
302        }
303        config
304            .validate()
305            .map_err(|message| ScanError::InvalidConfig { message })?;
306        let start = Instant::now();
307        let root_path =
308            dunce::canonicalize(&config.root).map_err(|e| ScanError::io(&config.root, e))?;
309        let root_metadata =
310            std::fs::metadata(&root_path).map_err(|e| ScanError::io(&root_path, e))?;
311        if !root_metadata.is_dir() {
312            return Err(ScanError::NotADirectory { path: root_path });
313        }
314
315        let mut tracker = if config.follow_symlinks {
316            InodeTracker::with_alias_tracking()
317        } else {
318            InodeTracker::new()
319        };
320        let mut stats = TreeStats::new();
321        let mut warnings = WarningAccumulator::default();
322        let mut budget = EntryBudget::new(config.max_entries);
323        let retained_depth = config.max_depth.map_or(usize::MAX, |depth| depth as usize);
324
325        let root_entry = Entry::from_metadata(&root_metadata, PathBuf::new);
326        let root_size = root_entry.counted_size(config, &mut tracker);
327        let root_node = root_entry.node(
328            root_path.file_name().unwrap_or(root_path.as_os_str()),
329            root_size,
330        );
331        stats.record_dir(0);
332        let mut stack = vec![Frame::new(Some(root_node), root_size)];
333
334        let walker = self.walker(config, &root_path, get_dev(&root_metadata), token.clone())?;
335        let mut seen: u64 = 0;
336        for item in walker {
337            if token.is_cancelled() {
338                return Err(ScanError::Interrupted);
339            }
340            let mut entry = match item {
341                Ok(entry) => entry,
342                Err(error) => {
343                    warnings.push(walk_warning(&error));
344                    continue;
345                }
346            };
347            let depth = entry.depth;
348            if depth == 0 {
349                continue;
350            }
351            budget.consume()?;
352            while stack.len() > depth {
353                close_frame(&mut stack);
354            }
355            if stack.len() < depth {
356                // Below a directory that was not recorded.
357                continue;
358            }
359            let fact = match std::mem::take(&mut entry.client_state) {
360                Probe::Entry(fact) => fact,
361                Probe::Failed(error) => {
362                    warnings.push(metadata_warning(entry.path(), &error));
363                    continue;
364                }
365                Probe::Unprobed => continue,
366            };
367            let size = fact.counted_size(config, &mut tracker);
368            let node = (depth <= retained_depth).then(|| fact.node(&entry.file_name, size));
369            match &fact.kind {
370                EntryKind::Dir => {
371                    stats.record_dir(depth as u32);
372                    // jwalk attaches a failed read_dir to the directory entry
373                    // rather than yielding it as an error item.
374                    if let Some(error) = entry.read_children.as_ref().and_then(|c| c.error()) {
375                        warnings.push(walk_warning(error));
376                    }
377                    stack.push(Frame::new(node, size));
378                }
379                EntryKind::File { .. } => {
380                    stats.record_file_with(size, fact.modified, depth as u32, || entry.path());
381                    stack
382                        .last_mut()
383                        .expect("open directory")
384                        .add_leaf(size, node);
385                }
386                EntryKind::Symlink { target, broken } => {
387                    stats.record_symlink();
388                    if *broken {
389                        warnings.push(ScanWarning::broken_symlink(entry.path(), target));
390                    }
391                    stack.last_mut().expect("open directory").add_leaf(0, node);
392                }
393                EntryKind::Other => {
394                    stack
395                        .last_mut()
396                        .expect("open directory")
397                        .add_leaf(size, node);
398                }
399            }
400            seen += 1;
401            if seen.is_multiple_of(PROGRESS_INTERVAL) {
402                let _ = self.progress_tx.send(ScanProgress {
403                    files_scanned: stats.total_files,
404                    dirs_scanned: stats.total_dirs,
405                    bytes_scanned: stats.total_size,
406                    current_path: entry.path(),
407                    errors_count: warnings.count,
408                    elapsed: start.elapsed(),
409                });
410            }
411        }
412        // Cancellation may clear the walker's last batch without yielding
413        // another entry, so the per-entry check alone is insufficient.
414        if token.is_cancelled() {
415            return Err(ScanError::Interrupted);
416        }
417        while stack.len() > 1 {
418            close_frame(&mut stack);
419        }
420        let root = stack
421            .pop()
422            .and_then(Frame::finish)
423            .expect("root is always retained");
424        let mut tree = FileTree::new(
425            root,
426            root_path,
427            config.clone(),
428            stats,
429            start.elapsed(),
430            warnings.into_vec(),
431        );
432
433        #[cfg(feature = "git")]
434        if config.git_status {
435            crate::git::apply_git_status_cancellable(&mut tree, &token);
436            if token.is_cancelled() {
437                return Err(ScanError::Interrupted);
438            }
439        }
440        // Enrichment is part of completing the scan.
441        tree.scan_duration = start.elapsed();
442        tree.scanned_at = SystemTime::now();
443        Ok(tree)
444    }
445
446    /// Configure the parallel walk: filtering, filesystem pruning and
447    /// metadata probing all run on jwalk's worker threads.
448    fn walker(
449        &self,
450        config: &ScanConfig,
451        root_path: &Path,
452        root_device: u64,
453        token: CancellationToken,
454    ) -> Result<WalkDirGeneric<Client>, ScanError> {
455        let parallelism = match config.threads {
456            0 => Parallelism::RayonExistingPool {
457                pool: scan_pool(),
458                // The consumer never runs on this pool, so it cannot deadlock.
459                busy_timeout: None,
460            },
461            n => Parallelism::RayonNewPool(n),
462        };
463        let ignore: Option<Arc<globset::GlobSet>> = config
464            .effective_ignore_globset()
465            .map_err(|message| ScanError::InvalidConfig { message })?
466            .map(Arc::new);
467        let cross_filesystems = config.cross_filesystems;
468        let filter_root = root_path.to_path_buf();
469
470        Ok(WalkDirGeneric::<Client>::new(root_path)
471            .parallelism(parallelism)
472            .skip_hidden(!config.include_hidden)
473            .follow_links(config.follow_symlinks)
474            .min_depth(0)
475            .process_read_dir(move |_depth, _dir, _state, children| {
476                if token.is_cancelled() {
477                    children.clear();
478                    return;
479                }
480                children.retain_mut(|child| {
481                    let Ok(entry) = child else {
482                        return true; // surfaces as a warning
483                    };
484                    if let Some(ignore) = &ignore
485                        && (ignore.is_match(&entry.file_name)
486                            || entry
487                                .path()
488                                .strip_prefix(&filter_root)
489                                .is_ok_and(|relative| ignore.is_match(relative)))
490                    {
491                        return false;
492                    }
493                    let metadata = match entry.metadata() {
494                        Ok(metadata) => metadata,
495                        Err(error) => {
496                            let error = error
497                                .into_io_error()
498                                .unwrap_or_else(|| std::io::Error::other("metadata unavailable"));
499                            entry.client_state = Probe::Failed(error);
500                            entry.read_children = None;
501                            return true;
502                        }
503                    };
504                    if !cross_filesystems && get_dev(&metadata) != root_device {
505                        // Another filesystem: neither counted nor descended.
506                        entry.read_children = None;
507                        return false;
508                    }
509                    entry.client_state =
510                        Probe::Entry(Entry::from_metadata(&metadata, || entry.path()));
511                    true
512                });
513            }))
514    }
515}
516
517impl Default for JwalkScanner {
518    fn default() -> Self {
519        Self::new()
520    }
521}
522
523fn walk_warning(error: &jwalk::Error) -> ScanWarning {
524    let path = error.path().map(Path::to_path_buf).unwrap_or_default();
525    let kind = match error.io_error().map(std::io::Error::kind) {
526        Some(std::io::ErrorKind::PermissionDenied) => WarningKind::PermissionDenied,
527        _ => WarningKind::ReadError,
528    };
529    ScanWarning::new(path, kind, error.to_string())
530}
531
532fn metadata_warning(path: PathBuf, error: &std::io::Error) -> ScanWarning {
533    let kind = if error.kind() == std::io::ErrorKind::PermissionDenied {
534        WarningKind::PermissionDenied
535    } else {
536        WarningKind::MetadataError
537    };
538    ScanWarning::new(path, kind, error.to_string())
539}
540
541/// An entry scanned on its own, for grafting into an existing tree.
542#[derive(Debug)]
543pub struct ScannedEntry {
544    pub node: FileNode,
545    pub warnings: Vec<ScanWarning>,
546}
547
548/// Scan the entry at `path` as `config` measures it: a directory with
549/// everything below it, or anything else on its own. `None` if `path` no
550/// longer exists. `config.root` is ignored.
551///
552/// Hard links are counted once within the entry; links shared with the rest
553/// of a tree it is grafted into are not known here.
554pub fn scan_entry(
555    path: &Path,
556    config: &ScanConfig,
557    token: CancellationToken,
558) -> Result<Option<ScannedEntry>, ScanError> {
559    let metadata = if config.follow_symlinks {
560        std::fs::metadata(path)
561    } else {
562        std::fs::symlink_metadata(path)
563    };
564    let metadata = match metadata {
565        Ok(metadata) => metadata,
566        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
567        Err(error) => return Err(ScanError::io(path, error)),
568    };
569    let name = path.file_name().unwrap_or(path.as_os_str());
570    if metadata.is_dir() {
571        let mut subtree = config.clone();
572        subtree.root = path.to_path_buf();
573        let FileTree {
574            mut root, warnings, ..
575        } = JwalkScanner::new().scan(&subtree, token)?;
576        root.set_file_name(name);
577        return Ok(Some(ScannedEntry {
578            node: root,
579            warnings,
580        }));
581    }
582    let entry = Entry::from_metadata(&metadata, || path.to_path_buf());
583    let size = entry.counted_size(config, &mut InodeTracker::new());
584    Ok(Some(ScannedEntry {
585        node: entry.node(name, size),
586        warnings: Vec::new(),
587    }))
588}
589
590/// Create a quick, non-recursive directory listing for immediate display.
591/// This function reads only the immediate children of a directory without
592/// recursing into subdirectories. Directory sizes cover only their own
593/// allocation until a full scan.
594///
595/// Warnings encountered during the listing are included in the returned
596/// `FileTree` rather than being silently dropped.
597///
598/// The `config` parameter controls whether hidden files are shown. Pass
599/// `None` to use a default config (hidden files included).
600pub fn quick_list(path: &Path, config: Option<&ScanConfig>) -> Result<FileTree, ScanError> {
601    let start = Instant::now();
602    let root_path = dunce::canonicalize(path).map_err(|e| ScanError::io(path, e))?;
603    let root_metadata = std::fs::metadata(&root_path).map_err(|e| ScanError::io(&root_path, e))?;
604    if !root_metadata.is_dir() {
605        return Err(ScanError::NotADirectory { path: root_path });
606    }
607
608    let owned_config;
609    let cfg: &ScanConfig = match config {
610        Some(c) => c,
611        None => {
612            owned_config = ScanConfig::new(&root_path);
613            &owned_config
614        }
615    };
616    cfg.validate()
617        .map_err(|message| ScanError::InvalidConfig { message })?;
618    let ignore = cfg
619        .effective_ignore_globset()
620        .map_err(|message| ScanError::InvalidConfig { message })?;
621
622    let mut stats = TreeStats::new();
623    let mut warnings = WarningAccumulator::default();
624    let mut budget = EntryBudget::new(cfg.max_entries);
625    let mut tracker = if cfg.follow_symlinks {
626        InodeTracker::with_alias_tracking()
627    } else {
628        InodeTracker::new()
629    };
630    let root_entry = Entry::from_metadata(&root_metadata, PathBuf::new);
631    let root_size = root_entry.counted_size(cfg, &mut tracker);
632    let root_node = root_entry.node(
633        root_path.file_name().unwrap_or(root_path.as_os_str()),
634        root_size,
635    );
636    let mut frame = Frame::new(Some(root_node), root_size);
637    stats.record_dir(0);
638
639    let read_dir = std::fs::read_dir(&root_path).map_err(|e| ScanError::io(&root_path, e))?;
640    for item in read_dir {
641        let entry = match item {
642            Ok(entry) => entry,
643            Err(error) => {
644                warnings.push(ScanWarning::read_error(root_path.clone(), &error));
645                continue;
646            }
647        };
648        let entry_path = entry.path();
649        let file_name = entry.file_name();
650        if !cfg.include_hidden && file_name.as_encoded_bytes().starts_with(b".") {
651            continue;
652        }
653        if let Some(ignore) = &ignore
654            && (ignore.is_match(&file_name)
655                || entry_path
656                    .strip_prefix(&root_path)
657                    .is_ok_and(|relative| ignore.is_match(relative)))
658        {
659            continue;
660        }
661        budget.consume()?;
662        let metadata = if cfg.follow_symlinks {
663            std::fs::metadata(&entry_path)
664        } else {
665            entry.metadata()
666        };
667        let metadata = match metadata {
668            Ok(metadata) => metadata,
669            Err(error) => {
670                warnings.push(metadata_warning(entry_path, &error));
671                continue;
672            }
673        };
674        if !cfg.cross_filesystems && get_dev(&metadata) != get_dev(&root_metadata) {
675            continue;
676        }
677        let fact = Entry::from_metadata(&metadata, || entry_path.clone());
678        let size = fact.counted_size(cfg, &mut tracker);
679        let node = fact.node(&file_name, size);
680        match &fact.kind {
681            EntryKind::Dir => {
682                stats.record_dir(1);
683                frame.dirs += 1;
684                // Contents unknown until a full scan: own allocation only.
685                frame.size = frame.size.saturating_add(size);
686                if let Some(root) = &mut frame.node {
687                    root.children.push(node);
688                }
689            }
690            EntryKind::File { .. } => {
691                stats.record_file(&entry_path, size, fact.modified, 1);
692                frame.add_leaf(size, Some(node));
693            }
694            EntryKind::Symlink { target, broken } => {
695                stats.record_symlink();
696                if *broken {
697                    warnings.push(ScanWarning::broken_symlink(&entry_path, target));
698                }
699                frame.add_leaf(0, Some(node));
700            }
701            EntryKind::Other => frame.add_leaf(size, Some(node)),
702        }
703    }
704
705    // Ordered by size like every other tree, so a caller's sort applies the
706    // same way to a listing and to a scan.
707    let root = frame.finish().expect("root is retained");
708
709    // quick_list observes only immediate children, even if supplied a recursive
710    // scan configuration. Preserve the actual scope for downstream consumers.
711    let mut scan_config = cfg.clone();
712    scan_config.root = root_path.clone();
713    scan_config.max_depth = Some(1);
714    Ok(FileTree::new(
715        root,
716        root_path,
717        scan_config,
718        stats,
719        start.elapsed(),
720        warnings.into_vec(),
721    ))
722}
723
724/// Resolve a symlink's target and whether it is broken.
725///
726/// "Broken" means the target genuinely does not exist. A failure to stat for
727/// any other reason (e.g. permission denied on a parent component) is *not*
728/// treated as broken, so valid links into restricted directories aren't
729/// mislabelled. The target string is empty if the link itself can't be read.
730fn resolve_symlink(path: &Path) -> (Box<str>, bool) {
731    match std::fs::read_link(path) {
732        Ok(target) => {
733            let broken = matches!(
734                std::fs::metadata(path),
735                Err(error) if error.kind() == std::io::ErrorKind::NotFound
736            );
737            (target.to_string_lossy().into(), broken)
738        }
739        Err(_) => (Box::from(""), true),
740    }
741}
742
743#[cfg(unix)]
744fn is_executable(metadata: &std::fs::Metadata) -> bool {
745    use std::os::unix::fs::PermissionsExt;
746    metadata.permissions().mode() & 0o111 != 0
747}
748
749#[cfg(not(unix))]
750fn is_executable(_metadata: &std::fs::Metadata) -> bool {
751    false
752}
753
754/// Thread pool shared by every scan. Kept separate from rayon's global pool so
755/// CPU-bound work there (hashing, sorting) can never starve a walk, which
756/// jwalk would otherwise report as an empty result.
757fn scan_pool() -> Arc<jwalk::rayon::ThreadPool> {
758    static POOL: std::sync::OnceLock<Arc<jwalk::rayon::ThreadPool>> = std::sync::OnceLock::new();
759    POOL.get_or_init(|| {
760        Arc::new(
761            jwalk::rayon::ThreadPoolBuilder::new()
762                .thread_name(|index| format!("gravityfile-scan-{index}"))
763                .build()
764                .expect("scan thread pool"),
765        )
766    })
767    .clone()
768}
769
770// Cross-platform metadata helpers
771
772#[cfg(unix)]
773fn get_dev(metadata: &std::fs::Metadata) -> u64 {
774    metadata.dev()
775}
776
777#[cfg(not(unix))]
778fn get_dev(_metadata: &std::fs::Metadata) -> u64 {
779    // No stable numeric device ID without unstable `windows_by_handle`.
780    0
781}
782
783/// Stable file identity where the standard library exposes it.
784#[cfg(unix)]
785fn inode_info(metadata: &std::fs::Metadata) -> Option<InodeInfo> {
786    Some(InodeInfo::new(metadata.ino(), metadata.dev()))
787}
788
789#[cfg(not(unix))]
790fn inode_info(_metadata: &std::fs::Metadata) -> Option<InodeInfo> {
791    // A fabricated identity would make every file look like a hard link of
792    // every other one.
793    None
794}
795
796#[cfg(unix)]
797fn get_nlink(metadata: &std::fs::Metadata) -> u64 {
798    metadata.nlink()
799}
800
801#[cfg(not(unix))]
802fn get_nlink(_metadata: &std::fs::Metadata) -> u64 {
803    // number_of_links() requires unstable `windows_by_handle`.
804    1
805}
806
807/// 512-byte blocks allocated to the entry.
808#[cfg(unix)]
809fn get_blocks(metadata: &std::fs::Metadata) -> u64 {
810    metadata.blocks()
811}
812
813/// Estimated from the length: the standard library does not expose the
814/// allocation here.
815#[cfg(not(unix))]
816fn get_blocks(metadata: &std::fs::Metadata) -> u64 {
817    metadata.len().div_ceil(512)
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use std::fs;
824    use tempfile::TempDir;
825
826    fn scan(config: &ScanConfig) -> FileTree {
827        JwalkScanner::new()
828            .scan(config, CancellationToken::new())
829            .unwrap()
830    }
831
832    fn child<'a>(node: &'a FileNode, name: &str) -> &'a FileNode {
833        node.children
834            .iter()
835            .find(|child| child.name.as_str() == name)
836            .unwrap_or_else(|| panic!("{name} missing"))
837    }
838
839    #[test]
840    fn warnings_bound_detail_and_preserve_observed_counts() {
841        let mut warnings = WarningAccumulator::default();
842        warnings.push(ScanWarning::new(
843            "p",
844            WarningKind::ReadError,
845            "x".repeat(8191),
846        ));
847        warnings.push(ScanWarning::new(
848            "p",
849            WarningKind::ReadError,
850            "x".repeat(8192),
851        ));
852        for index in 0..100_000 {
853            warnings.push(ScanWarning::read_error(
854                format!("item-{index}"),
855                &std::io::Error::other("failed"),
856            ));
857            assert!(warnings.details.len() <= 999);
858        }
859        assert_eq!(warnings.count, 100_002);
860        let retained = warnings.into_vec();
861        assert_eq!(retained.len(), 1000);
862        assert_eq!(retained[0].message.len(), 8191);
863        assert_eq!(
864            retained
865                .iter()
866                .map(ScanWarning::represented_count)
867                .sum::<u64>(),
868            100_002
869        );
870        let omitted = retained.last().unwrap();
871        assert!(omitted.path.as_os_str().is_empty());
872        assert_eq!(omitted.kind, WarningKind::Omitted { count: 99_003 });
873    }
874
875    fn create_test_tree() -> TempDir {
876        let temp = TempDir::new().unwrap();
877        let root = temp.path();
878        fs::create_dir(root.join("dir1")).unwrap();
879        fs::create_dir(root.join("dir2")).unwrap();
880        fs::create_dir(root.join("dir1/subdir")).unwrap();
881        fs::write(root.join("file1.txt"), "hello").unwrap();
882        fs::write(root.join("dir1/file2.txt"), "world world world").unwrap();
883        fs::write(root.join("dir1/subdir/file3.txt"), "test").unwrap();
884        fs::write(root.join("dir2/file4.txt"), "another file here").unwrap();
885        temp
886    }
887
888    #[test]
889    fn scan_counts_files_directories_and_sizes() {
890        let temp = create_test_tree();
891        let tree = scan(&ScanConfig::new(temp.path()));
892        assert_eq!(tree.stats.total_files, 4);
893        assert_eq!(tree.stats.total_dirs, 4, "three directories plus the root");
894        assert_eq!(tree.root.file_count(), 4);
895        assert_eq!(tree.root.dir_count(), 3);
896        let children: u64 = tree.root.children.iter().map(|child| child.size).sum();
897        assert!(
898            tree.root.size >= children,
899            "the root adds its own allocation"
900        );
901        let dir1 = child(&tree.root, "dir1");
902        assert_eq!(dir1.file_count(), 2);
903        assert_eq!(dir1.dir_count(), 1);
904        assert_eq!(child(dir1, "subdir").file_count(), 1);
905    }
906
907    #[test]
908    fn apparent_sizes_include_directories_themselves() {
909        let temp = create_test_tree();
910        let mut config = ScanConfig::new(temp.path());
911        config.apparent_size = true;
912        let tree = scan(&config);
913        let root = temp.path();
914        let dirs: u64 = [".", "dir1", "dir2", "dir1/subdir"]
915            .iter()
916            .map(|dir| fs::metadata(root.join(dir)).unwrap().len())
917            .sum();
918        assert_eq!(tree.root.size, 5 + 17 + 4 + 17 + dirs);
919        assert_eq!(tree.stats.total_size, 43, "stats count file contents only");
920    }
921
922    #[test]
923    fn children_are_sorted_by_size_then_name() {
924        let temp = create_test_tree();
925        let tree = scan(&ScanConfig::new(temp.path()));
926        for pair in tree.root.children.windows(2) {
927            assert!(
928                pair[0].size > pair[1].size
929                    || (pair[0].size == pair[1].size && pair[0].name <= pair[1].name)
930            );
931        }
932    }
933
934    #[test]
935    fn max_depth_limits_retention_but_not_sizes() {
936        let temp = create_test_tree();
937        let mut config = ScanConfig::new(temp.path());
938        config.apparent_size = true;
939        let full = scan(&config);
940        config.max_depth = Some(1);
941        let shallow = scan(&config);
942        assert_eq!(shallow.root.size, full.root.size);
943        assert_eq!(shallow.stats.total_files, 4);
944        let dir1 = child(&shallow.root, "dir1");
945        assert!(
946            dir1.children.is_empty(),
947            "nothing below depth 1 is retained"
948        );
949        assert_eq!(dir1.size, child(&full.root, "dir1").size);
950        assert_eq!(dir1.file_count(), 2);
951        assert_eq!(dir1.dir_count(), 1);
952    }
953
954    #[cfg(unix)]
955    #[test]
956    fn hard_links_count_once_in_both_size_modes() {
957        let temp = TempDir::new().unwrap();
958        let root = temp.path();
959        fs::create_dir(root.join("a")).unwrap();
960        fs::create_dir(root.join("b")).unwrap();
961        fs::write(root.join("a/data"), vec![1u8; 10_000]).unwrap();
962        fs::hard_link(root.join("a/data"), root.join("b/data")).unwrap();
963        let linked = |tree: &FileTree| {
964            child(&tree.root, "a").children[0].size + child(&tree.root, "b").children[0].size
965        };
966        for apparent in [false, true] {
967            let one = if apparent {
968                10_000
969            } else {
970                fs::metadata(root.join("a/data")).unwrap().blocks() * 512
971            };
972            let mut config = ScanConfig::new(root);
973            config.apparent_size = apparent;
974            assert_eq!(linked(&scan(&config)), one, "apparent={apparent}");
975            config.count_hard_links = true;
976            assert_eq!(linked(&scan(&config)), 2 * one, "apparent={apparent}");
977        }
978    }
979
980    #[test]
981    fn grafting_scanned_entries_matches_a_fresh_scan() {
982        let temp = create_test_tree();
983        let root = dunce::canonicalize(temp.path()).unwrap();
984        let config = ScanConfig::new(&root);
985        let mut tree = scan(&config);
986        // Change a directory and a file, and delete another file.
987        fs::write(root.join("dir1/subdir/new.bin"), vec![1u8; 20_000]).unwrap();
988        fs::write(root.join("file1.txt"), vec![2u8; 9_000]).unwrap();
989        fs::remove_file(root.join("dir2/file4.txt")).unwrap();
990        for changed in ["dir1/subdir", "file1.txt", "dir2/file4.txt"] {
991            let path = root.join(changed);
992            tree.remove(&path);
993            if let Some(entry) = scan_entry(&path, &config, CancellationToken::new()).unwrap() {
994                assert!(tree.insert(path.parent().unwrap(), entry.node));
995            }
996        }
997        tree.recount();
998        let fresh = scan(&config);
999        assert_eq!(tree.root.size, fresh.root.size);
1000        assert_eq!(tree.root.file_count(), fresh.root.file_count());
1001        assert_eq!(tree.stats.total_size, fresh.stats.total_size);
1002        for directory in ["dir1", "dir2"] {
1003            assert_eq!(
1004                child(&tree.root, directory).size,
1005                child(&fresh.root, directory).size
1006            );
1007        }
1008        assert!(
1009            scan_entry(&root.join("missing"), &config, CancellationToken::new())
1010                .unwrap()
1011                .is_none()
1012        );
1013    }
1014
1015    #[test]
1016    fn remeasuring_a_scan_matches_scanning_with_that_measure() {
1017        let temp = create_test_tree();
1018        #[cfg(unix)]
1019        fs::hard_link(temp.path().join("file1.txt"), temp.path().join("dir2/link")).unwrap();
1020        for (apparent, links) in [(false, false), (true, false), (false, true), (true, true)] {
1021            let mut config = ScanConfig::new(temp.path());
1022            config.apparent_size = apparent;
1023            config.count_hard_links = links;
1024            let direct = scan(&config);
1025            let mut converted = scan(&ScanConfig::new(temp.path()));
1026            converted.remeasure(apparent, links);
1027            let label = format!("apparent={apparent} links={links}");
1028            assert_eq!(converted.root.size, direct.root.size, "{label}");
1029            assert_eq!(
1030                converted.stats.total_size, direct.stats.total_size,
1031                "{label}"
1032            );
1033            assert_eq!(
1034                converted.stats.total_files, direct.stats.total_files,
1035                "{label}"
1036            );
1037            assert_eq!(
1038                child(&converted.root, "dir1").size,
1039                child(&direct.root, "dir1").size,
1040                "{label}"
1041            );
1042        }
1043    }
1044
1045    #[cfg(unix)]
1046    #[test]
1047    fn special_files_are_listed_not_dropped() {
1048        use std::os::unix::ffi::OsStrExt;
1049        let temp = TempDir::new().unwrap();
1050        let fifo = temp.path().join("pipe");
1051        let native = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
1052        // SAFETY: a valid NUL-terminated path for the duration of the call.
1053        assert_eq!(unsafe { libc::mkfifo(native.as_ptr(), 0o600) }, 0);
1054        let tree = scan(&ScanConfig::new(temp.path()));
1055        assert!(matches!(child(&tree.root, "pipe").kind, NodeKind::Other));
1056        assert_eq!(tree.root.file_count(), 1);
1057        let listed = quick_list(temp.path(), None).unwrap();
1058        assert!(matches!(child(&listed.root, "pipe").kind, NodeKind::Other));
1059    }
1060
1061    #[cfg(unix)]
1062    #[test]
1063    fn symlinks_are_kept_as_links_and_broken_ones_warned() {
1064        let temp = TempDir::new().unwrap();
1065        fs::write(temp.path().join("target"), "x").unwrap();
1066        std::os::unix::fs::symlink("target", temp.path().join("good")).unwrap();
1067        std::os::unix::fs::symlink("missing", temp.path().join("bad")).unwrap();
1068        let tree = scan(&ScanConfig::new(temp.path()));
1069        assert!(matches!(
1070            &child(&tree.root, "good").kind,
1071            NodeKind::Symlink { target, broken: false } if &**target == "target"
1072        ));
1073        assert!(matches!(
1074            child(&tree.root, "bad").kind,
1075            NodeKind::Symlink { broken: true, .. }
1076        ));
1077        assert_eq!(tree.stats.total_symlinks, 2);
1078        assert!(
1079            tree.warnings
1080                .iter()
1081                .any(|warning| warning.kind == WarningKind::BrokenSymlink)
1082        );
1083    }
1084
1085    #[cfg(unix)]
1086    #[test]
1087    fn unreadable_directories_are_reported_as_permission_denied() {
1088        use std::os::unix::fs::PermissionsExt;
1089        let temp = TempDir::new().unwrap();
1090        let locked = temp.path().join("locked");
1091        fs::create_dir(&locked).unwrap();
1092        fs::write(locked.join("secret"), "x").unwrap();
1093        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
1094        let tree = scan(&ScanConfig::new(temp.path()));
1095        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
1096        // Root bypasses permissions; only assert when the read was refused.
1097        if child(&tree.root, "locked").children.is_empty() {
1098            assert!(
1099                tree.warnings
1100                    .iter()
1101                    .any(|warning| warning.kind == WarningKind::PermissionDenied),
1102                "{:?}",
1103                tree.warnings
1104            );
1105        }
1106    }
1107
1108    #[test]
1109    fn entry_limit_fails_the_scan() {
1110        let temp = create_test_tree();
1111        let mut config = ScanConfig::new(temp.path());
1112        config.max_entries = Some(2);
1113        assert!(matches!(
1114            JwalkScanner::new().scan(&config, CancellationToken::new()),
1115            Err(ScanError::EntryLimitExceeded { limit: 2 })
1116        ));
1117    }
1118
1119    #[test]
1120    fn cancellation_returns_interrupted() {
1121        let temp = create_test_tree();
1122        let token = CancellationToken::new();
1123        token.cancel();
1124        assert!(matches!(
1125            JwalkScanner::new().scan(&ScanConfig::new(temp.path()), token),
1126            Err(ScanError::Interrupted)
1127        ));
1128    }
1129
1130    #[test]
1131    fn test_ignore_patterns() {
1132        let temp = create_test_tree();
1133        let config = ScanConfig::builder()
1134            .root(temp.path())
1135            .ignore_patterns(vec!["dir2".to_string()])
1136            .build()
1137            .unwrap();
1138        let tree = scan(&config);
1139        assert!(!tree.root.children.iter().any(|c| c.name.as_str() == "dir2"));
1140    }
1141
1142    #[test]
1143    fn test_ignore_patterns_match_root_relative_paths() {
1144        let temp = create_test_tree();
1145        let config = ScanConfig::builder()
1146            .root(temp.path())
1147            .ignore_patterns(vec!["dir1/subdir/**".to_string()])
1148            .build()
1149            .unwrap();
1150        let tree = scan(&config);
1151        assert!(
1152            child(child(&tree.root, "dir1"), "subdir")
1153                .children
1154                .is_empty()
1155        );
1156    }
1157
1158    #[test]
1159    fn quick_list_respects_hidden() {
1160        let temp = TempDir::new().unwrap();
1161        let root = temp.path();
1162        fs::write(root.join(".hidden"), "secret").unwrap();
1163        fs::write(root.join("visible"), "public").unwrap();
1164        for include_hidden in [false, true] {
1165            let config = ScanConfig::builder()
1166                .root(root)
1167                .include_hidden(include_hidden)
1168                .build()
1169                .unwrap();
1170            let tree = quick_list(root, Some(&config)).unwrap();
1171            let names: Vec<_> = tree.root.children.iter().map(|c| c.name.as_str()).collect();
1172            assert!(names.contains(&"visible"));
1173            assert_eq!(names.contains(&".hidden"), include_hidden);
1174        }
1175    }
1176}