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
83    // Write to a temp file first, then rename for atomicity.
84    // On Unix, restrict permissions to owner-only (0o600).
85    let tmp_path = path.with_extension("json.tmp");
86    fs::write(&tmp_path, &json)?;
87    #[cfg(unix)]
88    {
89        use std::os::unix::fs::PermissionsExt;
90        fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)).ok();
91    }
92    fs::rename(&tmp_path, path)?;
93
94    Ok(())
95}
96
97/// Save a test result to the history file.
98///
99/// # Errors
100///
101/// Returns [`SpeedtestError::IoError`] if reading or writing the history file fails.
102/// Returns [`SpeedtestError::ParseJson`] if the history file contains invalid JSON.
103pub fn save_result(result: &TestResult) -> Result<(), SpeedtestError> {
104    let Some(path) = get_history_path() else {
105        return Ok(());
106    };
107
108    save_result_to_path(result, &path)
109}
110
111/// Load all test history from the history file.
112///
113/// # Errors
114///
115/// Returns [`SpeedtestError::IoError`] if reading the history file fails.
116/// Returns [`SpeedtestError::ParseJson`] if the history file contains invalid JSON.
117pub fn load_history() -> Result<Vec<HistoryEntry>, SpeedtestError> {
118    let Some(path) = get_history_path() else {
119        return Ok(Vec::new());
120    };
121
122    load_history_from_path(&path)
123}
124
125/// Print formatted test history to stdout.
126///
127/// # Errors
128///
129/// Returns [`SpeedtestError::IoError`] if reading the history file fails.
130/// Returns [`SpeedtestError::ParseJson`] if the history file contains invalid JSON.
131pub fn print_history() -> Result<(), SpeedtestError> {
132    let history = load_history()?;
133
134    if history.is_empty() {
135        println!("No test history found.");
136        return Ok(());
137    }
138
139    println!("\n  {}", "TEST HISTORY".bold().underline());
140    println!(
141        "  {:<20}  {:<15}  {:>10}  {:>12}  {:>12}",
142        "Date".dimmed(),
143        "Sponsor".dimmed(),
144        "Ping".dimmed(),
145        "Download".dimmed(),
146        "Upload".dimmed()
147    );
148
149    for entry in history.iter().rev() {
150        let date = &entry.timestamp[0..10]; // Simple YYYY-MM-DD
151        let ping = entry.ping.map_or("-".to_string(), |p| format!("{p:.1} ms"));
152        let dl = entry
153            .download
154            .map_or("-".to_string(), |d| format!("{:.2} Mb/s", d / 1_000_000.0));
155        let ul = entry
156            .upload
157            .map_or("-".to_string(), |u| format!("{:.2} Mb/s", u / 1_000_000.0));
158
159        println!(
160            "  {:<20}  {:<15}  {:>10}  {:>12}  {:>12}",
161            date,
162            if entry.sponsor.len() > 15 {
163                &entry.sponsor[0..12]
164            } else {
165                &entry.sponsor
166            },
167            ping.cyan(),
168            dl.green(),
169            ul.yellow()
170        );
171    }
172
173    Ok(())
174}
175
176/// Compute average download and upload speeds from history (last 20 entries).
177/// Returns (avg_dl_mbps, avg_ul_mbps) or None if insufficient data.
178pub fn get_averages() -> Option<(f64, f64)> {
179    let history = load_history().ok()?;
180    let recent: Vec<_> = history.iter().rev().take(20).collect();
181    let dl_entries: Vec<f64> = recent
182        .iter()
183        .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
184        .collect();
185    let ul_entries: Vec<f64> = recent
186        .iter()
187        .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
188        .collect();
189
190    if dl_entries.is_empty() || ul_entries.is_empty() {
191        return None;
192    }
193
194    let avg_dl = dl_entries.iter().sum::<f64>() / dl_entries.len() as f64;
195    let avg_ul = ul_entries.iter().sum::<f64>() / ul_entries.len() as f64;
196    Some((avg_dl, avg_ul))
197}
198
199/// Format historical comparison as a string for display.
200/// Returns None if insufficient history data.
201pub fn format_comparison(download_mbps: f64, upload_mbps: f64, nc: bool) -> Option<String> {
202    let (avg_dl, avg_ul) = get_averages()?;
203
204    // Use the combined metric: dl + ul as a single score
205    let current_score = download_mbps + upload_mbps;
206    let avg_score = avg_dl + avg_ul;
207
208    if avg_score <= 0.0 {
209        return None;
210    }
211
212    let pct_change = ((current_score / avg_score) - 1.0) * 100.0;
213
214    let display = if pct_change.abs() < 3.0 {
215        if nc {
216            "~ On par with your history".to_string()
217        } else {
218            "~ On par with your history".bright_black().to_string()
219        }
220    } else if pct_change > 0.0 {
221        if nc {
222            format!("↑ {pct_change:.0}% faster than your average")
223        } else {
224            format!("↑ {pct_change:.0}% faster than your average")
225                .green()
226                .to_string()
227        }
228    } else {
229        let abs_pct = pct_change.abs();
230        if nc {
231            format!("↓ {abs_pct:.0}% slower than your average")
232        } else {
233            format!("↓ {abs_pct:.0}% slower than your average")
234                .red()
235                .to_string()
236        }
237    };
238
239    Some(display)
240}
241
242/// Render a sparkline from a slice of numeric values using Unicode block chars.
243///
244/// # Examples
245///
246/// ```
247/// # use netspeed_cli::history::sparkline;
248/// let line = sparkline(&[10.0, 20.0, 30.0]);
249/// assert_eq!(line.chars().count(), 3); // one char per value
250/// ```
251#[must_use]
252pub fn sparkline(values: &[f64]) -> String {
253    const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
254    if values.is_empty() {
255        return String::new();
256    }
257    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
258    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
259    let range = max - min;
260    if range <= 0.0 {
261        // All same value — show middle bar
262        return CHARS[3].to_string().repeat(values.len());
263    }
264    values
265        .iter()
266        .map(|v| {
267            let idx = (((v - min) / range) * 7.0).round() as usize;
268            CHARS[idx.min(7)]
269        })
270        .collect::<String>()
271}
272
273/// Get recent download/upload speeds as paired tuples for sparkline display.
274/// Returns up to the last 7 entries as `(date_label, dl_mbps, ul_mbps)`.
275#[must_use]
276pub fn get_recent_sparkline() -> Vec<(String, f64, f64)> {
277    let Ok(history) = load_history() else {
278        return Vec::new();
279    };
280    history
281        .iter()
282        .rev()
283        .take(7)
284        .filter_map(|e| {
285            let dl = e.download.map(|d| d / 1_000_000.0).unwrap_or(0.0);
286            let ul = e.upload.map(|u| u / 1_000_000.0).unwrap_or(0.0);
287            if dl > 0.0 || ul > 0.0 {
288                // Extract just the date part (YYYY-MM-DD)
289                let date = e.timestamp.get(0..10).unwrap_or(&e.timestamp).to_string();
290                Some((date, dl, ul))
291            } else {
292                None
293            }
294        })
295        .collect()
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::types::{ServerInfo, TestResult};
302    use serial_test::serial;
303
304    fn make_test_result(download: f64, upload: f64, timestamp: &str) -> TestResult {
305        TestResult {
306            server: ServerInfo {
307                id: "1".to_string(),
308                name: "Test".to_string(),
309                sponsor: "Test".to_string(),
310                country: "US".to_string(),
311                distance: 0.0,
312            },
313            ping: Some(10.0),
314            jitter: Some(1.0),
315            packet_loss: Some(0.0),
316            download: Some(download),
317            download_peak: None,
318            upload: Some(upload),
319            upload_peak: None,
320            latency_download: None,
321            latency_upload: None,
322            download_samples: None,
323            upload_samples: None,
324            ping_samples: None,
325            timestamp: timestamp.to_string(),
326            client_ip: None,
327        }
328    }
329
330    /// Helper: create a temp directory with a history.json path
331    fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
332        let temp_dir = tempfile::TempDir::new().unwrap();
333        let path = temp_dir.path().join("history.json");
334        (temp_dir, path)
335    }
336
337    #[test]
338    #[serial]
339    fn test_get_averages_returns_values() {
340        let (_temp, path) = temp_history_path();
341
342        let results = vec![
343            make_test_result(100_000_000.0, 50_000_000.0, "2026-01-01T00:00:00Z"),
344            make_test_result(120_000_000.0, 60_000_000.0, "2026-01-02T00:00:00Z"),
345            make_test_result(80_000_000.0, 40_000_000.0, "2026-01-03T00:00:00Z"),
346        ];
347        for r in &results {
348            save_result_to_path(r, &path).unwrap();
349        }
350
351        // Load and verify
352        let history = load_history_from_path(&path).unwrap();
353        let dl_values: Vec<f64> = history
354            .iter()
355            .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
356            .collect();
357        assert_eq!(dl_values.len(), 3);
358        let avg_dl = dl_values.iter().sum::<f64>() / dl_values.len() as f64;
359        assert!((avg_dl - 100.0).abs() < 0.1);
360    }
361
362    #[test]
363    #[serial]
364    fn test_format_comparison_faster() {
365        let (_temp, path) = temp_history_path();
366
367        for i in 0..3 {
368            let r = make_test_result(
369                20_000_000.0,
370                10_000_000.0,
371                &format!("2026-06-{i:02}T00:00:00Z"),
372            );
373            save_result_to_path(&r, &path).unwrap();
374        }
375
376        // Verify it doesn't panic
377        let history = load_history_from_path(&path).unwrap();
378        assert_eq!(history.len(), 3);
379    }
380
381    #[test]
382    #[serial]
383    fn test_format_comparison_slower() {
384        let (_temp, path) = temp_history_path();
385
386        for i in 0..3 {
387            let r = make_test_result(
388                800_000_000.0,
389                800_000_000.0,
390                &format!("2026-07-{i:02}T00:00:00Z"),
391            );
392            save_result_to_path(&r, &path).unwrap();
393        }
394
395        let history = load_history_from_path(&path).unwrap();
396        assert_eq!(history.len(), 3);
397    }
398
399    #[test]
400    #[serial]
401    fn test_format_comparison_on_par() {
402        let (_temp, path) = temp_history_path();
403
404        let sim_results = vec![
405            make_test_result(100_000_000.0, 50_000_000.0, "2026-04-01T00:00:00Z"),
406            make_test_result(105_000_000.0, 52_000_000.0, "2026-04-02T00:00:00Z"),
407            make_test_result(95_000_000.0, 48_000_000.0, "2026-04-03T00:00:00Z"),
408        ];
409        for r in &sim_results {
410            save_result_to_path(r, &path).unwrap();
411        }
412
413        let history = load_history_from_path(&path).unwrap();
414        assert_eq!(history.len(), 3);
415    }
416
417    #[test]
418    #[serial]
419    fn test_save_result_appends_to_existing() {
420        let (_temp, path) = temp_history_path();
421
422        let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
423        save_result_to_path(&r1, &path).unwrap();
424        let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
425        save_result_to_path(&r2, &path).unwrap();
426
427        let history = load_history_from_path(&path).unwrap();
428        assert_eq!(history.len(), 2);
429    }
430
431    #[test]
432    #[serial]
433    fn test_print_history_with_data() {
434        let (_temp, path) = temp_history_path();
435
436        for i in 0..3 {
437            let r = make_test_result(
438                100_000_000.0,
439                50_000_000.0,
440                &format!("2026-05-{i:02}T00:00:00Z"),
441            );
442            save_result_to_path(&r, &path).unwrap();
443        }
444
445        let history = load_history_from_path(&path).unwrap();
446        assert_eq!(history.len(), 3);
447    }
448
449    #[test]
450    #[serial]
451    fn test_print_history_long_sponsor_truncation() {
452        let (_temp, path) = temp_history_path();
453
454        let mut r = make_test_result(100_000_000.0, 50_000_000.0, "2026-09-01T00:00:00Z");
455        r.server.sponsor = "VeryLongSponsorNameThatExceedsLimit".to_string();
456        save_result_to_path(&r, &path).unwrap();
457
458        let history = load_history_from_path(&path).unwrap();
459        assert_eq!(history[0].sponsor, "VeryLongSponsorNameThatExceedsLimit");
460    }
461
462    #[test]
463    #[serial]
464    fn test_format_comparison_zero_avg() {
465        let (_temp, path) = temp_history_path();
466
467        let r = make_test_result(0.0, 0.0, "2026-10-01T00:00:00Z");
468        save_result_to_path(&r, &path).unwrap();
469
470        let history = load_history_from_path(&path).unwrap();
471        assert_eq!(history.len(), 1);
472        assert_eq!(history[0].download, Some(0.0));
473    }
474
475    #[test]
476    #[serial]
477    fn test_save_result_invalid_json_recovery() {
478        let (_temp, path) = temp_history_path();
479
480        // Write invalid JSON
481        fs::write(&path, "{invalid json}").unwrap();
482
483        // Should recover and save the new result
484        let r = make_test_result(100_000_000.0, 50_000_000.0, "2026-12-01T00:00:00Z");
485        save_result_to_path(&r, &path).unwrap();
486
487        let history = load_history_from_path(&path).unwrap();
488        assert_eq!(history.len(), 1);
489        assert_eq!(history[0].download, Some(100_000_000.0));
490    }
491
492    #[test]
493    #[serial]
494    fn test_history_keeps_last_100_entries() {
495        let (_temp, path) = temp_history_path();
496
497        // Save 105 entries
498        for i in 0..105 {
499            let r = make_test_result(
500                100_000_000.0,
501                50_000_000.0,
502                &format!("2026-01-{i:02}T00:00:00Z"),
503            );
504            save_result_to_path(&r, &path).unwrap();
505        }
506
507        let history = load_history_from_path(&path).unwrap();
508        assert_eq!(history.len(), 100);
509        // Should have dropped the first 5
510        assert_eq!(history[0].timestamp, "2026-01-05T00:00:00Z");
511    }
512
513    #[test]
514    fn test_sparkline_increasing() {
515        let line = sparkline(&[10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]);
516        assert_eq!(line.chars().count(), 8);
517        // Should produce ascending bars
518        assert_eq!(line, "▁▂▃▄▅▆▇█");
519    }
520
521    #[test]
522    fn test_sparkline_decreasing() {
523        let line = sparkline(&[80.0, 60.0, 40.0, 20.0]);
524        assert_eq!(line.chars().count(), 4);
525    }
526
527    #[test]
528    fn test_sparkline_empty() {
529        assert_eq!(sparkline(&[]), "");
530    }
531
532    #[test]
533    fn test_sparkline_single_value() {
534        let line = sparkline(&[42.0]);
535        assert_eq!(line, "▄"); // single value → middle bar
536    }
537
538    #[test]
539    fn test_sparkline_identical_values() {
540        let line = sparkline(&[50.0, 50.0, 50.0]);
541        assert_eq!(line, "▄▄▄"); // all same → middle bar
542    }
543}