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#[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#[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#[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 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 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 pub fn reset(&mut self) {
82 *self = Self::default();
83 }
84
85 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 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 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#[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#[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 pub time_buckets: BTreeMap<String, u64>,
151 pub largest_files: Vec<(String, u64)>,
153}
154
155#[derive(Clone, Debug, Default)]
159pub struct FileStatsCollector {
160 extensions: BTreeMap<String, ExtensionGroup>,
161 sizes: Vec<u64>,
162 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 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 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 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 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 #[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 collector.add_entry_with_time("hour_ago.txt", 10, Some(micros(3600)));
409 collector.add_entry_with_time("two_days.txt", 10, Some(micros(2 * 86400)));
411 collector.add_entry_with_time("two_weeks.txt", 10, Some(micros(14 * 86400)));
413 collector.add_entry_with_time("two_months.txt", 10, Some(micros(60 * 86400)));
415 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}