#![cfg(unix)] use sized::{build_tree, scan_tree, EntryType, ScanControl, ScanError, ScanOptions}; use std::{ fs, io::Write, os::unix::fs::{symlink, MetadataExt, PermissionsExt}, }; use tempfile::tempdir; fn scan(path: &std::path::Path) -> sized::ScanReport { scan_tree(path, &ScanOptions::default(), &ScanControl::default()).unwrap() } #[test] fn hard_links_count_allocation_once_but_keep_apparent_sizes() { let dir = tempdir().unwrap(); let original = dir.path().join("a-original"); fs::write(&original, [9; 65536]).unwrap(); fs::hard_link(&original, dir.path().join("z-alias")).unwrap(); let expected = fs::metadata(&original).unwrap().blocks() + fs::metadata(dir.path()).unwrap().blocks(); for threads in [1, 4] { let report = scan_tree( dir.path(), &ScanOptions { threads: Some(threads), ..ScanOptions::default() }, &ScanControl::default(), ) .unwrap(); assert_eq!(report.root.blocks, expected); assert_eq!(report.root.children[0].size_bytes, 65536); assert_eq!(report.root.children[1].size_bytes, 65536); assert!(!report.root.children[0].hard_link_duplicate); assert!(report.root.children[1].hard_link_duplicate); assert_eq!(report.root.children[1].blocks, 0); assert_eq!(report.entries_scanned, 3); assert!(report.root.complete); } } #[test] fn ignored_hard_link_does_not_steal_filtered_allocation() { let dir = tempdir().unwrap(); fs::write(dir.path().join(".ignore"), "a-ignored\n").unwrap(); let original = dir.path().join("a-ignored"); fs::write(&original, [9; 65536]).unwrap(); fs::hard_link(&original, dir.path().join("z-included")).unwrap(); let report = scan_tree( dir.path(), &ScanOptions { compare_ignore: true, ..ScanOptions::default() }, &ScanControl::default(), ) .unwrap(); let expected = fs::metadata(&original).unwrap().blocks() + fs::metadata(dir.path()).unwrap().blocks() + fs::metadata(dir.path().join(".ignore")).unwrap().blocks(); assert_eq!(report.root.blocks, expected); assert_eq!(report.root.blocks_filtered, expected); assert_eq!(report.root.children.last().unwrap().blocks, 0); assert!(report.root.children.last().unwrap().blocks_filtered > 0); } #[test] fn hidden_files_are_counted_without_ignore_filtering() { let dir = tempdir().unwrap(); fs::write(dir.path().join(".hidden"), b"hidden").unwrap(); fs::write(dir.path().join(".ignore"), "build\n").unwrap(); fs::write(dir.path().join("build"), b"build").unwrap(); assert_eq!(scan(dir.path()).root.children.len(), 3); let report = scan_tree( dir.path(), &ScanOptions { respect_ignore: true, ..ScanOptions::default() }, &ScanControl::default(), ) .unwrap(); assert_eq!(report.root.children.len(), 2); assert!(report .root .children .iter() .any(|n| n.path.ends_with(".hidden"))); } #[test] fn ignore_rules_are_inherited_by_nested_directories() { let dir = tempdir().unwrap(); fs::write(dir.path().join(".ignore"), "*.tmp\n").unwrap(); let nested = dir.path().join("nested"); fs::create_dir(&nested).unwrap(); fs::write(nested.join("skip.tmp"), b"ignored").unwrap(); fs::write(nested.join("keep.bin"), b"included").unwrap(); let report = scan_tree( dir.path(), &ScanOptions { respect_ignore: true, ..ScanOptions::default() }, &ScanControl::default(), ) .unwrap(); let children = &report .root .children .iter() .find(|n| n.path == nested) .unwrap() .children; assert_eq!(children.len(), 1); assert!(children[0].path.ends_with("keep.bin")); let compared = scan_tree( dir.path(), &ScanOptions { compare_ignore: true, ..ScanOptions::default() }, &ScanControl::default(), ) .unwrap(); assert_eq!(compared.root.size_bytes_filtered, report.root.size_bytes); assert_eq!(compared.root.blocks_filtered, report.root.blocks); } #[test] fn sparse_file_reports_blocks_and_logical_length_separately() { let dir = tempdir().unwrap(); let path = dir.path().join("sparse"); let mut file = fs::File::create(&path).unwrap(); file.write_all(&[7; 4096]).unwrap(); file.set_len(128 * 1024 * 1024).unwrap(); file.sync_all().unwrap(); let node = scan(&path).root; assert_eq!(node.size_bytes, 128 * 1024 * 1024); assert_eq!(node.blocks, fs::metadata(path).unwrap().blocks()); assert!(node.blocks * 512 < node.size_bytes); } #[test] fn symlinks_including_loop_and_dangling_link_are_leaves() { let dir = tempdir().unwrap(); symlink(dir.path(), dir.path().join("loop")).unwrap(); symlink(dir.path().join("missing"), dir.path().join("dangling")).unwrap(); let report = scan(dir.path()); assert_eq!(report.root.children.len(), 2); for node in report.root.children { assert!(node.symlink); assert!(node.children.is_empty()); assert!(node.complete); } } #[test] fn missing_root_returns_error_and_legacy_helper_marks_unavailable() { let dir = tempdir().unwrap(); let path = dir.path().join("vanished"); assert!( matches!(scan_tree(&path, &ScanOptions::default(), &ScanControl::default()), Err(ScanError::Root { source, .. }) if source.kind() == std::io::ErrorKind::NotFound) ); let node = build_tree(&path, false, false); assert_eq!(node.entry_type, EntryType::Unknown); assert!(!node.accessible); assert!(!node.complete); } #[test] fn permission_error_propagates_incomplete_status_to_root() { let dir = tempdir().unwrap(); let blocked = dir.path().join("blocked"); fs::create_dir(&blocked).unwrap(); fs::write(blocked.join("unreadable"), b"data").unwrap(); fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap(); let result = std::panic::catch_unwind(|| { assert!( fs::read_dir(&blocked).is_err(), "permission tests require an unprivileged user" ); let report = scan(dir.path()); assert!(report.root.accessible); assert!(!report.root.complete); assert!(!report.root.children[0].accessible); assert_eq!(report.issues.len(), 1); assert_eq!(report.issues[0].path, blocked); assert_eq!(report.issues[0].kind, std::io::ErrorKind::PermissionDenied); }); fs::set_permissions(blocked, fs::Permissions::from_mode(0o700)).unwrap(); result.unwrap(); } #[test] fn cancelled_scan_never_returns_completed_tree() { let dir = tempdir().unwrap(); let control = ScanControl::default(); control.cancel(); assert!(matches!( scan_tree(dir.path(), &ScanOptions::default(), &control), Err(ScanError::Cancelled) )); assert_eq!(control.entries_scanned(), 0); } #[test] fn cancellation_and_progress_work_during_scan() { let dir = tempdir().unwrap(); for i in 0..2000 { fs::write(dir.path().join(format!("file-{i}")), b"x").unwrap(); } let control = ScanControl::default(); let watcher = control.clone(); let cancel = std::thread::spawn(move || { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while watcher.entries_scanned() < 10 { assert!( std::time::Instant::now() < deadline, "scan made no progress" ); std::thread::yield_now(); } watcher.cancel(); }); let result = scan_tree( dir.path(), &ScanOptions { threads: Some(1), ..ScanOptions::default() }, &control, ); cancel.join().unwrap(); assert!(matches!(result, Err(ScanError::Cancelled))); assert!(control.entries_scanned() >= 10); } #[test] fn special_file_is_not_treated_as_directory() { let dir = tempdir().unwrap(); let path = dir.path().join("fifo"); assert!(std::process::Command::new("mkfifo") .arg(&path) .status() .unwrap() .success()); let node = scan(&path).root; assert_eq!(node.entry_type, EntryType::Special); assert!(node.children.is_empty()); assert!(node.complete); }