Skip to main content

netspeed_cli/
storage.rs

1//! Result storage trait for test results.
2//!
3//! Enables dependency injection for different storage backends.
4
5use crate::error::Error;
6
7/// Minimal trait for persisting a speed‑test report.
8pub trait ResultSink {
9    fn write_report(&self, report: &crate::domain::reporting::Report) -> Result<(), Error>;
10}
11
12use crate::types::TestResult;
13
14/// Trait for persisting a single test result.
15/// Implementations may be file‑based, cloud‑based, etc.
16pub trait SaveResult: Send + Sync {
17    fn save(&self, result: &TestResult) -> Result<(), Error>;
18}
19
20/// Trait for reading historic results (optional).
21/// Not all storage backends need to implement this – e.g. a transient
22/// in‑memory store can choose to omit history support.
23pub trait LoadHistory: Send + Sync {
24    fn load_recent(&self, limit: usize) -> Result<Vec<TestResult>, Error>;
25    fn clear(&self) -> Result<(), Error>;
26}
27
28/// Combined trait for storage that supports both saving and loading.
29/// Provides cleaner dependency injection than separate traits.
30pub trait HistoryStorage: Send + Sync {
31    fn save(&self, result: &TestResult) -> Result<(), Error>;
32    fn load_history(&self, limit: usize) -> Result<Vec<TestResult>, Error>;
33    fn clear_history(&self) -> Result<(), Error>;
34}
35
36impl<T: SaveResult + LoadHistory> HistoryStorage for T {
37    fn save(&self, result: &TestResult) -> Result<(), Error> {
38        SaveResult::save(self, result)
39    }
40
41    fn load_history(&self, limit: usize) -> Result<Vec<TestResult>, Error> {
42        LoadHistory::load_recent(self, limit)
43    }
44
45    fn clear_history(&self) -> Result<(), Error> {
46        LoadHistory::clear(self)
47    }
48}
49
50/// File-based storage implementation using history module.
51pub struct FileStorage {
52    // implements both SaveResult/LoadHistory and ResultSink
53    _path: std::path::PathBuf,
54}
55
56impl FileStorage {
57    pub fn new() -> Self {
58        Self {
59            _path: std::path::PathBuf::new(),
60        }
61    }
62
63    pub fn with_path(path: std::path::PathBuf) -> Self {
64        Self { _path: path }
65    }
66}
67
68impl Default for FileStorage {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl SaveResult for FileStorage {
75    fn save(&self, result: &TestResult) -> Result<(), Error> {
76        crate::history::save_result(result)
77    }
78}
79
80impl LoadHistory for FileStorage {
81    fn load_recent(&self, limit: usize) -> Result<Vec<TestResult>, Error> {
82        let entries = crate::history::load()?;
83        let converted: Vec<TestResult> = entries
84            .into_iter()
85            .rev()
86            .take(limit)
87            .map(|e| TestResult {
88                timestamp: e.timestamp,
89                server: crate::types::ServerInfo {
90                    id: "0".to_string(),
91                    name: e.server_name,
92                    sponsor: e.sponsor,
93                    country: "".to_string(),
94                    distance: 0.0,
95                },
96                ping: e.ping,
97                jitter: e.jitter,
98                packet_loss: e.packet_loss,
99                download: e.download,
100                download_peak: e.download_peak,
101                upload: e.upload,
102                upload_peak: e.upload_peak,
103                latency_download: e.latency_download,
104                latency_upload: e.latency_upload,
105                client_ip: e.client_ip,
106                ..TestResult::default()
107            })
108            .collect();
109        Ok(converted)
110    }
111
112    fn clear(&self) -> Result<(), Error> {
113        Ok(())
114    }
115}
116
117impl ResultSink for FileStorage {
118    fn write_report(&self, report: &crate::domain::reporting::Report) -> Result<(), Error> {
119        // Reuse the history module which already knows how to serialize a Report.
120        crate::history::save_report(report)
121    }
122}
123
124/// In-memory storage for testing - does not persist to disk.
125pub struct MockStorage {
126    results: std::sync::Mutex<Vec<TestResult>>,
127}
128
129impl MockStorage {
130    pub fn new() -> Self {
131        Self {
132            results: std::sync::Mutex::new(Vec::new()),
133        }
134    }
135
136    pub fn with_results(results: Vec<TestResult>) -> Self {
137        Self {
138            results: std::sync::Mutex::new(results),
139        }
140    }
141}
142
143impl Default for MockStorage {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl SaveResult for MockStorage {
150    fn save(&self, result: &TestResult) -> Result<(), Error> {
151        let mut guard = self
152            .results
153            .lock()
154            .map_err(|e| Error::context(format!("mock storage lock poisoned: {e}")))?;
155        guard.push(result.clone());
156        Ok(())
157    }
158}
159
160impl LoadHistory for MockStorage {
161    fn load_recent(&self, limit: usize) -> Result<Vec<TestResult>, Error> {
162        let guard = self
163            .results
164            .lock()
165            .map_err(|e| Error::context(format!("mock storage lock poisoned: {e}")))?;
166        Ok(guard.iter().rev().take(limit).cloned().collect())
167    }
168
169    fn clear(&self) -> Result<(), Error> {
170        let mut guard = self
171            .results
172            .lock()
173            .map_err(|e| Error::context(format!("mock storage lock poisoned: {e}")))?;
174        guard.clear();
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn make_test_result(id: &str) -> TestResult {
184        TestResult {
185            status: "ok".to_string(),
186            version: "0.0.0".to_string(),
187            test_id: Some(id.to_string()),
188            server: crate::types::ServerInfo {
189                id: id.to_string(),
190                name: "Test".to_string(),
191                sponsor: "ISP".to_string(),
192                country: "US".to_string(),
193                distance: 100.0,
194            },
195            ping: Some(10.0),
196            jitter: Some(1.0),
197            packet_loss: Some(0.0),
198            download: Some(100_000_000.0),
199            download_peak: Some(120_000_000.0),
200            upload: Some(50_000_000.0),
201            upload_peak: Some(60_000_000.0),
202            download_cv: None,
203            upload_cv: None,
204            download_ci_95: None,
205            upload_ci_95: None,
206            latency_download: None,
207            latency_upload: None,
208            download_samples: None,
209            upload_samples: None,
210            ping_samples: None,
211            timestamp: "2026-01-01T00:00:00Z".to_string(),
212            client_ip: Some("1.2.3.4".to_string()),
213            client_location: None,
214            overall_grade: None,
215            download_grade: None,
216            upload_grade: None,
217            connection_rating: None,
218            phases: crate::types::TestPhases {
219                ping: crate::types::PhaseResult::completed(),
220                download: crate::types::PhaseResult::completed(),
221                upload: crate::types::PhaseResult::completed(),
222            },
223        }
224    }
225
226    #[test]
227    fn test_mock_storage_save_load_round_trip() {
228        let storage = MockStorage::new();
229        let result = make_test_result("abc");
230
231        <dyn SaveResult>::save(&storage, &result).unwrap();
232
233        let loaded = <dyn LoadHistory>::load_recent(&storage, 10).unwrap();
234        assert_eq!(loaded.len(), 1);
235        assert_eq!(loaded[0].test_id, Some("abc".to_string()));
236    }
237
238    #[test]
239    fn test_mock_storage_with_results() {
240        let r1 = make_test_result("first");
241        let r2 = make_test_result("second");
242        let storage = MockStorage::with_results(vec![r1, r2]);
243
244        let loaded = <dyn LoadHistory>::load_recent(&storage, 10).unwrap();
245        assert_eq!(loaded.len(), 2);
246    }
247
248    #[test]
249    fn test_mock_storage_load_recent_limit() {
250        let storage = MockStorage::with_results(vec![
251            make_test_result("a"),
252            make_test_result("b"),
253            make_test_result("c"),
254        ]);
255
256        let loaded = <dyn LoadHistory>::load_recent(&storage, 2).unwrap();
257        assert_eq!(loaded.len(), 2);
258        assert_eq!(loaded[0].test_id, Some("c".to_string()));
259        assert_eq!(loaded[1].test_id, Some("b".to_string()));
260    }
261
262    #[test]
263    fn test_mock_storage_clear() {
264        let storage = MockStorage::with_results(vec![make_test_result("x")]);
265        assert_eq!(
266            <dyn LoadHistory>::load_recent(&storage, 10).unwrap().len(),
267            1
268        );
269
270        <dyn LoadHistory>::clear(&storage).unwrap();
271        assert!(
272            <dyn LoadHistory>::load_recent(&storage, 10)
273                .unwrap()
274                .is_empty()
275        );
276    }
277
278    #[test]
279    fn test_mock_storage_empty_load() {
280        let storage = MockStorage::new();
281        let loaded = <dyn LoadHistory>::load_recent(&storage, 10).unwrap();
282        assert!(loaded.is_empty());
283    }
284
285    #[test]
286    #[serial_test::serial]
287    fn test_history_storage_for_file_storage() {
288        let storage = crate::storage::FileStorage::new();
289        let result = make_test_result("hist");
290        if <dyn SaveResult>::save(&storage, &result).is_err() {
291            return;
292        }
293        if let Ok(loaded) = <dyn HistoryStorage>::load_history(&storage, 1) {
294            assert_eq!(loaded.len(), 1);
295            let _ = <dyn HistoryStorage>::clear_history(&storage);
296        }
297    }
298}