1use chrono::{DateTime, Utc};
11use redb::{ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15use crate::types::SpeedTestResult;
16
17const DB_NAME: &str = "netrunner_history.db";
18const RETENTION_DAYS: i64 = 30;
19
20const RESULTS_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("test_results");
21const STATS_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("statistics");
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct TestStatistics {
25 pub test_count: usize,
26 pub avg_download_mbps: f64,
27 pub max_download_mbps: f64,
28 pub min_download_mbps: f64,
29 pub avg_upload_mbps: f64,
30 pub max_upload_mbps: f64,
31 pub min_upload_mbps: f64,
32 pub avg_ping_ms: f64,
33 pub min_ping_ms: f64,
34 pub max_ping_ms: f64,
35 pub total_data_downloaded_gb: f64,
36 pub total_data_uploaded_gb: f64,
37 pub first_test: DateTime<Utc>,
38 pub last_test: DateTime<Utc>,
39}
40
41impl Default for TestStatistics {
42 fn default() -> Self {
43 Self {
44 test_count: 0,
45 avg_download_mbps: 0.0,
46 max_download_mbps: 0.0,
47 min_download_mbps: f64::MAX,
48 avg_upload_mbps: 0.0,
49 max_upload_mbps: 0.0,
50 min_upload_mbps: f64::MAX,
51 avg_ping_ms: 0.0,
52 min_ping_ms: f64::MAX,
53 max_ping_ms: 0.0,
54 total_data_downloaded_gb: 0.0,
55 total_data_uploaded_gb: 0.0,
56 first_test: Utc::now(),
57 last_test: Utc::now(),
58 }
59 }
60}
61
62pub struct HistoryStorage {
63 db: redb::Database,
64}
65
66#[allow(dead_code)]
67impl HistoryStorage {
68 pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
70 let db_path = Self::get_db_path()?;
71 let db = redb::Database::create(db_path)?;
72
73 Ok(Self { db })
74 }
75
76 #[cfg(test)]
78 fn new_with_path(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
79 let db = redb::Database::create(path)?;
80 Ok(Self { db })
81 }
82
83 fn get_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
85 let config_dir = dirs::config_dir()
86 .ok_or("Failed to find config directory")?
87 .join("netrunner");
88
89 std::fs::create_dir_all(&config_dir)?;
90 let db_path = config_dir.join(DB_NAME);
91
92 if db_path.is_dir() {
99 let _ = std::fs::remove_dir_all(&db_path);
100 }
101
102 Ok(db_path)
103 }
104
105 pub fn save_result(&self, result: &SpeedTestResult) -> Result<(), Box<dyn std::error::Error>> {
107 let key = result
109 .timestamp
110 .timestamp_nanos_opt()
111 .unwrap_or_default()
112 .to_be_bytes();
113
114 let value = postcard::to_stdvec(result)?;
116
117 let txn = self.db.begin_write()?;
119 {
120 let mut table = txn.open_table(RESULTS_TABLE)?;
121 table.insert(key.as_slice(), value.as_slice())?;
122 }
123 txn.commit()?;
124
125 self.update_statistics(result)?;
127
128 self.cleanup_old_records()?;
130
131 Ok(())
132 }
133
134 pub fn get_recent_results(
136 &self,
137 limit: usize,
138 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
139 let txn = self.db.begin_read()?;
140 let table = txn.open_table(RESULTS_TABLE)?;
141
142 let mut results = Vec::new();
143
144 for item in table.iter()?.rev() {
147 if results.len() >= limit {
148 break;
149 }
150 let (_, value) = item?;
151 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
152 results.push(result);
153 }
154 }
155
156 Ok(results)
157 }
158
159 pub fn get_all_results(&self) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
161 let txn = self.db.begin_read()?;
162 let table = txn.open_table(RESULTS_TABLE)?;
163
164 let mut results = Vec::new();
165
166 for item in table.iter()?.rev() {
167 let (_, value) = item?;
168 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
169 results.push(result);
170 }
171 }
172
173 Ok(results)
174 }
175
176 pub fn get_results_by_date_range(
178 &self,
179 start: DateTime<Utc>,
180 end: DateTime<Utc>,
181 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
182 let txn = self.db.begin_read()?;
183 let table = txn.open_table(RESULTS_TABLE)?;
184
185 let start_key = start
186 .timestamp_nanos_opt()
187 .unwrap_or_default()
188 .to_be_bytes();
189 let end_key = end.timestamp_nanos_opt().unwrap_or_default().to_be_bytes();
190
191 let mut results = Vec::new();
192
193 let start_slice: &[u8] = start_key.as_slice();
194 let end_slice: &[u8] = end_key.as_slice();
195
196 for item in table.range(start_slice..=end_slice)? {
197 let (_, value) = item?;
198 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
199 results.push(result);
200 }
201 }
202
203 Ok(results)
204 }
205
206 pub fn get_results_by_quality(
208 &self,
209 quality: crate::types::ConnectionQuality,
210 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
211 let all_results = self.get_all_results()?;
212
213 Ok(all_results
214 .into_iter()
215 .filter(|r| r.quality == quality)
216 .collect())
217 }
218
219 pub fn get_results_by_server(
221 &self,
222 server_location: &str,
223 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
224 let all_results = self.get_all_results()?;
225
226 Ok(all_results
227 .into_iter()
228 .filter(|r| r.server_location.contains(server_location))
229 .collect())
230 }
231
232 fn update_statistics(
234 &self,
235 result: &SpeedTestResult,
236 ) -> Result<(), Box<dyn std::error::Error>> {
237 let mut stats = self.get_statistics_internal()?;
238
239 stats.test_count += 1;
241
242 stats.avg_download_mbps = (stats.avg_download_mbps * (stats.test_count - 1) as f64
244 + result.download_mbps)
245 / stats.test_count as f64;
246 stats.max_download_mbps = stats.max_download_mbps.max(result.download_mbps);
247 stats.min_download_mbps = stats.min_download_mbps.min(result.download_mbps);
248
249 stats.avg_upload_mbps = (stats.avg_upload_mbps * (stats.test_count - 1) as f64
251 + result.upload_mbps)
252 / stats.test_count as f64;
253 stats.max_upload_mbps = stats.max_upload_mbps.max(result.upload_mbps);
254 stats.min_upload_mbps = stats.min_upload_mbps.min(result.upload_mbps);
255
256 stats.avg_ping_ms = (stats.avg_ping_ms * (stats.test_count - 1) as f64 + result.ping_ms)
258 / stats.test_count as f64;
259 stats.min_ping_ms = stats.min_ping_ms.min(result.ping_ms);
260 stats.max_ping_ms = stats.max_ping_ms.max(result.ping_ms);
261
262 let test_duration_hours = result.test_duration_seconds / 3600.0;
264 stats.total_data_downloaded_gb += result.download_mbps * test_duration_hours / 8.0 / 1000.0;
265 stats.total_data_uploaded_gb += result.upload_mbps * test_duration_hours / 8.0 / 1000.0;
266
267 stats.last_test = result.timestamp;
269 if stats.test_count == 1 {
270 stats.first_test = result.timestamp;
271 }
272
273 let value = postcard::to_stdvec(&stats)?;
275 let txn = self.db.begin_write()?;
276 {
277 let mut table = txn.open_table(STATS_TABLE)?;
278 table.insert(b"global".as_slice(), value.as_slice())?;
279 }
280 txn.commit()?;
281
282 Ok(())
283 }
284
285 pub fn get_statistics(&self) -> Result<TestStatistics, Box<dyn std::error::Error>> {
287 self.get_statistics_internal()
288 }
289
290 fn get_statistics_internal(&self) -> Result<TestStatistics, Box<dyn std::error::Error>> {
291 let txn = self.db.begin_read()?;
292 let table = match txn.open_table(STATS_TABLE) {
293 Ok(t) => t,
294 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(TestStatistics::default()),
295 Err(e) => return Err(e.into()),
296 };
297
298 match table.get(b"global".as_slice())? {
299 Some(value) => Ok(postcard::from_bytes(value.value()).unwrap_or_default()),
302 None => Ok(TestStatistics::default()),
303 }
304 }
305
306 pub fn get_statistics_by_date_range(
308 &self,
309 start: DateTime<Utc>,
310 end: DateTime<Utc>,
311 ) -> Result<TestStatistics, Box<dyn std::error::Error>> {
312 let results = self.get_results_by_date_range(start, end)?;
313
314 if results.is_empty() {
315 return Ok(TestStatistics::default());
316 }
317
318 let mut stats = TestStatistics {
319 test_count: results.len(),
320 max_download_mbps: 0.0,
321 min_download_mbps: f64::MAX,
322 max_upload_mbps: 0.0,
323 min_upload_mbps: f64::MAX,
324 ..Default::default()
325 };
326
327 let mut total_download = 0.0;
329 let mut total_upload = 0.0;
330 let mut total_ping = 0.0;
331 stats.max_ping_ms = 0.0;
332 stats.min_ping_ms = f64::MAX;
333
334 for result in &results {
335 total_download += result.download_mbps;
336 total_upload += result.upload_mbps;
337 total_ping += result.ping_ms;
338
339 stats.max_download_mbps = stats.max_download_mbps.max(result.download_mbps);
340 stats.min_download_mbps = stats.min_download_mbps.min(result.download_mbps);
341 stats.max_upload_mbps = stats.max_upload_mbps.max(result.upload_mbps);
342 stats.min_upload_mbps = stats.min_upload_mbps.min(result.upload_mbps);
343 stats.max_ping_ms = stats.max_ping_ms.max(result.ping_ms);
344 stats.min_ping_ms = stats.min_ping_ms.min(result.ping_ms);
345
346 let test_duration_hours = result.test_duration_seconds / 3600.0;
348 stats.total_data_downloaded_gb +=
349 result.download_mbps * test_duration_hours / 8.0 / 1000.0;
350 stats.total_data_uploaded_gb += result.upload_mbps * test_duration_hours / 8.0 / 1000.0;
351 }
352
353 stats.avg_download_mbps = total_download / results.len() as f64;
354 stats.avg_upload_mbps = total_upload / results.len() as f64;
355 stats.avg_ping_ms = total_ping / results.len() as f64;
356
357 if let Some(first) = results.last() {
358 stats.first_test = first.timestamp;
359 }
360 if let Some(last) = results.first() {
361 stats.last_test = last.timestamp;
362 }
363
364 Ok(stats)
365 }
366
367 pub fn count(&self) -> Result<usize, Box<dyn std::error::Error>> {
369 let txn = self.db.begin_read()?;
370 let table = match txn.open_table(RESULTS_TABLE) {
371 Ok(t) => t,
372 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
373 Err(e) => return Err(e.into()),
374 };
375 Ok(table.len()? as usize)
376 }
377
378 pub fn delete_result(
380 &self,
381 timestamp: DateTime<Utc>,
382 ) -> Result<(), Box<dyn std::error::Error>> {
383 let key = timestamp
384 .timestamp_nanos_opt()
385 .unwrap_or_default()
386 .to_be_bytes();
387
388 let txn = self.db.begin_write()?;
389 {
390 let mut table = txn.open_table(RESULTS_TABLE)?;
391 table.remove(key.as_slice())?;
392 }
393 txn.commit()?;
394
395 self.recalculate_statistics()?;
397
398 Ok(())
399 }
400
401 pub fn clear_history(&self) -> Result<(), Box<dyn std::error::Error>> {
403 let txn = self.db.begin_write()?;
404 txn.delete_table(RESULTS_TABLE)?;
405 txn.delete_table(STATS_TABLE)?;
406 txn.commit()?;
407
408 Ok(())
409 }
410
411 fn recalculate_statistics(&self) -> Result<(), Box<dyn std::error::Error>> {
413 let txn = self.db.begin_write()?;
415 txn.delete_table(STATS_TABLE)?;
416 txn.commit()?;
417
418 let results = self.get_all_results()?;
419
420 for result in results {
421 self.update_statistics(&result)?;
422 }
423
424 Ok(())
425 }
426
427 fn cleanup_old_records(&self) -> Result<(), Box<dyn std::error::Error>> {
429 let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
431 let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
432
433 let mut keys_to_delete = Vec::new();
435
436 {
437 let txn = self.db.begin_read()?;
438 let table = match txn.open_table(RESULTS_TABLE) {
439 Ok(t) => t,
440 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(()),
441 Err(e) => return Err(e.into()),
442 };
443
444 for item in table.iter()? {
445 let (_, value) = item?;
446
447 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
449 let result_nanos = result.timestamp.timestamp_nanos_opt().unwrap_or_default();
450
451 if result_nanos < cutoff_nanos {
452 keys_to_delete.push(
453 result
454 .timestamp
455 .timestamp_nanos_opt()
456 .unwrap_or_default()
457 .to_be_bytes(),
458 );
459 }
460 }
461 }
462 }
463
464 let deleted_count = keys_to_delete.len();
465
466 if deleted_count > 0 {
468 let txn = self.db.begin_write()?;
469 {
470 let mut table = txn.open_table(RESULTS_TABLE)?;
471 for key in &keys_to_delete {
472 table.remove(key.as_slice())?;
473 }
474 }
475 txn.commit()?;
476
477 self.recalculate_statistics()?;
479 }
480
481 Ok(())
482 }
483
484 pub fn export_to_json(&self, path: &str) -> Result<(), Box<dyn std::error::Error>> {
486 let results = self.get_all_results()?;
487 let json = serde_json::to_string_pretty(&results)?;
488 std::fs::write(path, json)?;
489 Ok(())
490 }
491
492 pub fn import_from_json(&self, path: &str) -> Result<usize, Box<dyn std::error::Error>> {
494 let json = std::fs::read_to_string(path)?;
495 let results: Vec<SpeedTestResult> = serde_json::from_str(&json)?;
496
497 let count = results.len();
498
499 for result in results {
500 self.save_result(&result)?;
501 }
502
503 Ok(count)
504 }
505
506 pub fn get_db_stats(&self) -> Result<DbStats, Box<dyn std::error::Error>> {
508 let db_path = Self::get_db_path()?;
509 let size_on_disk = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
510 let results_count = self.count()?;
511
512 Ok(DbStats {
513 size_on_disk,
514 results_count,
515 db_path: db_path.to_string_lossy().to_string(),
516 })
517 }
518
519 pub fn optimize(&self) -> Result<(), Box<dyn std::error::Error>> {
523 Ok(())
525 }
526
527 pub fn get_fastest_download(
529 &self,
530 ) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
531 let results = self.get_all_results()?;
532 Ok(results.into_iter().max_by(|a, b| {
533 a.download_mbps
534 .partial_cmp(&b.download_mbps)
535 .unwrap_or(std::cmp::Ordering::Equal)
536 }))
537 }
538
539 pub fn get_fastest_upload(
541 &self,
542 ) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
543 let results = self.get_all_results()?;
544 Ok(results.into_iter().max_by(|a, b| {
545 a.upload_mbps
546 .partial_cmp(&b.upload_mbps)
547 .unwrap_or(std::cmp::Ordering::Equal)
548 }))
549 }
550
551 pub fn get_lowest_ping(&self) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
553 let results = self.get_all_results()?;
554 Ok(results.into_iter().min_by(|a, b| {
555 a.ping_ms
556 .partial_cmp(&b.ping_ms)
557 .unwrap_or(std::cmp::Ordering::Equal)
558 }))
559 }
560
561 pub fn cleanup_old_records_manual(&self) -> Result<usize, Box<dyn std::error::Error>> {
564 let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
566 let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
567
568 let mut keys_to_delete = Vec::new();
570
571 {
572 let txn = self.db.begin_read()?;
573 let table = match txn.open_table(RESULTS_TABLE) {
574 Ok(t) => t,
575 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
576 Err(e) => return Err(e.into()),
577 };
578
579 for item in table.iter()? {
580 let (_, value) = item?;
581
582 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
584 let result_nanos = result.timestamp.timestamp_nanos_opt().unwrap_or_default();
585
586 if result_nanos < cutoff_nanos {
587 keys_to_delete.push(
588 result
589 .timestamp
590 .timestamp_nanos_opt()
591 .unwrap_or_default()
592 .to_be_bytes(),
593 );
594 }
595 }
596 }
597 }
598
599 let deleted_count = keys_to_delete.len();
600
601 if deleted_count > 0 {
603 let txn = self.db.begin_write()?;
604 {
605 let mut table = txn.open_table(RESULTS_TABLE)?;
606 for key in &keys_to_delete {
607 table.remove(key.as_slice())?;
608 }
609 }
610 txn.commit()?;
611
612 self.recalculate_statistics()?;
614 }
615
616 Ok(deleted_count)
617 }
618
619 pub const fn get_retention_days() -> i64 {
621 RETENTION_DAYS
622 }
623
624 pub fn get_speed_trends(&self) -> Result<SpeedTrends, Box<dyn std::error::Error>> {
626 let all_stats = self.get_statistics()?;
627 let recent_results = self.get_recent_results(10)?;
628
629 if recent_results.is_empty() {
630 return Ok(SpeedTrends::default());
631 }
632
633 let recent_avg_download = recent_results.iter().map(|r| r.download_mbps).sum::<f64>()
634 / recent_results.len() as f64;
635 let recent_avg_upload =
636 recent_results.iter().map(|r| r.upload_mbps).sum::<f64>() / recent_results.len() as f64;
637 let recent_avg_ping =
638 recent_results.iter().map(|r| r.ping_ms).sum::<f64>() / recent_results.len() as f64;
639
640 let download_trend = if all_stats.avg_download_mbps > 0.0 {
641 ((recent_avg_download - all_stats.avg_download_mbps) / all_stats.avg_download_mbps)
642 * 100.0
643 } else {
644 0.0
645 };
646
647 let upload_trend = if all_stats.avg_upload_mbps > 0.0 {
648 ((recent_avg_upload - all_stats.avg_upload_mbps) / all_stats.avg_upload_mbps) * 100.0
649 } else {
650 0.0
651 };
652
653 let ping_trend = if all_stats.avg_ping_ms > 0.0 {
654 ((recent_avg_ping - all_stats.avg_ping_ms) / all_stats.avg_ping_ms) * 100.0
655 } else {
656 0.0
657 };
658
659 Ok(SpeedTrends {
660 download_trend_percent: download_trend,
661 upload_trend_percent: upload_trend,
662 ping_trend_percent: ping_trend,
663 improving: download_trend > 0.0 && upload_trend > 0.0 && ping_trend < 0.0,
664 })
665 }
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
669#[allow(dead_code)]
670pub struct DbStats {
671 pub size_on_disk: u64,
672 pub results_count: usize,
673 pub db_path: String,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, Default)]
677#[allow(dead_code)]
678pub struct SpeedTrends {
679 pub download_trend_percent: f64,
680 pub upload_trend_percent: f64,
681 pub ping_trend_percent: f64,
682 pub improving: bool,
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::types::ConnectionQuality;
689 use tempfile::tempdir;
690
691 #[test]
692 fn migrates_over_stale_sled_directory() {
693 let tmp = std::env::temp_dir().join(format!("nr_sled_migrate_{}", std::process::id()));
696 let _ = std::fs::remove_dir_all(&tmp);
697 std::fs::create_dir_all(&tmp).unwrap();
698 std::env::set_var("HOME", &tmp);
699 std::env::set_var("XDG_CONFIG_HOME", tmp.join(".config"));
700
701 let cfg = dirs::config_dir().unwrap().join("netrunner");
703 std::fs::create_dir_all(&cfg).unwrap();
704 let stale = cfg.join(DB_NAME);
705 std::fs::create_dir_all(stale.join("blobs")).unwrap();
706 std::fs::write(stale.join("conf"), b"stale").unwrap();
707 assert!(stale.is_dir(), "precondition: stale sled dir exists");
708
709 let store = HistoryStorage::new().expect("should open after migrating");
710 let r = SpeedTestResult {
711 download_mbps: 42.0,
712 ..Default::default()
713 };
714 store.save_result(&r).unwrap();
715 assert_eq!(store.get_recent_results(10).unwrap().len(), 1);
716 assert!(
717 HistoryStorage::get_db_path().unwrap().is_file(),
718 "redb path should now be a file"
719 );
720
721 let _ = std::fs::remove_dir_all(&tmp);
722 }
723
724 #[test]
725 fn test_storage_creation() {
726 let temp_dir = tempdir().unwrap();
727 let db_path = temp_dir.path().join("test_db");
728 let storage = HistoryStorage::new_with_path(db_path);
729 assert!(storage.is_ok());
730 }
731
732 #[test]
733 fn test_save_and_retrieve() {
734 let temp_dir = tempdir().unwrap();
735 let db_path = temp_dir.path().join("test_db");
736 let storage = HistoryStorage::new_with_path(db_path).unwrap();
737
738 let result = SpeedTestResult {
739 timestamp: Utc::now(),
740 download_mbps: 100.0,
741 upload_mbps: 50.0,
742 ping_ms: 10.0,
743 jitter_ms: 1.0,
744 packet_loss_percent: 0.0,
745 server_location: "Test Server".to_string(),
746 server_ip: None,
747 client_ip: None,
748 quality: ConnectionQuality::Excellent,
749 test_duration_seconds: 10.0,
750 isp: None,
751 };
752
753 assert!(storage.save_result(&result).is_ok());
754
755 let results = storage.get_recent_results(1).unwrap();
756 assert_eq!(results.len(), 1);
757 assert_eq!(results[0].download_mbps, 100.0);
758 }
759
760 #[test]
761 fn test_statistics() {
762 let temp_dir = tempdir().unwrap();
763 let db_path = temp_dir.path().join("test_db");
764 let storage = HistoryStorage::new_with_path(db_path).unwrap();
765
766 let stats = storage.get_statistics();
767 assert!(stats.is_ok());
768 }
769}