1use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime};
5
6use serde::{Deserialize, Serialize};
7
8use crate::config::ScanConfig;
9use crate::error::ScanWarning;
10use crate::node::{FileNode, NodeKind};
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct TreeStats {
15 pub total_size: u64,
17 pub total_files: u64,
19 pub total_dirs: u64,
21 pub total_symlinks: u64,
23 pub max_depth: u32,
25 #[serde(default, with = "crate::path_serde::optional_pair")]
27 pub largest_file: Option<(PathBuf, u64)>,
28 #[serde(default, with = "crate::path_serde::optional_pair")]
30 pub oldest_file: Option<(PathBuf, SystemTime)>,
31 #[serde(default, with = "crate::path_serde::optional_pair")]
33 pub newest_file: Option<(PathBuf, SystemTime)>,
34}
35
36impl TreeStats {
37 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn record_file(
49 &mut self,
50 path: &Path,
51 size: u64,
52 modified: Option<SystemTime>,
53 depth: u32,
54 ) {
55 self.record_file_with(size, modified, depth, || path.to_path_buf());
56 }
57
58 pub fn record_file_with(
61 &mut self,
62 size: u64,
63 modified: Option<SystemTime>,
64 depth: u32,
65 path: impl FnOnce() -> PathBuf,
66 ) {
67 self.total_files += 1;
68 self.total_size = self.total_size.saturating_add(size);
69 self.max_depth = self.max_depth.max(depth);
70
71 let largest = self.largest_file.as_ref().is_none_or(|(_, s)| size >= *s);
72 let (oldest, newest) = match modified {
73 Some(modified) => (
74 self.oldest_file
75 .as_ref()
76 .is_none_or(|(_, t)| modified <= *t),
77 self.newest_file
78 .as_ref()
79 .is_none_or(|(_, t)| modified >= *t),
80 ),
81 None => (false, false),
82 };
83 if !(largest || oldest || newest) {
84 return;
85 }
86 let path = path();
88 if largest
89 && self
90 .largest_file
91 .as_ref()
92 .is_none_or(|(previous, s)| size > *s || path < *previous)
93 {
94 self.largest_file = Some((path.clone(), size));
95 }
96 if let Some(modified) = modified {
97 if oldest
98 && self
99 .oldest_file
100 .as_ref()
101 .is_none_or(|(previous, t)| modified < *t || path < *previous)
102 {
103 self.oldest_file = Some((path.clone(), modified));
104 }
105 if newest
106 && self
107 .newest_file
108 .as_ref()
109 .is_none_or(|(previous, t)| modified > *t || path < *previous)
110 {
111 self.newest_file = Some((path, modified));
112 }
113 }
114 }
115
116 pub fn record_dir(&mut self, depth: u32) {
118 self.total_dirs += 1;
119 self.max_depth = self.max_depth.max(depth);
120 }
121
122 pub fn record_symlink(&mut self) {
124 self.total_symlinks += 1;
125 }
126
127 pub fn collect(root: &FileNode, root_path: &Path) -> Self {
130 let mut stats = Self::new();
131 stats.record_dir(0);
132 let mut path = root_path.to_path_buf();
133 let mut stack = vec![root.children.iter()];
134 while let Some(children) = stack.last_mut() {
135 let Some(node) = children.next() else {
136 stack.pop();
137 if !stack.is_empty() {
138 path.pop();
139 }
140 continue;
141 };
142 let depth = stack.len() as u32;
143 match node.kind {
144 NodeKind::Directory { .. } => {
145 stats.record_dir(depth);
146 if !node.children.is_empty() {
147 path.push(node.file_name());
148 stack.push(node.children.iter());
149 }
150 }
151 NodeKind::File { .. } => {
152 stats.record_file_with(
153 node.size,
154 Some(node.timestamps.modified),
155 depth,
156 || path.join(node.file_name()),
157 );
158 }
159 NodeKind::Symlink { .. } => stats.record_symlink(),
160 NodeKind::Other => {}
161 }
162 }
163 stats
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct FileTree {
172 pub root: FileNode,
174
175 #[serde(with = "crate::path_serde")]
177 pub root_path: PathBuf,
178
179 pub scanned_at: SystemTime,
181
182 pub scan_duration: Duration,
184
185 pub config: ScanConfig,
187
188 pub stats: TreeStats,
190
191 pub warnings: Vec<ScanWarning>,
193}
194
195impl FileTree {
196 pub fn new(
202 root: FileNode,
203 root_path: PathBuf,
204 config: ScanConfig,
205 stats: TreeStats,
206 scan_duration: Duration,
207 warnings: Vec<ScanWarning>,
208 ) -> Self {
209 Self {
210 root,
211 root_path,
212 scanned_at: SystemTime::now(),
213 scan_duration,
214 config,
215 stats,
216 warnings,
217 }
218 }
219
220 #[inline]
222 pub fn total_size(&self) -> u64 {
223 self.root.size
224 }
225
226 #[inline]
228 pub fn total_files(&self) -> u64 {
229 self.stats.total_files
230 }
231
232 #[inline]
234 pub fn total_dirs(&self) -> u64 {
235 self.stats.total_dirs
236 }
237
238 #[inline]
240 pub fn has_warnings(&self) -> bool {
241 !self.warnings.is_empty()
242 }
243
244 pub fn warning_count(&self) -> u64 {
246 self.warnings.iter().fold(0u64, |count, warning| {
247 count.saturating_add(warning.represented_count())
248 })
249 }
250
251 pub fn find(&self, path: &Path) -> Option<&FileNode> {
253 let mut node = &self.root;
254 for name in relative_names(&self.root_path, path)? {
255 node = node
256 .children
257 .iter()
258 .find(|child| child.file_name() == name)?;
259 }
260 Some(node)
261 }
262
263 pub fn remove(&mut self, path: &Path) -> Option<FileNode> {
267 let mut names = relative_names(&self.root_path, path)?;
268 let name = names.pop()?;
269 let parent = descend_mut(&mut self.root, &names)?;
270 let index = parent
271 .children
272 .iter()
273 .position(|child| child.file_name() == name)?;
274 let removed = parent.children.remove(index);
275 adjust_chain(&mut self.root, &names, Contribution::of(&removed), false);
276 Some(removed)
277 }
278
279 pub fn insert(&mut self, parent: &Path, node: FileNode) -> bool {
284 let Some(names) = relative_names(&self.root_path, parent) else {
285 return false;
286 };
287 match descend_mut(&mut self.root, &names) {
288 Some(directory) if directory.is_dir() => {}
289 _ => return false,
290 }
291 let mut path = parent.to_path_buf();
292 path.push(node.file_name());
293 self.remove(&path);
294 let added = Contribution::of(&node);
295 let directory = descend_mut(&mut self.root, &names).expect("checked above");
296 directory.children.push(node);
297 adjust_chain(&mut self.root, &names, added, true);
298 true
299 }
300
301 pub fn recount(&mut self) {
303 self.stats = TreeStats::collect(&self.root, &self.root_path);
304 }
305
306 pub fn remeasure(&mut self, apparent: bool, count_hard_links: bool) {
309 self.root.remeasure(apparent, count_hard_links);
310 self.config.apparent_size = apparent;
311 self.config.count_hard_links = count_hard_links;
312 self.stats = TreeStats::collect(&self.root, &self.root_path);
313 }
314}
315
316fn relative_names<'a>(root: &Path, path: &'a Path) -> Option<Vec<&'a std::ffi::OsStr>> {
319 path.strip_prefix(root)
320 .ok()?
321 .components()
322 .map(|component| match component {
323 std::path::Component::Normal(name) => Some(name),
324 _ => None,
325 })
326 .collect()
327}
328
329fn descend_mut<'a>(
330 mut node: &'a mut FileNode,
331 names: &[&std::ffi::OsStr],
332) -> Option<&'a mut FileNode> {
333 for name in names {
334 node = node
335 .children
336 .iter_mut()
337 .find(|child| child.file_name() == *name)?;
338 }
339 Some(node)
340}
341
342#[derive(Clone, Copy)]
344struct Contribution {
345 size: u64,
346 files: u64,
347 dirs: u64,
348}
349
350impl Contribution {
351 fn of(node: &FileNode) -> Self {
352 Self {
353 size: node.size,
354 files: if node.is_dir() { node.file_count() } else { 1 },
355 dirs: if node.is_dir() {
356 node.dir_count() + 1
357 } else {
358 0
359 },
360 }
361 }
362}
363
364fn adjust_chain(root: &mut FileNode, names: &[&std::ffi::OsStr], change: Contribution, add: bool) {
367 let apply = |node: &mut FileNode| {
368 let step = |value: u64, by: u64| {
369 if add {
370 value.saturating_add(by)
371 } else {
372 value.saturating_sub(by)
373 }
374 };
375 node.size = step(node.size, change.size);
376 if let NodeKind::Directory {
377 file_count,
378 dir_count,
379 } = &mut node.kind
380 {
381 *file_count = step(*file_count, change.files);
382 *dir_count = step(*dir_count, change.dirs);
383 }
384 };
385 let sort = |node: &mut FileNode| {
386 node.children
387 .sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.compare_name(b)));
388 };
389 let mut node = &mut *root;
392 apply(node);
393 for name in names {
394 node = node
395 .children
396 .iter_mut()
397 .find(|child| child.file_name() == *name)
398 .expect("the chain exists");
399 apply(node);
400 }
401 for depth in (0..=names.len()).rev() {
402 sort(descend_mut(root, &names[..depth]).expect("the chain exists"));
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::node::Timestamps;
410
411 fn sample() -> FileTree {
412 let time = Timestamps::with_modified(SystemTime::UNIX_EPOCH);
413 let file = |name: &str, size| FileNode::new_file(name, size, 0, time, false);
414 let mut sub = FileNode::new_directory("sub", time);
415 sub.children = vec![file("a", 300), file("b", 100)];
416 let mut root = FileNode::new_directory("root", time);
417 root.children = vec![sub, file("c", 200)];
418 root.update_counts();
419 root.size = 600;
420 root.children[0].size = 400;
421 let mut tree = FileTree::new(
422 root,
423 PathBuf::from("/root"),
424 ScanConfig::new("/root"),
425 TreeStats::new(),
426 Duration::ZERO,
427 Vec::new(),
428 );
429 tree.recount();
430 tree
431 }
432
433 #[test]
434 fn removing_an_entry_updates_every_ancestor_and_order() {
435 let mut tree = sample();
436 let removed = tree.remove(Path::new("/root/sub/a")).unwrap();
437 assert_eq!(removed.name, "a");
438 assert_eq!(tree.root.size, 300);
439 let sub = tree.find(Path::new("/root/sub")).unwrap();
440 assert_eq!((sub.size, sub.file_count()), (100, 1));
441 assert_eq!(tree.root.file_count(), 2);
442 assert_eq!(
443 tree.root.children[0].name, "c",
444 "c (200) now outranks sub (100)"
445 );
446 tree.recount();
447 assert_eq!(tree.stats.total_files, 2);
448 assert_eq!(tree.stats.total_size, 300);
449 tree.remove(Path::new("/root/sub")).unwrap();
451 assert_eq!(
452 (
453 tree.root.size,
454 tree.root.file_count(),
455 tree.root.dir_count()
456 ),
457 (200, 1, 0)
458 );
459 assert!(tree.remove(Path::new("/root")).is_none());
460 assert!(tree.remove(Path::new("/elsewhere/c")).is_none());
461 assert!(tree.remove(Path::new("/root/missing")).is_none());
462 }
463
464 #[test]
465 fn inserting_an_entry_replaces_one_of_the_same_name() {
466 let mut tree = sample();
467 let time = Timestamps::with_modified(SystemTime::UNIX_EPOCH);
468 let mut grown = FileNode::new_directory("sub", time);
469 grown.children = vec![FileNode::new_file("x", 1000, 0, time, false)];
470 grown.update_counts();
471 grown.size = 1000;
472 assert!(tree.insert(Path::new("/root"), grown));
473 assert_eq!(tree.root.size, 1200);
474 assert_eq!(tree.root.file_count(), 2);
475 assert_eq!(tree.find(Path::new("/root/sub/x")).unwrap().size, 1000);
476 assert!(tree.find(Path::new("/root/sub/a")).is_none());
477
478 let new = FileNode::new_file("d", 50, 0, time, false);
479 assert!(tree.insert(Path::new("/root/sub"), new));
480 assert_eq!(tree.root.size, 1250);
481 assert_eq!(tree.find(Path::new("/root/sub")).unwrap().file_count(), 2);
482 let file = FileNode::new_file("e", 1, 0, time, false);
483 assert!(!tree.insert(Path::new("/root/c"), file), "c is a file");
484 }
485
486 #[test]
487 fn tied_extremes_are_independent_of_arrival_order() {
488 let time = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
489 for order in [["c", "b", "a"], ["a", "c", "b"], ["b", "a", "c"]] {
490 let mut stats = TreeStats::new();
491 for path in order {
492 stats.record_file(Path::new(path), 42, Some(time), 1);
493 }
494 assert_eq!(stats.largest_file, Some((PathBuf::from("a"), 42)));
495 assert_eq!(stats.oldest_file, Some((PathBuf::from("a"), time)));
496 assert_eq!(stats.newest_file, Some((PathBuf::from("a"), time)));
497 assert_eq!(stats.total_files, 3);
498 assert_eq!(stats.total_size, 126);
499 }
500 }
501
502 #[cfg(unix)]
503 #[test]
504 fn tied_extremes_compare_native_paths_not_lossy_labels() {
505 use std::os::unix::ffi::OsStrExt;
506 let paths = [b"/file-\xfe".as_slice(), b"/file-\xff".as_slice()]
507 .map(|bytes| PathBuf::from(std::ffi::OsStr::from_bytes(bytes)));
508 assert_eq!(paths[0].to_string_lossy(), paths[1].to_string_lossy());
509 for order in [[0, 1], [1, 0]] {
510 let mut stats = TreeStats::new();
511 for index in order {
512 stats.record_file(&paths[index], 10, Some(SystemTime::UNIX_EPOCH), 1);
513 }
514 assert_eq!(stats.largest_file.unwrap().0, paths[0]);
515 assert_eq!(stats.oldest_file.unwrap().0, paths[0]);
516 assert_eq!(stats.newest_file.unwrap().0, paths[0]);
517 }
518 }
519
520 #[test]
521 fn test_tree_stats_default() {
522 let stats = TreeStats::default();
523 assert_eq!(stats.total_size, 0);
524 assert_eq!(stats.total_files, 0);
525 assert_eq!(stats.total_dirs, 0);
526 }
527
528 #[test]
529 fn test_tree_stats_record_file() {
530 let mut stats = TreeStats::new();
531 let now = SystemTime::now();
532
533 stats.record_file(Path::new("/test/file.txt"), 1024, Some(now), 2);
534
535 assert_eq!(stats.total_files, 1);
536 assert_eq!(stats.total_size, 1024);
537 assert_eq!(stats.max_depth, 2);
538 assert!(stats.largest_file.is_some());
539 }
540
541 #[test]
542 fn test_tree_stats_record_file_tracks_extremes() {
543 let mut stats = TreeStats::new();
544 let t1 = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
545 let t2 = SystemTime::UNIX_EPOCH + Duration::from_secs(2000);
546
547 stats.record_file(Path::new("/small_old"), 100, Some(t1), 1);
548 stats.record_file(Path::new("/big_new"), 999, Some(t2), 2);
549
550 assert_eq!(stats.largest_file.as_ref().unwrap().1, 999);
551 assert_eq!(stats.oldest_file.as_ref().unwrap().1, t1);
552 assert_eq!(stats.newest_file.as_ref().unwrap().1, t2);
553 }
554
555 #[test]
556 fn test_record_file_unknown_mtime_does_not_poison_extremes() {
557 let mut stats = TreeStats::new();
558 let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
559
560 stats.record_file(Path::new("/has_time"), 10, Some(t), 1);
562 stats.record_file(Path::new("/no_time"), 20, None, 1);
563
564 assert_eq!(stats.total_files, 2);
567 assert_eq!(stats.total_size, 30);
568 assert_eq!(
569 stats.oldest_file.as_ref().unwrap().0,
570 Path::new("/has_time")
571 );
572 assert_eq!(
573 stats.newest_file.as_ref().unwrap().0,
574 Path::new("/has_time")
575 );
576 }
577}