Skip to main content

netspeed_cli/
history.rs

1use crate::error::SpeedtestError;
2use crate::types::TestResult;
3use directories::ProjectDirs;
4use owo_colors::OwoColorize;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Serialize, Deserialize, Debug)]
10pub struct HistoryEntry {
11    pub timestamp: String,
12    pub server_name: String,
13    pub sponsor: String,
14    pub ping: Option<f64>,
15    pub jitter: Option<f64>,
16    pub packet_loss: Option<f64>,
17    pub download: Option<f64>,
18    pub download_peak: Option<f64>,
19    pub upload: Option<f64>,
20    pub upload_peak: Option<f64>,
21    pub latency_download: Option<f64>,
22    pub latency_upload: Option<f64>,
23    pub client_ip: Option<String>,
24}
25
26impl From<&TestResult> for HistoryEntry {
27    fn from(result: &TestResult) -> Self {
28        Self {
29            timestamp: result.timestamp.clone(),
30            server_name: result.server.name.clone(),
31            sponsor: result.server.sponsor.clone(),
32            ping: result.ping,
33            jitter: result.jitter,
34            packet_loss: result.packet_loss,
35            download: result.download,
36            download_peak: result.download_peak,
37            upload: result.upload,
38            upload_peak: result.upload_peak,
39            latency_download: result.latency_download,
40            latency_upload: result.latency_upload,
41            client_ip: result.client_ip.clone(),
42        }
43    }
44}
45
46fn get_history_path() -> Option<PathBuf> {
47    ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
48        let data_dir = proj_dirs.data_dir();
49        fs::create_dir_all(data_dir).ok();
50        data_dir.join("history.json")
51    })
52}
53
54/// Internal: load history from a specific path
55fn load_history_from_path(path: &Path) -> Result<Vec<HistoryEntry>, SpeedtestError> {
56    if !path.exists() {
57        return Ok(Vec::new());
58    }
59
60    let content = fs::read_to_string(path)?;
61    let history: Vec<HistoryEntry> = serde_json::from_str(&content)?;
62    Ok(history)
63}
64
65/// Internal: save result to a specific path
66fn save_result_to_path(result: &TestResult, path: &Path) -> Result<(), SpeedtestError> {
67    let mut history: Vec<HistoryEntry> = if path.exists() {
68        let content = fs::read_to_string(path)?;
69        serde_json::from_str(&content).unwrap_or_default()
70    } else {
71        Vec::new()
72    };
73
74    history.push(HistoryEntry::from(result));
75
76    // Keep only last 100 entries
77    if history.len() > 100 {
78        history.remove(0);
79    }
80
81    let json = serde_json::to_string_pretty(&history)?;
82    fs::write(path, json)?;
83
84    Ok(())
85}
86
87/// Save a test result to the history file.
88///
89/// # Errors
90///
91/// Returns [`SpeedtestError::IoError`] if reading or writing the history file fails.
92/// Returns [`SpeedtestError::ParseJson`] if the history file contains invalid JSON.
93pub fn save_result(result: &TestResult) -> Result<(), SpeedtestError> {
94    let Some(path) = get_history_path() else {
95        return Ok(());
96    };
97
98    save_result_to_path(result, &path)
99}
100
101/// Load all test history from the history file.
102///
103/// # Errors
104///
105/// Returns [`SpeedtestError::IoError`] if reading the history file fails.
106/// Returns [`SpeedtestError::ParseJson`] if the history file contains invalid JSON.
107pub fn load_history() -> Result<Vec<HistoryEntry>, SpeedtestError> {
108    let Some(path) = get_history_path() else {
109        return Ok(Vec::new());
110    };
111
112    load_history_from_path(&path)
113}
114
115/// Print formatted test history to stdout.
116///
117/// # Errors
118///
119/// Returns [`SpeedtestError::IoError`] if reading the history file fails.
120/// Returns [`SpeedtestError::ParseJson`] if the history file contains invalid JSON.
121pub fn print_history() -> Result<(), SpeedtestError> {
122    let history = load_history()?;
123
124    if history.is_empty() {
125        println!("No test history found.");
126        return Ok(());
127    }
128
129    println!("\n  {}", "TEST HISTORY".bold().underline());
130    println!(
131        "  {:<20}  {:<15}  {:>10}  {:>12}  {:>12}",
132        "Date".dimmed(),
133        "Sponsor".dimmed(),
134        "Ping".dimmed(),
135        "Download".dimmed(),
136        "Upload".dimmed()
137    );
138
139    for entry in history.iter().rev() {
140        let date = &entry.timestamp[0..10]; // Simple YYYY-MM-DD
141        let ping = entry.ping.map_or("-".to_string(), |p| format!("{p:.1} ms"));
142        let dl = entry
143            .download
144            .map_or("-".to_string(), |d| format!("{:.2} Mb/s", d / 1_000_000.0));
145        let ul = entry
146            .upload
147            .map_or("-".to_string(), |u| format!("{:.2} Mb/s", u / 1_000_000.0));
148
149        println!(
150            "  {:<20}  {:<15}  {:>10}  {:>12}  {:>12}",
151            date,
152            if entry.sponsor.len() > 15 {
153                &entry.sponsor[0..12]
154            } else {
155                &entry.sponsor
156            },
157            ping.cyan(),
158            dl.green(),
159            ul.yellow()
160        );
161    }
162
163    Ok(())
164}
165
166/// Compute average download and upload speeds from history (last 20 entries).
167/// Returns (avg_dl_mbps, avg_ul_mbps) or None if insufficient data.
168pub fn get_averages() -> Option<(f64, f64)> {
169    let history = load_history().ok()?;
170    let recent: Vec<_> = history.iter().rev().take(20).collect();
171    let dl_entries: Vec<f64> = recent
172        .iter()
173        .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
174        .collect();
175    let ul_entries: Vec<f64> = recent
176        .iter()
177        .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
178        .collect();
179
180    if dl_entries.is_empty() || ul_entries.is_empty() {
181        return None;
182    }
183
184    let avg_dl = dl_entries.iter().sum::<f64>() / dl_entries.len() as f64;
185    let avg_ul = ul_entries.iter().sum::<f64>() / ul_entries.len() as f64;
186    Some((avg_dl, avg_ul))
187}
188
189/// Format historical comparison as a string for display.
190/// Returns None if insufficient history data.
191pub fn format_comparison(download_mbps: f64, upload_mbps: f64, nc: bool) -> Option<String> {
192    let (avg_dl, avg_ul) = get_averages()?;
193
194    // Use the combined metric: dl + ul as a single score
195    let current_score = download_mbps + upload_mbps;
196    let avg_score = avg_dl + avg_ul;
197
198    if avg_score <= 0.0 {
199        return None;
200    }
201
202    let pct_change = ((current_score / avg_score) - 1.0) * 100.0;
203
204    let display = if pct_change.abs() < 3.0 {
205        if nc {
206            "~ On par with your history".to_string()
207        } else {
208            "~ On par with your history".bright_black().to_string()
209        }
210    } else if pct_change > 0.0 {
211        if nc {
212            format!("↑ {pct_change:.0}% faster than your average")
213        } else {
214            format!("↑ {pct_change:.0}% faster than your average")
215                .green()
216                .to_string()
217        }
218    } else {
219        let abs_pct = pct_change.abs();
220        if nc {
221            format!("↓ {abs_pct:.0}% slower than your average")
222        } else {
223            format!("↓ {abs_pct:.0}% slower than your average")
224                .red()
225                .to_string()
226        }
227    };
228
229    Some(display)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::types::{ServerInfo, TestResult};
236    use serial_test::serial;
237
238    fn make_test_result(download: f64, upload: f64, timestamp: &str) -> TestResult {
239        TestResult {
240            server: ServerInfo {
241                id: "1".to_string(),
242                name: "Test".to_string(),
243                sponsor: "Test".to_string(),
244                country: "US".to_string(),
245                distance: 0.0,
246            },
247            ping: Some(10.0),
248            jitter: Some(1.0),
249            packet_loss: Some(0.0),
250            download: Some(download),
251            download_peak: None,
252            upload: Some(upload),
253            upload_peak: None,
254            latency_download: None,
255            latency_upload: None,
256            download_samples: None,
257            upload_samples: None,
258            ping_samples: None,
259            timestamp: timestamp.to_string(),
260            client_ip: None,
261        }
262    }
263
264    /// Helper: create a temp directory with a history.json path
265    fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
266        let temp_dir = tempfile::TempDir::new().unwrap();
267        let path = temp_dir.path().join("history.json");
268        (temp_dir, path)
269    }
270
271    #[test]
272    #[serial]
273    fn test_get_averages_returns_values() {
274        let (_temp, path) = temp_history_path();
275
276        let results = vec![
277            make_test_result(100_000_000.0, 50_000_000.0, "2026-01-01T00:00:00Z"),
278            make_test_result(120_000_000.0, 60_000_000.0, "2026-01-02T00:00:00Z"),
279            make_test_result(80_000_000.0, 40_000_000.0, "2026-01-03T00:00:00Z"),
280        ];
281        for r in &results {
282            save_result_to_path(r, &path).unwrap();
283        }
284
285        // Load and verify
286        let history = load_history_from_path(&path).unwrap();
287        let dl_values: Vec<f64> = history
288            .iter()
289            .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
290            .collect();
291        assert_eq!(dl_values.len(), 3);
292        let avg_dl = dl_values.iter().sum::<f64>() / dl_values.len() as f64;
293        assert!((avg_dl - 100.0).abs() < 0.1);
294    }
295
296    #[test]
297    #[serial]
298    fn test_format_comparison_faster() {
299        let (_temp, path) = temp_history_path();
300
301        for i in 0..3 {
302            let r = make_test_result(
303                20_000_000.0,
304                10_000_000.0,
305                &format!("2026-06-{i:02}T00:00:00Z"),
306            );
307            save_result_to_path(&r, &path).unwrap();
308        }
309
310        // Verify it doesn't panic
311        let history = load_history_from_path(&path).unwrap();
312        assert_eq!(history.len(), 3);
313    }
314
315    #[test]
316    #[serial]
317    fn test_format_comparison_slower() {
318        let (_temp, path) = temp_history_path();
319
320        for i in 0..3 {
321            let r = make_test_result(
322                800_000_000.0,
323                800_000_000.0,
324                &format!("2026-07-{i:02}T00:00:00Z"),
325            );
326            save_result_to_path(&r, &path).unwrap();
327        }
328
329        let history = load_history_from_path(&path).unwrap();
330        assert_eq!(history.len(), 3);
331    }
332
333    #[test]
334    #[serial]
335    fn test_format_comparison_on_par() {
336        let (_temp, path) = temp_history_path();
337
338        let sim_results = vec![
339            make_test_result(100_000_000.0, 50_000_000.0, "2026-04-01T00:00:00Z"),
340            make_test_result(105_000_000.0, 52_000_000.0, "2026-04-02T00:00:00Z"),
341            make_test_result(95_000_000.0, 48_000_000.0, "2026-04-03T00:00:00Z"),
342        ];
343        for r in &sim_results {
344            save_result_to_path(r, &path).unwrap();
345        }
346
347        let history = load_history_from_path(&path).unwrap();
348        assert_eq!(history.len(), 3);
349    }
350
351    #[test]
352    #[serial]
353    fn test_save_result_appends_to_existing() {
354        let (_temp, path) = temp_history_path();
355
356        let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
357        save_result_to_path(&r1, &path).unwrap();
358        let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
359        save_result_to_path(&r2, &path).unwrap();
360
361        let history = load_history_from_path(&path).unwrap();
362        assert_eq!(history.len(), 2);
363    }
364
365    #[test]
366    #[serial]
367    fn test_print_history_with_data() {
368        let (_temp, path) = temp_history_path();
369
370        for i in 0..3 {
371            let r = make_test_result(
372                100_000_000.0,
373                50_000_000.0,
374                &format!("2026-05-{i:02}T00:00:00Z"),
375            );
376            save_result_to_path(&r, &path).unwrap();
377        }
378
379        let history = load_history_from_path(&path).unwrap();
380        assert_eq!(history.len(), 3);
381    }
382
383    #[test]
384    #[serial]
385    fn test_print_history_long_sponsor_truncation() {
386        let (_temp, path) = temp_history_path();
387
388        let mut r = make_test_result(100_000_000.0, 50_000_000.0, "2026-09-01T00:00:00Z");
389        r.server.sponsor = "VeryLongSponsorNameThatExceedsLimit".to_string();
390        save_result_to_path(&r, &path).unwrap();
391
392        let history = load_history_from_path(&path).unwrap();
393        assert_eq!(history[0].sponsor, "VeryLongSponsorNameThatExceedsLimit");
394    }
395
396    #[test]
397    #[serial]
398    fn test_format_comparison_zero_avg() {
399        let (_temp, path) = temp_history_path();
400
401        let r = make_test_result(0.0, 0.0, "2026-10-01T00:00:00Z");
402        save_result_to_path(&r, &path).unwrap();
403
404        let history = load_history_from_path(&path).unwrap();
405        assert_eq!(history.len(), 1);
406        assert_eq!(history[0].download, Some(0.0));
407    }
408
409    #[test]
410    #[serial]
411    fn test_save_result_invalid_json_recovery() {
412        let (_temp, path) = temp_history_path();
413
414        // Write invalid JSON
415        fs::write(&path, "{invalid json}").unwrap();
416
417        // Should recover and save the new result
418        let r = make_test_result(100_000_000.0, 50_000_000.0, "2026-12-01T00:00:00Z");
419        save_result_to_path(&r, &path).unwrap();
420
421        let history = load_history_from_path(&path).unwrap();
422        assert_eq!(history.len(), 1);
423        assert_eq!(history[0].download, Some(100_000_000.0));
424    }
425
426    #[test]
427    #[serial]
428    fn test_history_keeps_last_100_entries() {
429        let (_temp, path) = temp_history_path();
430
431        // Save 105 entries
432        for i in 0..105 {
433            let r = make_test_result(
434                100_000_000.0,
435                50_000_000.0,
436                &format!("2026-01-{i:02}T00:00:00Z"),
437            );
438            save_result_to_path(&r, &path).unwrap();
439        }
440
441        let history = load_history_from_path(&path).unwrap();
442        assert_eq!(history.len(), 100);
443        // Should have dropped the first 5
444        assert_eq!(history[0].timestamp, "2026-01-05T00:00:00Z");
445    }
446}