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}
152
153#[derive(Clone, Debug, Default)]
157pub struct FileStatsCollector {
158 extensions: BTreeMap<String, ExtensionGroup>,
159 sizes: Vec<u64>,
160 timestamps: Vec<Option<u64>>,
163}
164
165impl FileStatsCollector {
166 #[must_use]
167 pub fn new() -> Self {
168 Self::default()
169 }
170
171 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 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 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 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 #[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 collector.add_entry_with_time("hour_ago.txt", 10, Some(micros(3600)));
401 collector.add_entry_with_time("two_days.txt", 10, Some(micros(2 * 86400)));
403 collector.add_entry_with_time("two_weeks.txt", 10, Some(micros(14 * 86400)));
405 collector.add_entry_with_time("two_months.txt", 10, Some(micros(60 * 86400)));
407 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}