Skip to main content

syncweb_core/
bandwidth_stats.rs

1use std::{
2    collections::BTreeMap,
3    path::Path,
4    time::{SystemTime, UNIX_EPOCH},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{Result, SyncwebError};
10
11/// Transfer counters for one synchronized folder.
12#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
13#[non_exhaustive]
14pub struct FolderStats {
15    pub upload: u64,
16    pub download: u64,
17    pub files_transferred: u64,
18}
19
20/// Transfer counters for one peer.
21#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
22#[non_exhaustive]
23pub struct PeerStats {
24    pub upload: u64,
25    pub download: u64,
26    pub connection_count: u64,
27}
28
29/// Persisted bandwidth accounting for the local node.
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[non_exhaustive]
32pub struct BandwidthStats {
33    pub total_upload: u64,
34    pub total_download: u64,
35    pub per_folder: BTreeMap<String, FolderStats>,
36    pub per_peer: BTreeMap<String, PeerStats>,
37    pub period_start: u64,
38}
39
40impl Default for BandwidthStats {
41    fn default() -> Self {
42        Self {
43            total_upload: 0,
44            total_download: 0,
45            per_folder: BTreeMap::new(),
46            per_peer: BTreeMap::new(),
47            period_start: now_seconds(),
48        }
49    }
50}
51
52impl BandwidthStats {
53    /// Load counters from JSON, returning empty counters for a missing file.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the counters cannot be read or decoded.
58    pub fn load(stats_path_impl: impl AsRef<Path>) -> Result<Self> {
59        let stats_path = stats_path_impl.as_ref();
60        if !stats_path.exists() {
61            return Ok(Self::default());
62        }
63        let bytes = std::fs::read(stats_path)
64            .map_err(|error| SyncwebError::operation("failed to read bandwidth stats", error))?;
65        serde_json::from_slice(&bytes)
66            .map_err(|error| SyncwebError::operation("failed to parse bandwidth stats", error))
67    }
68
69    /// Persist counters atomically.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the counters cannot be serialized or persisted.
74    pub fn save(&self, stats_path: impl AsRef<Path>) -> Result<()> {
75        let bytes = serde_json::to_vec_pretty(self)
76            .map_err(|error| SyncwebError::operation("failed to serialize bandwidth stats", error))?;
77        crate::fs::atomic::atomic_write(stats_path.as_ref(), &bytes)
78    }
79
80    /// Reset all counters while retaining the same storage object.
81    pub fn reset(&mut self) {
82        *self = Self::default();
83    }
84
85    /// Record downloaded bytes, optionally scoped to a folder and peer.
86    pub fn record_download(
87        &mut self,
88        bytes: u64,
89        files: u64,
90        folder_name_option: Option<&str>,
91        peer_id_option: Option<&str>,
92    ) {
93        self.total_download = self.total_download.saturating_add(bytes);
94        if let Some(folder_name) = folder_name_option {
95            let stats = self.per_folder.entry(folder_name.to_owned()).or_default();
96            stats.download = stats.download.saturating_add(bytes);
97            stats.files_transferred = stats.files_transferred.saturating_add(files);
98        }
99        if let Some(peer_id) = peer_id_option {
100            let stats = self.per_peer.entry(peer_id.to_owned()).or_default();
101            stats.download = stats.download.saturating_add(bytes);
102        }
103    }
104
105    /// Record uploaded bytes, optionally scoped to a folder and peer.
106    pub fn record_upload(
107        &mut self,
108        bytes: u64,
109        files: u64,
110        folder_name_option: Option<&str>,
111        peer_id_option: Option<&str>,
112    ) {
113        self.total_upload = self.total_upload.saturating_add(bytes);
114        if let Some(folder_name) = folder_name_option {
115            let stats = self.per_folder.entry(folder_name.to_owned()).or_default();
116            stats.upload = stats.upload.saturating_add(bytes);
117            stats.files_transferred = stats.files_transferred.saturating_add(files);
118        }
119        if let Some(peer_id) = peer_id_option {
120            let stats = self.per_peer.entry(peer_id.to_owned()).or_default();
121            stats.upload = stats.upload.saturating_add(bytes);
122        }
123    }
124
125    /// Record a newly observed peer connection.
126    pub fn record_connection(&mut self, peer: &str) {
127        let stats = self.per_peer.entry(peer.to_owned()).or_default();
128        stats.connection_count = stats.connection_count.saturating_add(1);
129    }
130}
131
132/// A group of files sharing the same extension.
133#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
134#[non_exhaustive]
135pub struct ExtensionGroup {
136    pub count: u64,
137    pub total_size: u64,
138}
139
140/// Report produced by [`FileStatsCollector`].
141#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
142#[non_exhaustive]
143pub struct FileStatsReport {
144    pub total_files: u64,
145    pub total_size: u64,
146    pub by_extension: BTreeMap<String, ExtensionGroup>,
147    pub size_buckets: BTreeMap<String, u64>,
148    /// Age distribution of entry insertion timestamps.
149    /// Labels: <24h, 1d-7d, 7d-30d, 1m-1y, >1y, unknown.
150    pub time_buckets: BTreeMap<String, u64>,
151}
152
153/// Collects file-level statistics from existing metadata (doc entries).
154///
155/// Does **not** scan the filesystem — entries are fed via [`add_entry`].
156#[derive(Clone, Debug, Default)]
157pub struct FileStatsCollector {
158    extensions: BTreeMap<String, ExtensionGroup>,
159    sizes: Vec<u64>,
160    /// Entry insertion timestamps in microseconds since Unix epoch.
161    /// `None` when the timestamp was not provided.
162    timestamps: Vec<Option<u64>>,
163}
164
165impl FileStatsCollector {
166    #[must_use]
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// Record one file entry.
172    pub fn add_entry(&mut self, path: &str, size: u64) {
173        let raw = path
174            .rsplit('.')
175            .next()
176            .filter(|s| !s.contains(std::path::MAIN_SEPARATOR))
177            .unwrap_or("");
178        let normalized = if raw.is_empty() || raw == path { "" } else { raw };
179        let group = self.extensions.entry(normalized.to_lowercase()).or_default();
180        group.count = group.count.saturating_add(1);
181        group.total_size = group.total_size.saturating_add(size);
182        self.sizes.push(size);
183    }
184
185    /// Attempt to record an entry key stored as raw bytes (UTF-8).  Non-UTF-8 keys are silently skipped.
186    pub fn add_entry_bytes(&mut self, key: &[u8], size: u64) {
187        if let Ok(path) = std::str::from_utf8(key) {
188            self.add_entry(path, size);
189        }
190    }
191
192    /// Record one file entry with its insertion timestamp (microseconds since Unix epoch).
193    pub fn add_entry_with_time(&mut self, path: &str, size: u64, timestamp_micros: Option<u64>) {
194        self.add_entry(path, size);
195        self.timestamps.push(timestamp_micros);
196    }
197
198    /// UTF-8 bytes variant of [`add_entry_with_time`].
199    /// Non-UTF-8 keys are silently skipped and no timestamp is recorded.
200    pub fn add_entry_bytes_with_time(&mut self, key: &[u8], size: u64, timestamp_micros: Option<u64>) {
201        if let Ok(path) = std::str::from_utf8(key) {
202            self.add_entry_with_time(path, size, timestamp_micros);
203        }
204    }
205
206    /// Finalise and return the collected report.
207    #[must_use]
208    pub fn report(&self) -> FileStatsReport {
209        let total_files = self.extensions.values().map(|g| g.count).sum();
210        let total_size = self.extensions.values().map(|g| g.total_size).sum();
211
212        let mut size_buckets = BTreeMap::new();
213        for &size in &self.sizes {
214            let label = match size {
215                0..=1023 => "<1KB",
216                1024..=1_048_575 => "1KB-1MB",
217                1_048_576..=104_857_599 => "1MB-100MB",
218                _ => ">100MB",
219            };
220            size_buckets
221                .entry(label.to_owned())
222                .and_modify(|count: &mut u64| *count = count.saturating_add(1))
223                .or_insert(1_u64);
224        }
225
226        let now_dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
227        let now_micros = now_dur
228            .as_secs()
229            .saturating_mul(1_000_000)
230            .saturating_add(u64::from(now_dur.subsec_micros()));
231        let mut time_buckets = BTreeMap::new();
232        for &ts_opt in &self.timestamps {
233            let label = ts_opt.map_or("unknown", |ts| {
234                let age = now_micros.saturating_sub(ts);
235                if age < 86_400_000_000 {
236                    "<24h"
237                } else if age < 604_800_000_000 {
238                    "1d-7d"
239                } else if age < 2_592_000_000_000 {
240                    "7d-30d"
241                } else if age < 31_536_000_000_000 {
242                    "1m-1y"
243                } else {
244                    ">1y"
245                }
246            });
247            *time_buckets.entry(label.to_owned()).or_insert(0_u64) =
248                time_buckets.get(label).copied().unwrap_or(0).saturating_add(1);
249        }
250
251        FileStatsReport {
252            total_files,
253            total_size,
254            by_extension: self.extensions.clone(),
255            size_buckets,
256            time_buckets,
257        }
258    }
259}
260
261fn now_seconds() -> u64 {
262    SystemTime::now()
263        .duration_since(UNIX_EPOCH)
264        .map_or(0, |duration| duration.as_secs())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use anyhow::ensure;
271
272    #[test]
273    fn filestats_empty() {
274        let collector = FileStatsCollector::new();
275        let report = collector.report();
276        assert_eq!(report.total_files, 0);
277        assert_eq!(report.total_size, 0);
278        assert!(report.by_extension.is_empty());
279        assert!(report.size_buckets.is_empty());
280        assert!(report.time_buckets.is_empty());
281    }
282
283    #[test]
284    fn filestats_counts_by_extension() -> anyhow::Result<()> {
285        let mut collector = FileStatsCollector::new();
286        collector.add_entry("a.txt", 5);
287        collector.add_entry("b.txt", 10);
288        collector.add_entry("img.png", 100);
289        let report = collector.report();
290        ensure!(report.total_files == 3, "total_files should be 3");
291        ensure!(report.total_size == 115, "total_size should be 115");
292        let txt = report.by_extension.get("txt");
293        ensure!(txt.is_some(), "expected txt extension");
294        ensure!(txt.unwrap().count == 2, "txt count should be 2");
295        let png = report.by_extension.get("png");
296        ensure!(png.is_some(), "expected png extension");
297        ensure!(png.unwrap().count == 1, "png count should be 1");
298        ensure!(txt.unwrap().total_size == 15, "txt total_size should be 15");
299        Ok(())
300    }
301
302    #[test]
303    fn filestats_size_distribution() {
304        let mut collector = FileStatsCollector::new();
305        collector.add_entry("tiny.txt", 1);
306        collector.add_entry("medium.txt", 50_000);
307        collector.add_entry("large.txt", 5_000_000);
308        let report = collector.report();
309        assert_eq!(report.size_buckets.get("<1KB").copied().unwrap_or(0), 1);
310        assert!(report.size_buckets.get("1KB-1MB").copied().unwrap_or(0) >= 1);
311        assert!(report.size_buckets.get("1MB-100MB").copied().unwrap_or(0) >= 1);
312    }
313
314    #[test]
315    fn filestats_no_extension() -> anyhow::Result<()> {
316        let mut collector = FileStatsCollector::new();
317        collector.add_entry("Makefile", 200);
318        collector.add_entry("README", 50);
319        let report = collector.report();
320        ensure!(report.total_files == 2, "total_files should be 2");
321        let no_ext = report.by_extension.get("");
322        ensure!(no_ext.is_some(), "expected empty extension");
323        ensure!(no_ext.unwrap().count == 2, "no-ext count should be 2");
324        Ok(())
325    }
326
327    #[test]
328    fn filestats_bytes_key() -> anyhow::Result<()> {
329        let mut collector = FileStatsCollector::new();
330        collector.add_entry_bytes(b"notes.txt", 42);
331        collector.add_entry_bytes(b"sub/doc.pdf", 300);
332        let report = collector.report();
333        ensure!(report.total_files == 2, "total_files should be 2");
334        let txt = report.by_extension.get("txt");
335        ensure!(txt.is_some(), "expected txt extension");
336        ensure!(txt.unwrap().count == 1, "txt count should be 1");
337        let pdf = report.by_extension.get("pdf");
338        ensure!(pdf.is_some(), "expected pdf extension");
339        ensure!(pdf.unwrap().count == 1, "pdf count should be 1");
340        Ok(())
341    }
342
343    #[test]
344    fn filestats_extension_case_insensitive() -> anyhow::Result<()> {
345        let mut collector = FileStatsCollector::new();
346        collector.add_entry("a.TXT", 5);
347        collector.add_entry("b.txt", 10);
348        let report = collector.report();
349        ensure!(report.by_extension.len() == 1, "should be 1 extension");
350        let txt = report.by_extension.get("txt");
351        ensure!(txt.is_some(), "expected txt extension");
352        ensure!(txt.unwrap().count == 2, "txt count should be 2");
353        Ok(())
354    }
355
356    #[test]
357    fn filestats_empty_path() {
358        let mut collector = FileStatsCollector::new();
359        collector.add_entry("", 0);
360        let report = collector.report();
361        assert_eq!(report.total_files, 1);
362    }
363
364    #[test]
365    fn filestats_time_distribution() {
366        let mut collector = FileStatsCollector::new();
367        let now = SystemTime::now();
368        let ts = |offset_secs: i64| -> u64 {
369            let t = if offset_secs >= 0 {
370                now + std::time::Duration::from_secs(offset_secs.unsigned_abs())
371            } else {
372                now - std::time::Duration::from_secs(offset_secs.unsigned_abs())
373            };
374            let d = t.duration_since(UNIX_EPOCH).unwrap();
375            d.as_secs()
376                .saturating_mul(1_000_000)
377                .saturating_add(u64::from(d.subsec_micros()))
378        };
379        collector.add_entry_with_time("recent.txt", 100, Some(ts(0)));
380        collector.add_entry_with_time("old.txt", 200, Some(ts(-366 * 24 * 3600)));
381        collector.add_entry_with_time("no_time.txt", 50, None);
382        let report = collector.report();
383        assert_eq!(report.time_buckets.get("<24h").copied().unwrap_or(0), 1);
384        assert_eq!(report.time_buckets.get(">1y").copied().unwrap_or(0), 1);
385        assert_eq!(report.time_buckets.get("unknown").copied().unwrap_or(0), 1);
386    }
387
388    #[test]
389    fn filestats_time_all_buckets() {
390        let mut collector = FileStatsCollector::new();
391        let now = SystemTime::now();
392        let micros = |secs_ago: u64| -> u64 {
393            let t = now - std::time::Duration::from_secs(secs_ago);
394            let d = t.duration_since(UNIX_EPOCH).unwrap();
395            d.as_secs()
396                .saturating_mul(1_000_000)
397                .saturating_add(u64::from(d.subsec_micros()))
398        };
399        // <24h: 1 hour ago
400        collector.add_entry_with_time("hour_ago.txt", 10, Some(micros(3600)));
401        // 1d-7d: 2 days ago
402        collector.add_entry_with_time("two_days.txt", 10, Some(micros(2 * 86400)));
403        // 7d-30d: 14 days ago
404        collector.add_entry_with_time("two_weeks.txt", 10, Some(micros(14 * 86400)));
405        // 1m-1y: 60 days ago
406        collector.add_entry_with_time("two_months.txt", 10, Some(micros(60 * 86400)));
407        // >1y: 400 days ago
408        collector.add_entry_with_time("year_plus.txt", 10, Some(micros(400 * 86400)));
409        let report = collector.report();
410        assert_eq!(report.time_buckets.len(), 5, "all five time buckets should be present");
411        assert_eq!(report.time_buckets.get("<24h").copied().unwrap_or(0), 1);
412        assert_eq!(report.time_buckets.get("1d-7d").copied().unwrap_or(0), 1);
413        assert_eq!(report.time_buckets.get("7d-30d").copied().unwrap_or(0), 1);
414        assert_eq!(report.time_buckets.get("1m-1y").copied().unwrap_or(0), 1);
415        assert_eq!(report.time_buckets.get(">1y").copied().unwrap_or(0), 1);
416    }
417
418    #[test]
419    fn filestats_time_via_bytes() {
420        let mut collector = FileStatsCollector::new();
421        collector.add_entry_bytes_with_time(b"a.txt", 5, Some(1_700_000_000_000_000));
422        collector.add_entry_bytes_with_time(b"b.txt", 10, Some(1_700_000_000_000_001));
423        let report = collector.report();
424        assert_eq!(report.total_files, 2);
425        assert!(
426            report.time_buckets.contains_key("<24h")
427                || report.time_buckets.contains_key("1d-7d")
428                || report.time_buckets.contains_key(">1y")
429        );
430    }
431}