Skip to main content

netrunner_core/
history.rs

1//! History Storage - Using Redb Embedded Database
2//!
3//! A robust, fast, and efficient history storage system using the redb embedded database.
4//! Features:
5//! - ACID transactions
6//! - Type-safe table definitions
7//! - Crash recovery
8//! - Compact storage
9
10use 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    /// Create a new history storage instance
69    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    /// Create a new history storage instance with custom path (for testing)
77    #[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    /// Get the database path
84    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        // Migration guard: old (pre-redb) versions stored history in a *sled*
93        // database, which is a DIRECTORY at this same path. redb needs a file,
94        // so `Database::create` would fail with "Is a directory". The sled data
95        // is unreadable by current code (the dependency was removed), so remove
96        // the stale directory to unblock redb. A real redb database is a file,
97        // never a directory, so this never deletes valid history.
98        if db_path.is_dir() {
99            let _ = std::fs::remove_dir_all(&db_path);
100        }
101
102        Ok(db_path)
103    }
104
105    /// Save a test result
106    pub fn save_result(&self, result: &SpeedTestResult) -> Result<(), Box<dyn std::error::Error>> {
107        // Use timestamp as key (nanoseconds since epoch for uniqueness)
108        let key = result
109            .timestamp
110            .timestamp_nanos_opt()
111            .unwrap_or_default()
112            .to_be_bytes();
113
114        // Serialize result
115        let value = postcard::to_stdvec(result)?;
116
117        // Store in database
118        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        // Update statistics
126        self.update_statistics(result)?;
127
128        // Clean up old records (older than 30 days)
129        self.cleanup_old_records()?;
130
131        Ok(())
132    }
133
134    /// Get recent test results
135    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        // Iterate in reverse (newest first) — skip any records that cannot be
145        // decoded (e.g. stale bytes written by an older version).
146        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    /// Get all test results
160    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    /// Get results within a date range
177    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    /// Get results filtered by quality
207    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    /// Get results by server location
220    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    /// Update statistics
233    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        // Update counts
240        stats.test_count += 1;
241
242        // Update download stats
243        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        // Update upload stats
250        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        // Update ping stats
257        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        // Estimate data transferred (rough calculation based on test duration and speed)
263        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        // Update timestamps
268        stats.last_test = result.timestamp;
269        if stats.test_count == 1 {
270            stats.first_test = result.timestamp;
271        }
272
273        // Save updated statistics
274        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    /// Get statistics
286    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            // Fall back to default if the stored bytes cannot be decoded
300            // (e.g. stale bytes written by an older version).
301            Some(value) => Ok(postcard::from_bytes(value.value()).unwrap_or_default()),
302            None => Ok(TestStatistics::default()),
303        }
304    }
305
306    /// Get statistics for a specific date range
307    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        // Calculate statistics
328        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            // Estimate data transferred
347            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    /// Get the number of stored results
368    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    /// Delete a specific result
379    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        // Recalculate statistics
396        self.recalculate_statistics()?;
397
398        Ok(())
399    }
400
401    /// Clear all history
402    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    /// Recalculate all statistics from scratch
412    fn recalculate_statistics(&self) -> Result<(), Box<dyn std::error::Error>> {
413        // Clear stats table
414        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    /// Clean up records older than the retention period (30 days)
428    fn cleanup_old_records(&self) -> Result<(), Box<dyn std::error::Error>> {
429        // Calculate cutoff timestamp (30 days ago)
430        let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
431        let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
432
433        // Collect keys to delete
434        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                // Deserialize to check timestamp
448                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        // Delete old records
467        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            // Recalculate statistics
478            self.recalculate_statistics()?;
479        }
480
481        Ok(())
482    }
483
484    /// Export history to JSON
485    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    /// Import history from JSON
493    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    /// Get database statistics
507    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    /// Optimize database
520    /// Note: redb's compact() requires &mut self which is not available through &self.
521    /// The database already manages its storage efficiently with ACID transactions.
522    pub fn optimize(&self) -> Result<(), Box<dyn std::error::Error>> {
523        // redb handles storage management internally; no explicit optimization needed.
524        Ok(())
525    }
526
527    /// Get fastest recorded download speed
528    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    /// Get fastest recorded upload speed
540    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    /// Get lowest recorded ping
552    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    /// Manually cleanup old records (older than retention period)
562    /// Returns the number of records deleted
563    pub fn cleanup_old_records_manual(&self) -> Result<usize, Box<dyn std::error::Error>> {
564        // Calculate cutoff timestamp (30 days ago)
565        let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
566        let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
567
568        // Collect keys to delete
569        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                // Deserialize to check timestamp
583                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        // Delete old records
602        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            // Recalculate statistics
613            self.recalculate_statistics()?;
614        }
615
616        Ok(deleted_count)
617    }
618
619    /// Get the retention period in days
620    pub const fn get_retention_days() -> i64 {
621        RETENTION_DAYS
622    }
623
624    /// Get speed trends (compares recent results to historical average)
625    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        // Old (pre-redb) installs left a sled DB *directory* at the redb path.
694        // Opening must remove it and create a working redb file.
695        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        // Fabricate a stale sled-style directory at the DB path.
702        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}