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#[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: 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 pub fn reset(&mut self) {
105 *self = Self::default();
106 }
107
108 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 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 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#[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#[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 pub time_buckets: BTreeMap<String, u64>,
174}
175
176#[derive(Clone, Debug, Default)]
180pub struct FileStatsCollector {
181 extensions: BTreeMap<String, ExtensionGroup>,
182 sizes: Vec<u64>,
183 timestamps: Vec<Option<u64>>,
186}
187
188impl FileStatsCollector {
189 #[must_use]
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 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 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 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 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 #[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 collector.add_entry_with_time("hour_ago.txt", 10, Some(micros(3600)));
426 collector.add_entry_with_time("two_days.txt", 10, Some(micros(2 * 86400)));
428 collector.add_entry_with_time("two_weeks.txt", 10, Some(micros(14 * 86400)));
430 collector.add_entry_with_time("two_months.txt", 10, Some(micros(60 * 86400)));
432 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}