1use 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
27const PROGRESS_INTERVAL: u64 = 1024;
29
30struct 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#[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
97pub(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#[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 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 fn timestamps(&self) -> Timestamps {
164 Timestamps::with_modified(self.modified.unwrap_or(std::time::UNIX_EPOCH))
165 }
166
167 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 fn counted_size(&self, config: &ScanConfig, tracker: &mut InodeTracker) -> u64 {
199 if matches!(self.kind, EntryKind::Symlink { .. }) {
200 return 0;
201 }
202 let size = if config.apparent_size {
204 self.len
205 } else {
206 self.blocks.saturating_mul(512)
207 };
208 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
218struct Frame {
220 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 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
259fn 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
273pub struct JwalkScanner {
275 progress_tx: broadcast::Sender<ScanProgress>,
276}
277
278impl JwalkScanner {
279 pub fn new() -> Self {
281 let (progress_tx, _) = broadcast::channel(100);
282 Self { progress_tx }
283 }
284
285 pub fn subscribe(&self) -> broadcast::Receiver<ScanProgress> {
287 self.progress_tx.subscribe()
288 }
289
290 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 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 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 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 tree.scan_duration = start.elapsed();
442 tree.scanned_at = SystemTime::now();
443 Ok(tree)
444 }
445
446 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 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; };
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 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#[derive(Debug)]
543pub struct ScannedEntry {
544 pub node: FileNode,
545 pub warnings: Vec<ScanWarning>,
546}
547
548pub 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
590pub 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 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 let root = frame.finish().expect("root is retained");
708
709 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
724fn 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
754fn 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#[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 0
781}
782
783#[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 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 1
805}
806
807#[cfg(unix)]
809fn get_blocks(metadata: &std::fs::Metadata) -> u64 {
810 metadata.blocks()
811}
812
813#[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 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 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 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}