Skip to main content

syncweb_core/
stats.rs

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