Skip to main content

gravityfile_core/
tree.rs

1//! File tree container and statistics.
2
3use 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;
11
12/// Summary statistics for a scanned tree.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct TreeStats {
15    /// Total size in bytes.
16    pub total_size: u64,
17    /// Total number of files.
18    pub total_files: u64,
19    /// Total number of directories.
20    pub total_dirs: u64,
21    /// Total number of symbolic links.
22    pub total_symlinks: u64,
23    /// Maximum depth reached.
24    pub max_depth: u32,
25    /// Largest file (path, size).
26    pub largest_file: Option<(PathBuf, u64)>,
27    /// Oldest file (path, time).
28    pub oldest_file: Option<(PathBuf, SystemTime)>,
29    /// Newest file (path, time).
30    pub newest_file: Option<(PathBuf, SystemTime)>,
31}
32
33impl TreeStats {
34    /// Create new empty stats.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Update stats with a file entry. Only clones the path when updating a tracked field.
40    pub fn record_file(&mut self, path: &Path, size: u64, modified: SystemTime, depth: u32) {
41        self.total_files += 1;
42        self.total_size += size;
43        self.max_depth = self.max_depth.max(depth);
44
45        // Track largest file
46        if self.largest_file.as_ref().is_none_or(|(_, s)| size > *s) {
47            self.largest_file = Some((path.to_path_buf(), size));
48        }
49
50        // Track oldest file
51        if self.oldest_file.as_ref().is_none_or(|(_, t)| modified < *t) {
52            self.oldest_file = Some((path.to_path_buf(), modified));
53        }
54
55        // Track newest file
56        if self.newest_file.as_ref().is_none_or(|(_, t)| modified > *t) {
57            self.newest_file = Some((path.to_path_buf(), modified));
58        }
59    }
60
61    /// Record a directory.
62    pub fn record_dir(&mut self, depth: u32) {
63        self.total_dirs += 1;
64        self.max_depth = self.max_depth.max(depth);
65    }
66
67    /// Record a symlink.
68    pub fn record_symlink(&mut self) {
69        self.total_symlinks += 1;
70    }
71}
72
73/// Complete scanned file tree with metadata.
74///
75/// **Warning:** `Clone` performs a deep clone of the entire tree — O(n) in the number of nodes.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct FileTree {
78    /// Root node of the tree.
79    pub root: FileNode,
80
81    /// Root path that was scanned.
82    pub root_path: PathBuf,
83
84    /// When this scan was performed.
85    pub scanned_at: SystemTime,
86
87    /// Duration of the scan.
88    pub scan_duration: Duration,
89
90    /// Scan configuration used.
91    pub config: ScanConfig,
92
93    /// Summary statistics.
94    pub stats: TreeStats,
95
96    /// Warnings encountered during scan.
97    pub warnings: Vec<ScanWarning>,
98}
99
100impl FileTree {
101    /// Create a new file tree. `scanned_at` should be captured by the caller at scan start time.
102    pub fn new(
103        root: FileNode,
104        root_path: PathBuf,
105        config: ScanConfig,
106        stats: TreeStats,
107        scan_duration: Duration,
108        warnings: Vec<ScanWarning>,
109    ) -> Self {
110        Self {
111            root,
112            root_path,
113            scanned_at: SystemTime::now(),
114            scan_duration,
115            config,
116            stats,
117            warnings,
118        }
119    }
120
121    /// Get the total size of the tree.
122    #[inline]
123    pub fn total_size(&self) -> u64 {
124        self.root.size
125    }
126
127    /// Get the total number of files.
128    #[inline]
129    pub fn total_files(&self) -> u64 {
130        self.stats.total_files
131    }
132
133    /// Get the total number of directories.
134    #[inline]
135    pub fn total_dirs(&self) -> u64 {
136        self.stats.total_dirs
137    }
138
139    /// Check if there were any warnings during scanning.
140    #[inline]
141    pub fn has_warnings(&self) -> bool {
142        !self.warnings.is_empty()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_tree_stats_default() {
152        let stats = TreeStats::default();
153        assert_eq!(stats.total_size, 0);
154        assert_eq!(stats.total_files, 0);
155        assert_eq!(stats.total_dirs, 0);
156    }
157
158    #[test]
159    fn test_tree_stats_record_file() {
160        let mut stats = TreeStats::new();
161        let now = SystemTime::now();
162
163        stats.record_file(Path::new("/test/file.txt"), 1024, now, 2);
164
165        assert_eq!(stats.total_files, 1);
166        assert_eq!(stats.total_size, 1024);
167        assert_eq!(stats.max_depth, 2);
168        assert!(stats.largest_file.is_some());
169    }
170
171    #[test]
172    fn test_tree_stats_record_file_tracks_extremes() {
173        let mut stats = TreeStats::new();
174        let t1 = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
175        let t2 = SystemTime::UNIX_EPOCH + Duration::from_secs(2000);
176
177        stats.record_file(Path::new("/small_old"), 100, t1, 1);
178        stats.record_file(Path::new("/big_new"), 999, t2, 2);
179
180        assert_eq!(stats.largest_file.as_ref().unwrap().1, 999);
181        assert_eq!(stats.oldest_file.as_ref().unwrap().1, t1);
182        assert_eq!(stats.newest_file.as_ref().unwrap().1, t2);
183    }
184}