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    /// Largest files by size (relative path, bytes), descending.
152    pub largest_files: Vec<(String, u64)>,
153}
154
155/// Collects file-level statistics from existing metadata (doc entries).
156///
157/// Does **not** scan the filesystem — entries are fed via [`add_entry`].
158#[derive(Clone, Debug, Default)]
159pub struct FileStatsCollector {
160    extensions: BTreeMap<String, ExtensionGroup>,
161    sizes: Vec<u64>,
162    /// Entry insertion timestamps in microseconds since Unix epoch.
163    /// `None` when the timestamp was not provided.
164    timestamps: Vec<Option<u64>>,
165    largest_files: Vec<(String, u64)>,
166}
167
168impl FileStatsCollector {
169    #[must_use]
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Record one file entry.
175    pub fn add_entry(&mut self, path: &str, size: u64) {
176        let raw = path
177            .rsplit('.')
178            .next()
179            .filter(|s| !s.contains(std::path::MAIN_SEPARATOR))
180            .unwrap_or("");
181        let normalized = if raw.is_empty() || raw == path { "" } else { raw };
182        let group = self.extensions.entry(normalized.to_lowercase()).or_default();
183        group.count = group.count.saturating_add(1);
184        group.total_size = group.total_size.saturating_add(size);
185        self.sizes.push(size);
186        self.largest_files.push((path.to_owned(), size));
187        self.largest_files
188            .sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
189        self.largest_files.truncate(100);
190    }
191
192    /// Attempt to record an entry key stored as raw bytes (UTF-8).  Non-UTF-8 keys are silently skipped.
193    pub fn add_entry_bytes(&mut self, key: &[u8], size: u64) {
194        if let Ok(path) = std::str::from_utf8(key) {
195            self.add_entry(path, size);
196        }
197    }
198
199    /// Record one file entry with its insertion timestamp (microseconds since Unix epoch).
200    pub fn add_entry_with_time(&mut self, path: &str, size: u64, timestamp_micros: Option<u64>) {
201        self.add_entry(path, size);
202        self.timestamps.push(timestamp_micros);
203    }
204
205    /// UTF-8 bytes variant of [`add_entry_with_time`].
206    /// Non-UTF-8 keys are silently skipped and no timestamp is recorded.
207    pub fn add_entry_bytes_with_time(&mut self, key: &[u8], size: u64, timestamp_micros: Option<u64>) {
208        if let Ok(path) = std::str::from_utf8(key) {
209            self.add_entry_with_time(path, size, timestamp_micros);
210        }
211    }
212
213    /// Finalise and return the collected report.
214    #[must_use]
215    pub fn report(&self) -> FileStatsReport {
216        let total_files = self.extensions.values().map(|g| g.count).sum();
217        let total_size = self.extensions.values().map(|g| g.total_size).sum();
218
219        let mut size_buckets = BTreeMap::new();
220        for &size in &self.sizes {
221            let label = match size {
222                0..=1023 => "<1KB",
223                1024..=1_048_575 => "1KB-1MB",
224                1_048_576..=104_857_599 => "1MB-100MB",
225                _ => ">100MB",
226            };
227            size_buckets
228                .entry(label.to_owned())
229                .and_modify(|count: &mut u64| *count = count.saturating_add(1))
230                .or_insert(1_u64);
231        }
232
233        let now_dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
234        let now_micros = now_dur
235            .as_secs()
236            .saturating_mul(1_000_000)
237            .saturating_add(u64::from(now_dur.subsec_micros()));
238        let mut time_buckets = BTreeMap::new();
239        for &ts_opt in &self.timestamps {
240            let label = ts_opt.map_or("unknown", |ts| {
241                let age = now_micros.saturating_sub(ts);
242                if age < 86_400_000_000 {
243                    "<24h"
244                } else if age < 604_800_000_000 {
245                    "1d-7d"
246                } else if age < 2_592_000_000_000 {
247                    "7d-30d"
248                } else if age < 31_536_000_000_000 {
249                    "1m-1y"
250                } else {
251                    ">1y"
252                }
253            });
254            *time_buckets.entry(label.to_owned()).or_insert(0_u64) =
255                time_buckets.get(label).copied().unwrap_or(0).saturating_add(1);
256        }
257
258        FileStatsReport {
259            total_files,
260            total_size,
261            by_extension: self.extensions.clone(),
262            size_buckets,
263            time_buckets,
264            largest_files: self.largest_files.clone(),
265        }
266    }
267}
268
269fn now_seconds() -> u64 {
270    SystemTime::now()
271        .duration_since(UNIX_EPOCH)
272        .map_or(0, |duration| duration.as_secs())
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use anyhow::ensure;
279
280    #[test]
281    fn filestats_empty() {
282        let collector = FileStatsCollector::new();
283        let report = collector.report();
284        assert_eq!(report.total_files, 0);
285        assert_eq!(report.total_size, 0);
286        assert!(report.by_extension.is_empty());
287        assert!(report.size_buckets.is_empty());
288        assert!(report.time_buckets.is_empty());
289    }
290
291    #[test]
292    fn filestats_counts_by_extension() -> anyhow::Result<()> {
293        let mut collector = FileStatsCollector::new();
294        collector.add_entry("a.txt", 5);
295        collector.add_entry("b.txt", 10);
296        collector.add_entry("img.png", 100);
297        let report = collector.report();
298        ensure!(report.total_files == 3, "total_files should be 3");
299        ensure!(report.total_size == 115, "total_size should be 115");
300        let txt = report.by_extension.get("txt");
301        ensure!(txt.is_some(), "expected txt extension");
302        ensure!(txt.unwrap().count == 2, "txt count should be 2");
303        let png = report.by_extension.get("png");
304        ensure!(png.is_some(), "expected png extension");
305        ensure!(png.unwrap().count == 1, "png count should be 1");
306        ensure!(txt.unwrap().total_size == 15, "txt total_size should be 15");
307        Ok(())
308    }
309
310    #[test]
311    fn filestats_size_distribution() {
312        let mut collector = FileStatsCollector::new();
313        collector.add_entry("tiny.txt", 1);
314        collector.add_entry("medium.txt", 50_000);
315        collector.add_entry("large.txt", 5_000_000);
316        let report = collector.report();
317        assert_eq!(report.size_buckets.get("<1KB").copied().unwrap_or(0), 1);
318        assert!(report.size_buckets.get("1KB-1MB").copied().unwrap_or(0) >= 1);
319        assert!(report.size_buckets.get("1MB-100MB").copied().unwrap_or(0) >= 1);
320    }
321
322    #[test]
323    fn filestats_no_extension() -> anyhow::Result<()> {
324        let mut collector = FileStatsCollector::new();
325        collector.add_entry("Makefile", 200);
326        collector.add_entry("README", 50);
327        let report = collector.report();
328        ensure!(report.total_files == 2, "total_files should be 2");
329        let no_ext = report.by_extension.get("");
330        ensure!(no_ext.is_some(), "expected empty extension");
331        ensure!(no_ext.unwrap().count == 2, "no-ext count should be 2");
332        Ok(())
333    }
334
335    #[test]
336    fn filestats_bytes_key() -> anyhow::Result<()> {
337        let mut collector = FileStatsCollector::new();
338        collector.add_entry_bytes(b"notes.txt", 42);
339        collector.add_entry_bytes(b"sub/doc.pdf", 300);
340        let report = collector.report();
341        ensure!(report.total_files == 2, "total_files should be 2");
342        let txt = report.by_extension.get("txt");
343        ensure!(txt.is_some(), "expected txt extension");
344        ensure!(txt.unwrap().count == 1, "txt count should be 1");
345        let pdf = report.by_extension.get("pdf");
346        ensure!(pdf.is_some(), "expected pdf extension");
347        ensure!(pdf.unwrap().count == 1, "pdf count should be 1");
348        Ok(())
349    }
350
351    #[test]
352    fn filestats_extension_case_insensitive() -> anyhow::Result<()> {
353        let mut collector = FileStatsCollector::new();
354        collector.add_entry("a.TXT", 5);
355        collector.add_entry("b.txt", 10);
356        let report = collector.report();
357        ensure!(report.by_extension.len() == 1, "should be 1 extension");
358        let txt = report.by_extension.get("txt");
359        ensure!(txt.is_some(), "expected txt extension");
360        ensure!(txt.unwrap().count == 2, "txt count should be 2");
361        Ok(())
362    }
363
364    #[test]
365    fn filestats_empty_path() {
366        let mut collector = FileStatsCollector::new();
367        collector.add_entry("", 0);
368        let report = collector.report();
369        assert_eq!(report.total_files, 1);
370    }
371
372    #[test]
373    fn filestats_time_distribution() {
374        let mut collector = FileStatsCollector::new();
375        let now = SystemTime::now();
376        let ts = |offset_secs: i64| -> u64 {
377            let t = if offset_secs >= 0 {
378                now + std::time::Duration::from_secs(offset_secs.unsigned_abs())
379            } else {
380                now - std::time::Duration::from_secs(offset_secs.unsigned_abs())
381            };
382            let d = t.duration_since(UNIX_EPOCH).unwrap();
383            d.as_secs()
384                .saturating_mul(1_000_000)
385                .saturating_add(u64::from(d.subsec_micros()))
386        };
387        collector.add_entry_with_time("recent.txt", 100, Some(ts(0)));
388        collector.add_entry_with_time("old.txt", 200, Some(ts(-366 * 24 * 3600)));
389        collector.add_entry_with_time("no_time.txt", 50, None);
390        let report = collector.report();
391        assert_eq!(report.time_buckets.get("<24h").copied().unwrap_or(0), 1);
392        assert_eq!(report.time_buckets.get(">1y").copied().unwrap_or(0), 1);
393        assert_eq!(report.time_buckets.get("unknown").copied().unwrap_or(0), 1);
394    }
395
396    #[test]
397    fn filestats_time_all_buckets() {
398        let mut collector = FileStatsCollector::new();
399        let now = SystemTime::now();
400        let micros = |secs_ago: u64| -> u64 {
401            let t = now - std::time::Duration::from_secs(secs_ago);
402            let d = t.duration_since(UNIX_EPOCH).unwrap();
403            d.as_secs()
404                .saturating_mul(1_000_000)
405                .saturating_add(u64::from(d.subsec_micros()))
406        };
407        // <24h: 1 hour ago
408        collector.add_entry_with_time("hour_ago.txt", 10, Some(micros(3600)));
409        // 1d-7d: 2 days ago
410        collector.add_entry_with_time("two_days.txt", 10, Some(micros(2 * 86400)));
411        // 7d-30d: 14 days ago
412        collector.add_entry_with_time("two_weeks.txt", 10, Some(micros(14 * 86400)));
413        // 1m-1y: 60 days ago
414        collector.add_entry_with_time("two_months.txt", 10, Some(micros(60 * 86400)));
415        // >1y: 400 days ago
416        collector.add_entry_with_time("year_plus.txt", 10, Some(micros(400 * 86400)));
417        let report = collector.report();
418        assert_eq!(report.time_buckets.len(), 5, "all five time buckets should be present");
419        assert_eq!(report.time_buckets.get("<24h").copied().unwrap_or(0), 1);
420        assert_eq!(report.time_buckets.get("1d-7d").copied().unwrap_or(0), 1);
421        assert_eq!(report.time_buckets.get("7d-30d").copied().unwrap_or(0), 1);
422        assert_eq!(report.time_buckets.get("1m-1y").copied().unwrap_or(0), 1);
423        assert_eq!(report.time_buckets.get(">1y").copied().unwrap_or(0), 1);
424    }
425
426    #[test]
427    fn filestats_time_via_bytes() {
428        let mut collector = FileStatsCollector::new();
429        collector.add_entry_bytes_with_time(b"a.txt", 5, Some(1_700_000_000_000_000));
430        collector.add_entry_bytes_with_time(b"b.txt", 10, Some(1_700_000_000_000_001));
431        let report = collector.report();
432        assert_eq!(report.total_files, 2);
433        assert!(
434            report.time_buckets.contains_key("<24h")
435                || report.time_buckets.contains_key("1d-7d")
436                || report.time_buckets.contains_key(">1y")
437        );
438    }
439}