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 Ok(config_dir.join(DB_NAME))
91 }
92
93 pub fn save_result(&self, result: &SpeedTestResult) -> Result<(), Box<dyn std::error::Error>> {
95 let key = result
97 .timestamp
98 .timestamp_nanos_opt()
99 .unwrap_or_default()
100 .to_be_bytes();
101
102 let value = postcard::to_stdvec(result)?;
104
105 let txn = self.db.begin_write()?;
107 {
108 let mut table = txn.open_table(RESULTS_TABLE)?;
109 table.insert(key.as_slice(), value.as_slice())?;
110 }
111 txn.commit()?;
112
113 self.update_statistics(result)?;
115
116 self.cleanup_old_records()?;
118
119 Ok(())
120 }
121
122 pub fn get_recent_results(
124 &self,
125 limit: usize,
126 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
127 let txn = self.db.begin_read()?;
128 let table = txn.open_table(RESULTS_TABLE)?;
129
130 let mut results = Vec::new();
131
132 for item in table.iter()?.rev() {
135 if results.len() >= limit {
136 break;
137 }
138 let (_, value) = item?;
139 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
140 results.push(result);
141 }
142 }
143
144 Ok(results)
145 }
146
147 pub fn get_all_results(&self) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
149 let txn = self.db.begin_read()?;
150 let table = txn.open_table(RESULTS_TABLE)?;
151
152 let mut results = Vec::new();
153
154 for item in table.iter()?.rev() {
155 let (_, value) = item?;
156 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
157 results.push(result);
158 }
159 }
160
161 Ok(results)
162 }
163
164 pub fn get_results_by_date_range(
166 &self,
167 start: DateTime<Utc>,
168 end: DateTime<Utc>,
169 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
170 let txn = self.db.begin_read()?;
171 let table = txn.open_table(RESULTS_TABLE)?;
172
173 let start_key = start
174 .timestamp_nanos_opt()
175 .unwrap_or_default()
176 .to_be_bytes();
177 let end_key = end.timestamp_nanos_opt().unwrap_or_default().to_be_bytes();
178
179 let mut results = Vec::new();
180
181 let start_slice: &[u8] = start_key.as_slice();
182 let end_slice: &[u8] = end_key.as_slice();
183
184 for item in table.range(start_slice..=end_slice)? {
185 let (_, value) = item?;
186 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
187 results.push(result);
188 }
189 }
190
191 Ok(results)
192 }
193
194 pub fn get_results_by_quality(
196 &self,
197 quality: crate::types::ConnectionQuality,
198 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
199 let all_results = self.get_all_results()?;
200
201 Ok(all_results
202 .into_iter()
203 .filter(|r| r.quality == quality)
204 .collect())
205 }
206
207 pub fn get_results_by_server(
209 &self,
210 server_location: &str,
211 ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
212 let all_results = self.get_all_results()?;
213
214 Ok(all_results
215 .into_iter()
216 .filter(|r| r.server_location.contains(server_location))
217 .collect())
218 }
219
220 fn update_statistics(
222 &self,
223 result: &SpeedTestResult,
224 ) -> Result<(), Box<dyn std::error::Error>> {
225 let mut stats = self.get_statistics_internal()?;
226
227 stats.test_count += 1;
229
230 stats.avg_download_mbps = (stats.avg_download_mbps * (stats.test_count - 1) as f64
232 + result.download_mbps)
233 / stats.test_count as f64;
234 stats.max_download_mbps = stats.max_download_mbps.max(result.download_mbps);
235 stats.min_download_mbps = stats.min_download_mbps.min(result.download_mbps);
236
237 stats.avg_upload_mbps = (stats.avg_upload_mbps * (stats.test_count - 1) as f64
239 + result.upload_mbps)
240 / stats.test_count as f64;
241 stats.max_upload_mbps = stats.max_upload_mbps.max(result.upload_mbps);
242 stats.min_upload_mbps = stats.min_upload_mbps.min(result.upload_mbps);
243
244 stats.avg_ping_ms = (stats.avg_ping_ms * (stats.test_count - 1) as f64 + result.ping_ms)
246 / stats.test_count as f64;
247 stats.min_ping_ms = stats.min_ping_ms.min(result.ping_ms);
248 stats.max_ping_ms = stats.max_ping_ms.max(result.ping_ms);
249
250 let test_duration_hours = result.test_duration_seconds / 3600.0;
252 stats.total_data_downloaded_gb += result.download_mbps * test_duration_hours / 8.0 / 1000.0;
253 stats.total_data_uploaded_gb += result.upload_mbps * test_duration_hours / 8.0 / 1000.0;
254
255 stats.last_test = result.timestamp;
257 if stats.test_count == 1 {
258 stats.first_test = result.timestamp;
259 }
260
261 let value = postcard::to_stdvec(&stats)?;
263 let txn = self.db.begin_write()?;
264 {
265 let mut table = txn.open_table(STATS_TABLE)?;
266 table.insert(b"global".as_slice(), value.as_slice())?;
267 }
268 txn.commit()?;
269
270 Ok(())
271 }
272
273 pub fn get_statistics(&self) -> Result<TestStatistics, Box<dyn std::error::Error>> {
275 self.get_statistics_internal()
276 }
277
278 fn get_statistics_internal(&self) -> Result<TestStatistics, Box<dyn std::error::Error>> {
279 let txn = self.db.begin_read()?;
280 let table = match txn.open_table(STATS_TABLE) {
281 Ok(t) => t,
282 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(TestStatistics::default()),
283 Err(e) => return Err(e.into()),
284 };
285
286 match table.get(b"global".as_slice())? {
287 Some(value) => Ok(postcard::from_bytes(value.value()).unwrap_or_default()),
290 None => Ok(TestStatistics::default()),
291 }
292 }
293
294 pub fn get_statistics_by_date_range(
296 &self,
297 start: DateTime<Utc>,
298 end: DateTime<Utc>,
299 ) -> Result<TestStatistics, Box<dyn std::error::Error>> {
300 let results = self.get_results_by_date_range(start, end)?;
301
302 if results.is_empty() {
303 return Ok(TestStatistics::default());
304 }
305
306 let mut stats = TestStatistics {
307 test_count: results.len(),
308 max_download_mbps: 0.0,
309 min_download_mbps: f64::MAX,
310 max_upload_mbps: 0.0,
311 min_upload_mbps: f64::MAX,
312 ..Default::default()
313 };
314
315 let mut total_download = 0.0;
317 let mut total_upload = 0.0;
318 let mut total_ping = 0.0;
319 stats.max_ping_ms = 0.0;
320 stats.min_ping_ms = f64::MAX;
321
322 for result in &results {
323 total_download += result.download_mbps;
324 total_upload += result.upload_mbps;
325 total_ping += result.ping_ms;
326
327 stats.max_download_mbps = stats.max_download_mbps.max(result.download_mbps);
328 stats.min_download_mbps = stats.min_download_mbps.min(result.download_mbps);
329 stats.max_upload_mbps = stats.max_upload_mbps.max(result.upload_mbps);
330 stats.min_upload_mbps = stats.min_upload_mbps.min(result.upload_mbps);
331 stats.max_ping_ms = stats.max_ping_ms.max(result.ping_ms);
332 stats.min_ping_ms = stats.min_ping_ms.min(result.ping_ms);
333
334 let test_duration_hours = result.test_duration_seconds / 3600.0;
336 stats.total_data_downloaded_gb +=
337 result.download_mbps * test_duration_hours / 8.0 / 1000.0;
338 stats.total_data_uploaded_gb += result.upload_mbps * test_duration_hours / 8.0 / 1000.0;
339 }
340
341 stats.avg_download_mbps = total_download / results.len() as f64;
342 stats.avg_upload_mbps = total_upload / results.len() as f64;
343 stats.avg_ping_ms = total_ping / results.len() as f64;
344
345 if let Some(first) = results.last() {
346 stats.first_test = first.timestamp;
347 }
348 if let Some(last) = results.first() {
349 stats.last_test = last.timestamp;
350 }
351
352 Ok(stats)
353 }
354
355 pub fn count(&self) -> Result<usize, Box<dyn std::error::Error>> {
357 let txn = self.db.begin_read()?;
358 let table = match txn.open_table(RESULTS_TABLE) {
359 Ok(t) => t,
360 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
361 Err(e) => return Err(e.into()),
362 };
363 Ok(table.len()? as usize)
364 }
365
366 pub fn delete_result(
368 &self,
369 timestamp: DateTime<Utc>,
370 ) -> Result<(), Box<dyn std::error::Error>> {
371 let key = timestamp
372 .timestamp_nanos_opt()
373 .unwrap_or_default()
374 .to_be_bytes();
375
376 let txn = self.db.begin_write()?;
377 {
378 let mut table = txn.open_table(RESULTS_TABLE)?;
379 table.remove(key.as_slice())?;
380 }
381 txn.commit()?;
382
383 self.recalculate_statistics()?;
385
386 Ok(())
387 }
388
389 pub fn clear_history(&self) -> Result<(), Box<dyn std::error::Error>> {
391 let txn = self.db.begin_write()?;
392 txn.delete_table(RESULTS_TABLE)?;
393 txn.delete_table(STATS_TABLE)?;
394 txn.commit()?;
395
396 Ok(())
397 }
398
399 fn recalculate_statistics(&self) -> Result<(), Box<dyn std::error::Error>> {
401 let txn = self.db.begin_write()?;
403 txn.delete_table(STATS_TABLE)?;
404 txn.commit()?;
405
406 let results = self.get_all_results()?;
407
408 for result in results {
409 self.update_statistics(&result)?;
410 }
411
412 Ok(())
413 }
414
415 fn cleanup_old_records(&self) -> Result<(), Box<dyn std::error::Error>> {
417 let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
419 let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
420
421 let mut keys_to_delete = Vec::new();
423
424 {
425 let txn = self.db.begin_read()?;
426 let table = match txn.open_table(RESULTS_TABLE) {
427 Ok(t) => t,
428 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(()),
429 Err(e) => return Err(e.into()),
430 };
431
432 for item in table.iter()? {
433 let (_, value) = item?;
434
435 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
437 let result_nanos = result.timestamp.timestamp_nanos_opt().unwrap_or_default();
438
439 if result_nanos < cutoff_nanos {
440 keys_to_delete.push(
441 result
442 .timestamp
443 .timestamp_nanos_opt()
444 .unwrap_or_default()
445 .to_be_bytes(),
446 );
447 }
448 }
449 }
450 }
451
452 let deleted_count = keys_to_delete.len();
453
454 if deleted_count > 0 {
456 let txn = self.db.begin_write()?;
457 {
458 let mut table = txn.open_table(RESULTS_TABLE)?;
459 for key in &keys_to_delete {
460 table.remove(key.as_slice())?;
461 }
462 }
463 txn.commit()?;
464
465 self.recalculate_statistics()?;
467 }
468
469 Ok(())
470 }
471
472 pub fn export_to_json(&self, path: &str) -> Result<(), Box<dyn std::error::Error>> {
474 let results = self.get_all_results()?;
475 let json = serde_json::to_string_pretty(&results)?;
476 std::fs::write(path, json)?;
477 Ok(())
478 }
479
480 pub fn import_from_json(&self, path: &str) -> Result<usize, Box<dyn std::error::Error>> {
482 let json = std::fs::read_to_string(path)?;
483 let results: Vec<SpeedTestResult> = serde_json::from_str(&json)?;
484
485 let count = results.len();
486
487 for result in results {
488 self.save_result(&result)?;
489 }
490
491 Ok(count)
492 }
493
494 pub fn get_db_stats(&self) -> Result<DbStats, Box<dyn std::error::Error>> {
496 let db_path = Self::get_db_path()?;
497 let size_on_disk = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
498 let results_count = self.count()?;
499
500 Ok(DbStats {
501 size_on_disk,
502 results_count,
503 db_path: db_path.to_string_lossy().to_string(),
504 })
505 }
506
507 pub fn optimize(&self) -> Result<(), Box<dyn std::error::Error>> {
511 Ok(())
513 }
514
515 pub fn get_fastest_download(
517 &self,
518 ) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
519 let results = self.get_all_results()?;
520 Ok(results.into_iter().max_by(|a, b| {
521 a.download_mbps
522 .partial_cmp(&b.download_mbps)
523 .unwrap_or(std::cmp::Ordering::Equal)
524 }))
525 }
526
527 pub fn get_fastest_upload(
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.upload_mbps
534 .partial_cmp(&b.upload_mbps)
535 .unwrap_or(std::cmp::Ordering::Equal)
536 }))
537 }
538
539 pub fn get_lowest_ping(&self) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
541 let results = self.get_all_results()?;
542 Ok(results.into_iter().min_by(|a, b| {
543 a.ping_ms
544 .partial_cmp(&b.ping_ms)
545 .unwrap_or(std::cmp::Ordering::Equal)
546 }))
547 }
548
549 pub fn cleanup_old_records_manual(&self) -> Result<usize, Box<dyn std::error::Error>> {
552 let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
554 let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
555
556 let mut keys_to_delete = Vec::new();
558
559 {
560 let txn = self.db.begin_read()?;
561 let table = match txn.open_table(RESULTS_TABLE) {
562 Ok(t) => t,
563 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
564 Err(e) => return Err(e.into()),
565 };
566
567 for item in table.iter()? {
568 let (_, value) = item?;
569
570 if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
572 let result_nanos = result.timestamp.timestamp_nanos_opt().unwrap_or_default();
573
574 if result_nanos < cutoff_nanos {
575 keys_to_delete.push(
576 result
577 .timestamp
578 .timestamp_nanos_opt()
579 .unwrap_or_default()
580 .to_be_bytes(),
581 );
582 }
583 }
584 }
585 }
586
587 let deleted_count = keys_to_delete.len();
588
589 if deleted_count > 0 {
591 let txn = self.db.begin_write()?;
592 {
593 let mut table = txn.open_table(RESULTS_TABLE)?;
594 for key in &keys_to_delete {
595 table.remove(key.as_slice())?;
596 }
597 }
598 txn.commit()?;
599
600 self.recalculate_statistics()?;
602 }
603
604 Ok(deleted_count)
605 }
606
607 pub const fn get_retention_days() -> i64 {
609 RETENTION_DAYS
610 }
611
612 pub fn get_speed_trends(&self) -> Result<SpeedTrends, Box<dyn std::error::Error>> {
614 let all_stats = self.get_statistics()?;
615 let recent_results = self.get_recent_results(10)?;
616
617 if recent_results.is_empty() {
618 return Ok(SpeedTrends::default());
619 }
620
621 let recent_avg_download = recent_results.iter().map(|r| r.download_mbps).sum::<f64>()
622 / recent_results.len() as f64;
623 let recent_avg_upload =
624 recent_results.iter().map(|r| r.upload_mbps).sum::<f64>() / recent_results.len() as f64;
625 let recent_avg_ping =
626 recent_results.iter().map(|r| r.ping_ms).sum::<f64>() / recent_results.len() as f64;
627
628 let download_trend = if all_stats.avg_download_mbps > 0.0 {
629 ((recent_avg_download - all_stats.avg_download_mbps) / all_stats.avg_download_mbps)
630 * 100.0
631 } else {
632 0.0
633 };
634
635 let upload_trend = if all_stats.avg_upload_mbps > 0.0 {
636 ((recent_avg_upload - all_stats.avg_upload_mbps) / all_stats.avg_upload_mbps) * 100.0
637 } else {
638 0.0
639 };
640
641 let ping_trend = if all_stats.avg_ping_ms > 0.0 {
642 ((recent_avg_ping - all_stats.avg_ping_ms) / all_stats.avg_ping_ms) * 100.0
643 } else {
644 0.0
645 };
646
647 Ok(SpeedTrends {
648 download_trend_percent: download_trend,
649 upload_trend_percent: upload_trend,
650 ping_trend_percent: ping_trend,
651 improving: download_trend > 0.0 && upload_trend > 0.0 && ping_trend < 0.0,
652 })
653 }
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
657#[allow(dead_code)]
658pub struct DbStats {
659 pub size_on_disk: u64,
660 pub results_count: usize,
661 pub db_path: String,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, Default)]
665#[allow(dead_code)]
666pub struct SpeedTrends {
667 pub download_trend_percent: f64,
668 pub upload_trend_percent: f64,
669 pub ping_trend_percent: f64,
670 pub improving: bool,
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::types::ConnectionQuality;
677 use tempfile::tempdir;
678
679 #[test]
680 fn test_storage_creation() {
681 let temp_dir = tempdir().unwrap();
682 let db_path = temp_dir.path().join("test_db");
683 let storage = HistoryStorage::new_with_path(db_path);
684 assert!(storage.is_ok());
685 }
686
687 #[test]
688 fn test_save_and_retrieve() {
689 let temp_dir = tempdir().unwrap();
690 let db_path = temp_dir.path().join("test_db");
691 let storage = HistoryStorage::new_with_path(db_path).unwrap();
692
693 let result = SpeedTestResult {
694 timestamp: Utc::now(),
695 download_mbps: 100.0,
696 upload_mbps: 50.0,
697 ping_ms: 10.0,
698 jitter_ms: 1.0,
699 packet_loss_percent: 0.0,
700 server_location: "Test Server".to_string(),
701 server_ip: None,
702 client_ip: None,
703 quality: ConnectionQuality::Excellent,
704 test_duration_seconds: 10.0,
705 isp: None,
706 };
707
708 assert!(storage.save_result(&result).is_ok());
709
710 let results = storage.get_recent_results(1).unwrap();
711 assert_eq!(results.len(), 1);
712 assert_eq!(results[0].download_mbps, 100.0);
713 }
714
715 #[test]
716 fn test_statistics() {
717 let temp_dir = tempdir().unwrap();
718 let db_path = temp_dir.path().join("test_db");
719 let storage = HistoryStorage::new_with_path(db_path).unwrap();
720
721 let stats = storage.get_statistics();
722 assert!(stats.is_ok());
723 }
724}