Skip to main content

netspeed_cli/
history.rs

1use crate::common;
2use crate::error::Error;
3use crate::terminal;
4use crate::theme::{Colors, Theme};
5use crate::types::TestResult;
6use directories::ProjectDirs;
7use owo_colors::OwoColorize;
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Serialize, Deserialize, Debug)]
13pub struct Entry {
14    pub timestamp: String,
15    pub server_name: String,
16    pub sponsor: String,
17    pub ping: Option<f64>,
18    pub jitter: Option<f64>,
19    pub packet_loss: Option<f64>,
20    pub download: Option<f64>,
21    pub download_peak: Option<f64>,
22    pub upload: Option<f64>,
23    pub upload_peak: Option<f64>,
24    pub latency_download: Option<f64>,
25    pub latency_upload: Option<f64>,
26    pub client_ip: Option<String>,
27}
28
29impl From<&TestResult> for Entry {
30    fn from(result: &TestResult) -> Self {
31        Self {
32            timestamp: result.timestamp.clone(),
33            server_name: result.server.name.clone(),
34            sponsor: result.server.sponsor.clone(),
35            ping: result.ping,
36            jitter: result.jitter,
37            packet_loss: result.packet_loss,
38            download: result.download,
39            download_peak: result.download_peak,
40            upload: result.upload,
41            upload_peak: result.upload_peak,
42            latency_download: result.latency_download,
43            latency_upload: result.latency_upload,
44            client_ip: result.client_ip.clone(),
45        }
46    }
47}
48
49fn get_history_path() -> Option<PathBuf> {
50    ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
51        let data_dir = proj_dirs.data_dir();
52        if let Err(e) = fs::create_dir_all(data_dir) {
53            eprintln!("Warning: Failed to create data directory: {e}");
54        }
55        data_dir.join("history.json")
56    })
57}
58
59fn backup_path(path: &Path) -> PathBuf {
60    path.with_extension("json.bak")
61}
62
63fn corrupt_path(path: &Path) -> PathBuf {
64    path.with_extension("json.corrupt")
65}
66
67fn load_entries(path: &Path) -> Result<Vec<Entry>, Error> {
68    let content = fs::read_to_string(path)?;
69    Ok(serde_json::from_str(&content)?)
70}
71
72/// Internal: load history from a specific path
73fn load_history_from_path(path: &Path) -> Result<Vec<Entry>, Error> {
74    if !path.exists() {
75        return Ok(Vec::new());
76    }
77
78    match load_entries(path) {
79        Ok(history) => Ok(history),
80        Err(err) => {
81            let backup = backup_path(path);
82            if backup.exists() {
83                match load_entries(&backup) {
84                    Ok(history) => {
85                        eprintln!(
86                            "Warning: History file is invalid; using backup at {}",
87                            backup.display()
88                        );
89                        Ok(history)
90                    }
91                    Err(_) => Err(err),
92                }
93            } else {
94                Err(err)
95            }
96        }
97    }
98}
99
100/// Internal: save result to a specific path
101fn save_result_to_path(result: &TestResult, path: &Path) -> Result<(), Error> {
102    let backup = backup_path(path);
103    let mut recovered_from_backup = false;
104    let mut history: Vec<Entry> = if path.exists() {
105        match load_entries(path) {
106            Ok(history) => history,
107            Err(err) => {
108                if backup.exists() {
109                    let backup_history = load_entries(&backup)?;
110                    let corrupt = corrupt_path(path);
111                    fs::copy(path, &corrupt)?;
112                    eprintln!(
113                        "Warning: History file is invalid; preserving it at {} and repairing from backup {}",
114                        corrupt.display(),
115                        backup.display()
116                    );
117                    recovered_from_backup = true;
118                    backup_history
119                } else {
120                    return Err(err);
121                }
122            }
123        }
124    } else {
125        Vec::new()
126    };
127
128    history.push(Entry::from(result));
129
130    // Keep only last 100 entries
131    if history.len() > 100 {
132        let overflow = history.len() - 100;
133        history.drain(0..overflow);
134    }
135
136    let json = serde_json::to_string_pretty(&history)?;
137
138    // Write to a temp file first, then rename for atomicity.
139    // On Unix, restrict permissions to owner-only (0o600).
140    let tmp_path = path.with_extension("json.tmp");
141    fs::write(&tmp_path, &json)?;
142    #[cfg(unix)]
143    {
144        use std::os::unix::fs::PermissionsExt;
145        if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)) {
146            eprintln!("Warning: Failed to set permissions on history file: {e}");
147        }
148    }
149    if path.exists() && !recovered_from_backup {
150        fs::copy(path, &backup)?;
151    }
152    fs::rename(&tmp_path, path)?;
153
154    Ok(())
155}
156
157/// Save a test result to the history file.
158///
159/// # Errors
160///
161/// Returns [`Error::IoError`] if reading or writing the history file fails.
162/// Returns [`Error::ParseJson`] if the history file contains invalid JSON.
163pub fn save_result(result: &TestResult) -> Result<(), Error> {
164    let Some(path) = get_history_path() else {
165        return Ok(());
166    };
167
168    save_result_to_path(result, &path)
169}
170
171/// Save a full report (currently identical to a TestResult).
172pub fn save_report(report: &crate::domain::reporting::Report) -> Result<(), Error> {
173    // Report is an alias for TestResult; forward to existing saver.
174    save_result(report)
175}
176
177/// Load all test history from the history file.
178///
179/// # Errors
180///
181/// Returns [`Error::IoError`] if reading the history file fails.
182/// Returns [`Error::ParseJson`] if the history file contains invalid JSON.
183pub fn load() -> Result<Vec<Entry>, Error> {
184    let Some(path) = get_history_path() else {
185        return Ok(Vec::new());
186    };
187
188    load_history_from_path(&path)
189}
190
191/// Print formatted test history to stdout.
192///
193/// # Errors
194///
195/// Returns [`Error::IoError`] if reading the history file fails.
196/// Returns [`Error::ParseJson`] if the history file contains invalid JSON.
197pub fn show(theme: Theme) -> Result<(), Error> {
198    let history = load()?;
199
200    if history.is_empty() {
201        println!("No test history found.");
202        return Ok(());
203    }
204
205    let nc = terminal::no_color() || theme == Theme::Monochrome;
206    let term_w = common::get_terminal_width().unwrap_or(90) as usize;
207    let box_w = term_w.min(80);
208    let inner_w = box_w.saturating_sub(4); // 2 leading spaces + 2 border chars
209    let content_w = inner_w.saturating_sub(4); // 2 spaces each side inside box
210
211    let count = history.len();
212    let left_text = "◉ TEST HISTORY";
213    let right_text = format!("{count} entries");
214    let pad = content_w.saturating_sub(left_text.chars().count() + right_text.chars().count());
215    let spaces = " ".repeat(pad);
216
217    let top_border = format!("  ┌{}┐", "─".repeat(inner_w));
218    let mid_border = format!("  └{}┘", "─".repeat(inner_w));
219
220    println!();
221    println!("{top_border}");
222    if nc {
223        println!("  │  {left_text}{spaces}{right_text}  │");
224    } else {
225        let left_col = format!(
226            "{} {}",
227            Colors::muted("◉", theme),
228            Colors::header("TEST HISTORY", theme)
229        );
230        let right_col = Colors::muted(&right_text, theme);
231        println!("  │  {left_col}{spaces}{right_col}  │");
232    }
233    println!("{mid_border}");
234    println!();
235
236    // Column widths (plain)
237    const DATE_W: usize = 10;
238    const DL_W: usize = 12;
239    const UL_W: usize = 12;
240    const PING_W: usize = 9;
241    const SERVER_W: usize = 18;
242
243    // Header row
244    let h_date = format!("{:<DATE_W$}", "Date");
245    let h_dl = format!("{:>DL_W$}", "↓ Download");
246    let h_ul = format!("{:>UL_W$}", "↑ Upload");
247    let h_ping = format!("{:>PING_W$}", "Ping");
248    let h_server = format!("{:<SERVER_W$}", "Server");
249    if nc {
250        println!("  {h_date}  {h_dl}  {h_ul}  {h_ping}  {h_server}");
251    } else {
252        println!(
253            "  {}  {}  {}  {}  {}",
254            Colors::muted(&h_date, theme),
255            Colors::muted(&h_dl, theme),
256            Colors::muted(&h_ul, theme),
257            Colors::muted(&h_ping, theme),
258            Colors::muted(&h_server, theme),
259        );
260    }
261
262    // Thin dashed separator
263    let sep_len = DATE_W + 2 + DL_W + 2 + UL_W + 2 + PING_W + 2 + SERVER_W;
264    let sep = format!("  {}", "╌".repeat(sep_len));
265    if nc {
266        println!("{sep}");
267    } else {
268        println!("{}", sep.dimmed());
269    }
270
271    // Data rows — newest first
272    for entry in history.iter().rev() {
273        let date_plain = if entry.timestamp.len() >= 10 {
274            entry.timestamp[0..10].to_string()
275        } else {
276            entry.timestamp.clone()
277        };
278
279        let dl_mbps = entry.download.map(|d| d / 1_000_000.0);
280        let ul_mbps = entry.upload.map(|u| u / 1_000_000.0);
281
282        let dl_plain = dl_mbps.map_or("-".to_string(), |d| format!("{d:.1} Mb/s"));
283        let ul_plain = ul_mbps.map_or("-".to_string(), |u| format!("{u:.1} Mb/s"));
284        let ping_plain = entry.ping.map_or("-".to_string(), |p| format!("{p:.0} ms"));
285
286        let sponsor_truncated = if entry.sponsor.chars().count() > SERVER_W {
287            let truncated: String = entry.sponsor.chars().take(SERVER_W - 1).collect();
288            format!("{truncated}…")
289        } else {
290            entry.sponsor.clone()
291        };
292
293        // Pad plain strings to column widths BEFORE colorizing (ANSI-safe)
294        let date_col = format!("{date_plain:<DATE_W$}");
295        let dl_col_plain = format!("{dl_plain:>DL_W$}");
296        let ul_col_plain = format!("{ul_plain:>UL_W$}");
297        let ping_col_plain = format!("{ping_plain:>PING_W$}");
298        let server_col = format!("{sponsor_truncated:<SERVER_W$}");
299
300        if nc {
301            println!(
302                "  {date_col}  {dl_col_plain}  {ul_col_plain}  {ping_col_plain}  {server_col}"
303            );
304        } else {
305            let date_colored = Colors::muted(&date_col, theme);
306            let dl_colored = color_speed(&dl_col_plain, dl_mbps, theme);
307            let ul_colored = color_speed(&ul_col_plain, ul_mbps, theme);
308            let ping_colored = color_ping(&ping_col_plain, entry.ping, theme);
309            let server_colored = server_col.dimmed().to_string();
310            println!(
311                "  {date_colored}  {dl_colored}  {ul_colored}  {ping_colored}  {server_colored}"
312            );
313        }
314    }
315
316    // Sparkline section — last 20 entries in chronological order
317    let spark_start = if history.len() > 20 {
318        history.len() - 20
319    } else {
320        0
321    };
322    let spark_slice = &history[spark_start..];
323    let dl_vals: Vec<f64> = spark_slice
324        .iter()
325        .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
326        .collect();
327    let ul_vals: Vec<f64> = spark_slice
328        .iter()
329        .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
330        .collect();
331
332    if !dl_vals.is_empty() || !ul_vals.is_empty() {
333        let n = spark_slice.len();
334        if nc {
335            println!("{sep}");
336        } else {
337            println!("{}", sep.dimmed());
338        }
339        if !dl_vals.is_empty() {
340            let dl_spark = sparkline(&dl_vals);
341            if nc {
342                println!("  ↓  {dl_spark}  Download trend ({n} tests)");
343            } else {
344                println!(
345                    "  {}  {}  {}",
346                    Colors::muted("↓", theme),
347                    Colors::info(&dl_spark, theme),
348                    Colors::muted(&format!("Download trend ({n} tests)"), theme),
349                );
350            }
351        }
352        if !ul_vals.is_empty() {
353            let ul_spark = sparkline(&ul_vals);
354            if nc {
355                println!("  ↑  {ul_spark}  Upload trend");
356            } else {
357                println!(
358                    "  {}  {}  {}",
359                    Colors::muted("↑", theme),
360                    Colors::good(&ul_spark, theme),
361                    Colors::muted("Upload trend", theme),
362                );
363            }
364        }
365    }
366
367    println!();
368    Ok(())
369}
370
371fn color_speed(col: &str, mbps: Option<f64>, theme: Theme) -> String {
372    match mbps {
373        None => col.dimmed().to_string(),
374        Some(v) if v >= 100.0 => Colors::good(col, theme),
375        Some(v) if v >= 25.0 => Colors::info(col, theme),
376        Some(v) if v >= 5.0 => Colors::warn(col, theme),
377        Some(_) => Colors::bad(col, theme),
378    }
379}
380
381fn color_ping(col: &str, ping: Option<f64>, theme: Theme) -> String {
382    match ping {
383        None => col.dimmed().to_string(),
384        Some(v) if v <= 20.0 => Colors::good(col, theme),
385        Some(v) if v <= 50.0 => Colors::warn(col, theme),
386        Some(_) => Colors::bad(col, theme),
387    }
388}
389
390/// Compute average download and upload speeds from history (last 20 entries).
391/// Returns (`avg_dl_mbps`, `avg_ul_mbps`) or None if insufficient data.
392#[must_use]
393pub fn get_averages() -> Option<(f64, f64)> {
394    let history = match load() {
395        Ok(h) => h,
396        Err(e) => {
397            eprintln!("Warning: Failed to load history for averages: {e}");
398            return None;
399        }
400    };
401    let recent: Vec<_> = history.iter().rev().take(20).collect();
402    let dl_entries: Vec<f64> = recent
403        .iter()
404        .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
405        .collect();
406    let ul_entries: Vec<f64> = recent
407        .iter()
408        .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
409        .collect();
410
411    if dl_entries.is_empty() || ul_entries.is_empty() {
412        return None;
413    }
414
415    // Safe: history entries are at most 100, well under 2^53.
416    let download_avg = dl_entries.iter().sum::<f64>() / dl_entries.len() as f64;
417    let upload_avg = ul_entries.iter().sum::<f64>() / ul_entries.len() as f64;
418    Some((download_avg, upload_avg))
419}
420
421/// Format historical comparison as a string for display.
422/// Returns None if insufficient history data.
423#[must_use]
424pub fn format_comparison(
425    download_mbps: f64,
426    upload_mbps: f64,
427    nc: bool,
428    theme: Theme,
429) -> Option<String> {
430    let (download_avg, upload_avg) = get_averages()?;
431
432    // Use the combined metric: dl + ul as a single score
433    let current_score = download_mbps + upload_mbps;
434    let avg_score = download_avg + upload_avg;
435
436    if avg_score <= 0.0 {
437        return None;
438    }
439
440    let pct_change = ((current_score / avg_score) - 1.0) * 100.0;
441
442    let display = if pct_change.abs() < 3.0 {
443        if nc {
444            "~ On par with your history".to_string()
445        } else {
446            Colors::muted("~ On par with your history", theme)
447        }
448    } else if pct_change > 0.0 {
449        if nc {
450            format!("↑ {pct_change:.0}% faster than your average")
451        } else {
452            Colors::good(
453                &format!("↑ {pct_change:.0}% faster than your average"),
454                theme,
455            )
456        }
457    } else {
458        let abs_pct = pct_change.abs();
459        if nc {
460            format!("↓ {abs_pct:.0}% slower than your average")
461        } else {
462            Colors::bad(&format!("↓ {abs_pct:.0}% slower than your average"), theme)
463        }
464    };
465
466    Some(display)
467}
468
469/// Render a sparkline from a slice of numeric values using Unicode block chars.
470///
471/// # Examples
472///
473/// ```
474/// # use netspeed_cli::history::sparkline;
475/// let line = sparkline(&[10.0, 20.0, 30.0]);
476/// assert_eq!(line.chars().count(), 3); // one char per value
477/// ```
478#[must_use]
479pub fn sparkline(values: &[f64]) -> String {
480    const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
481    if values.is_empty() {
482        return String::new();
483    }
484    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
485    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
486    let range = max - min;
487    if range <= 0.0 {
488        // All same value — show middle bar
489        return CHARS[3].to_string().repeat(values.len());
490    }
491    values
492        .iter()
493        .map(|v| {
494            // Safe: (v-min)/range is 0..1, *7 → 0..7, round → 0..7, fits usize.
495            let idx = (((v - min) / range) * 7.0).round().clamp(0.0, 7.0) as usize;
496            CHARS[idx]
497        })
498        .collect::<String>()
499}
500
501/// Render an ASCII-only sparkline using `_-^` characters for environments
502/// where Unicode block characters don't render.
503#[must_use]
504pub fn sparkline_ascii(values: &[f64]) -> String {
505    const CHARS: &[char] = &['_', '_', '‗', '-', '=', '≈', '^', '▲'];
506    if values.is_empty() {
507        return String::new();
508    }
509    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
510    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
511    let range = max - min;
512    if range <= 0.0 {
513        return "-".repeat(values.len());
514    }
515    values
516        .iter()
517        .map(|v| {
518            // Safe: (v-min)/range is 0..1, *7 → 0..7, round → 0..7, fits usize.
519            let idx = (((v - min) / range) * 7.0).round().clamp(0.0, 7.0) as usize;
520            CHARS[idx]
521        })
522        .collect::<String>()
523}
524
525/// Get recent download/upload speeds as paired tuples for sparkline display.
526/// Returns up to the last 7 entries as `(date_label, dl_mbps, ul_mbps)`.
527#[must_use]
528pub fn get_recent_sparkline() -> Vec<(String, f64, f64)> {
529    let Ok(history) = load() else {
530        return Vec::new();
531    };
532    history
533        .iter()
534        .rev()
535        .take(7)
536        .filter_map(|e| {
537            let dl = e.download.map_or(0.0, |d| d / 1_000_000.0);
538            let ul = e.upload.map_or(0.0, |u| u / 1_000_000.0);
539            if dl > 0.0 || ul > 0.0 {
540                // Extract just the date part (YYYY-MM-DD)
541                let date = e.timestamp.get(0..10).unwrap_or(&e.timestamp).to_string();
542                Some((date, dl, ul))
543            } else {
544                None
545            }
546        })
547        .collect()
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::error::Error;
554    use crate::types::{PhaseResult, ServerInfo, TestPhases, TestResult};
555    use serial_test::serial;
556
557    fn make_test_result(download: f64, upload: f64, timestamp: &str) -> TestResult {
558        TestResult {
559            status: "ok".to_string(),
560            version: env!("CARGO_PKG_VERSION").to_string(),
561            test_id: None,
562            server: ServerInfo {
563                id: "1".to_string(),
564                name: "Test".to_string(),
565                sponsor: "Test".to_string(),
566                country: "US".to_string(),
567                distance: 0.0,
568            },
569            ping: Some(10.0),
570            jitter: Some(1.0),
571            packet_loss: Some(0.0),
572            download: Some(download),
573            download_peak: None,
574            upload: Some(upload),
575            upload_peak: None,
576            latency_download: None,
577            latency_upload: None,
578            download_samples: None,
579            upload_samples: None,
580            ping_samples: None,
581            timestamp: timestamp.to_string(),
582            client_ip: None,
583            client_location: None,
584            download_cv: None,
585            upload_cv: None,
586            download_ci_95: None,
587            upload_ci_95: None,
588            overall_grade: None,
589            download_grade: None,
590            upload_grade: None,
591            connection_rating: None,
592            phases: TestPhases {
593                ping: PhaseResult::completed(),
594                download: PhaseResult::completed(),
595                upload: PhaseResult::completed(),
596            },
597        }
598    }
599
600    /// Helper: create a temp directory with a history.json path
601    fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
602        let temp_dir = tempfile::TempDir::new().unwrap();
603        let path = temp_dir.path().join("history.json");
604        (temp_dir, path)
605    }
606
607    #[test]
608    #[serial]
609    fn test_get_averages_returns_values() {
610        let (_temp, path) = temp_history_path();
611
612        let results = vec![
613            make_test_result(100_000_000.0, 50_000_000.0, "2026-01-01T00:00:00Z"),
614            make_test_result(120_000_000.0, 60_000_000.0, "2026-01-02T00:00:00Z"),
615            make_test_result(80_000_000.0, 40_000_000.0, "2026-01-03T00:00:00Z"),
616        ];
617        for r in &results {
618            save_result_to_path(r, &path).unwrap();
619        }
620
621        // Load and verify
622        let history = load_history_from_path(&path).unwrap();
623        let dl_values: Vec<f64> = history
624            .iter()
625            .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
626            .collect();
627        assert_eq!(dl_values.len(), 3);
628        // Safe: history entries are at most 100, well under 2^53.
629        let download_avg = dl_values.iter().sum::<f64>() / dl_values.len() as f64;
630        assert!((download_avg - 100.0).abs() < 0.1);
631    }
632
633    #[test]
634    #[serial]
635    fn test_format_comparison_faster() {
636        let (_temp, path) = temp_history_path();
637
638        for i in 0..3 {
639            let r = make_test_result(
640                20_000_000.0,
641                10_000_000.0,
642                &format!("2026-06-{i:02}T00:00:00Z"),
643            );
644            save_result_to_path(&r, &path).unwrap();
645        }
646
647        // Verify it doesn't panic
648        let history = load_history_from_path(&path).unwrap();
649        assert_eq!(history.len(), 3);
650    }
651
652    #[test]
653    #[serial]
654    fn test_format_comparison_slower() {
655        let (_temp, path) = temp_history_path();
656
657        for i in 0..3 {
658            let r = make_test_result(
659                800_000_000.0,
660                800_000_000.0,
661                &format!("2026-07-{i:02}T00:00:00Z"),
662            );
663            save_result_to_path(&r, &path).unwrap();
664        }
665
666        let history = load_history_from_path(&path).unwrap();
667        assert_eq!(history.len(), 3);
668    }
669
670    #[test]
671    #[serial]
672    fn test_format_comparison_on_par() {
673        let (_temp, path) = temp_history_path();
674
675        let sim_results = vec![
676            make_test_result(100_000_000.0, 50_000_000.0, "2026-04-01T00:00:00Z"),
677            make_test_result(105_000_000.0, 52_000_000.0, "2026-04-02T00:00:00Z"),
678            make_test_result(95_000_000.0, 48_000_000.0, "2026-04-03T00:00:00Z"),
679        ];
680        for r in &sim_results {
681            save_result_to_path(r, &path).unwrap();
682        }
683
684        let history = load_history_from_path(&path).unwrap();
685        assert_eq!(history.len(), 3);
686    }
687
688    #[test]
689    #[serial]
690    fn test_save_result_appends_to_existing() {
691        let (_temp, path) = temp_history_path();
692
693        let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
694        save_result_to_path(&r1, &path).unwrap();
695        let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
696        save_result_to_path(&r2, &path).unwrap();
697
698        let history = load_history_from_path(&path).unwrap();
699        assert_eq!(history.len(), 2);
700    }
701
702    #[test]
703    #[serial]
704    fn test_print_history_with_data() {
705        let (_temp, path) = temp_history_path();
706
707        for i in 0..3 {
708            let r = make_test_result(
709                100_000_000.0,
710                50_000_000.0,
711                &format!("2026-05-{i:02}T00:00:00Z"),
712            );
713            save_result_to_path(&r, &path).unwrap();
714        }
715
716        let history = load_history_from_path(&path).unwrap();
717        assert_eq!(history.len(), 3);
718    }
719
720    #[test]
721    #[serial]
722    fn test_print_history_long_sponsor_truncation() {
723        let (_temp, path) = temp_history_path();
724
725        let mut r = make_test_result(100_000_000.0, 50_000_000.0, "2026-09-01T00:00:00Z");
726        r.server.sponsor = "VeryLongSponsorNameThatExceedsLimit".to_string();
727        save_result_to_path(&r, &path).unwrap();
728
729        let history = load_history_from_path(&path).unwrap();
730        assert_eq!(history[0].sponsor, "VeryLongSponsorNameThatExceedsLimit");
731    }
732
733    #[test]
734    #[serial]
735    fn test_format_comparison_zero_avg() {
736        let (_temp, path) = temp_history_path();
737
738        let r = make_test_result(0.0, 0.0, "2026-10-01T00:00:00Z");
739        save_result_to_path(&r, &path).unwrap();
740
741        let history = load_history_from_path(&path).unwrap();
742        assert_eq!(history.len(), 1);
743        assert_eq!(history[0].download, Some(0.0));
744    }
745
746    #[test]
747    #[serial]
748    fn test_save_result_invalid_json_recovery() {
749        let (_temp, path) = temp_history_path();
750
751        // Write invalid JSON
752        fs::write(&path, "{invalid json}").unwrap();
753
754        let r = make_test_result(100_000_000.0, 50_000_000.0, "2026-12-01T00:00:00Z");
755        let err = save_result_to_path(&r, &path).unwrap_err();
756        assert!(matches!(err, Error::ParseJson(_)));
757
758        let original = fs::read_to_string(&path).unwrap();
759        assert_eq!(original, "{invalid json}");
760    }
761
762    #[test]
763    #[serial]
764    fn test_save_result_recovers_from_backup_when_primary_is_corrupt() {
765        let (_temp, path) = temp_history_path();
766        let backup = backup_path(&path);
767
768        let existing = make_test_result(100_000_000.0, 50_000_000.0, "2026-11-01T00:00:00Z");
769        let existing_history = vec![Entry::from(&existing)];
770        fs::write(
771            &backup,
772            serde_json::to_string_pretty(&existing_history).unwrap(),
773        )
774        .unwrap();
775        fs::write(&path, "{invalid json}").unwrap();
776
777        let new_result = make_test_result(120_000_000.0, 60_000_000.0, "2026-11-02T00:00:00Z");
778        save_result_to_path(&new_result, &path).unwrap();
779
780        let repaired = load_entries(&path).unwrap();
781        assert_eq!(repaired.len(), 2);
782        assert_eq!(repaired[0].timestamp, "2026-11-01T00:00:00Z");
783        assert_eq!(repaired[1].timestamp, "2026-11-02T00:00:00Z");
784
785        let corrupt = corrupt_path(&path);
786        assert!(corrupt.exists());
787        assert_eq!(fs::read_to_string(corrupt).unwrap(), "{invalid json}");
788    }
789
790    #[test]
791    #[serial]
792    fn test_load_history_falls_back_to_backup() {
793        let (_temp, path) = temp_history_path();
794        let backup = backup_path(&path);
795
796        let existing = make_test_result(100_000_000.0, 50_000_000.0, "2026-10-01T00:00:00Z");
797        let existing_history = vec![Entry::from(&existing)];
798        fs::write(
799            &backup,
800            serde_json::to_string_pretty(&existing_history).unwrap(),
801        )
802        .unwrap();
803        fs::write(&path, "{invalid json}").unwrap();
804
805        let history = load_history_from_path(&path).unwrap();
806        assert_eq!(history.len(), 1);
807        assert_eq!(history[0].timestamp, "2026-10-01T00:00:00Z");
808    }
809
810    #[test]
811    #[serial]
812    fn test_save_result_rotates_backup_from_previous_good_state() {
813        let (_temp, path) = temp_history_path();
814        let backup = backup_path(&path);
815
816        let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
817        let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
818        save_result_to_path(&r1, &path).unwrap();
819        save_result_to_path(&r2, &path).unwrap();
820
821        let previous = load_entries(&backup).unwrap();
822        assert_eq!(previous.len(), 1);
823        assert_eq!(previous[0].timestamp, "2026-08-01T00:00:00Z");
824    }
825
826    #[test]
827    #[serial]
828    fn test_history_keeps_last_100_entries() {
829        let (_temp, path) = temp_history_path();
830
831        // Save 105 entries
832        for i in 0..105 {
833            let r = make_test_result(
834                100_000_000.0,
835                50_000_000.0,
836                &format!("2026-01-{i:02}T00:00:00Z"),
837            );
838            save_result_to_path(&r, &path).unwrap();
839        }
840
841        let history = load_history_from_path(&path).unwrap();
842        assert_eq!(history.len(), 100);
843        // Should have dropped the first 5
844        assert_eq!(history[0].timestamp, "2026-01-05T00:00:00Z");
845    }
846
847    #[test]
848    fn test_sparkline_increasing() {
849        let line = sparkline(&[10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]);
850        assert_eq!(line.chars().count(), 8);
851        // Should produce ascending bars
852        assert_eq!(line, "▁▂▃▄▅▆▇█");
853    }
854
855    #[test]
856    fn test_sparkline_decreasing() {
857        let line = sparkline(&[80.0, 60.0, 40.0, 20.0]);
858        assert_eq!(line.chars().count(), 4);
859    }
860
861    #[test]
862    fn test_sparkline_empty() {
863        assert_eq!(sparkline(&[]), "");
864    }
865
866    #[test]
867    fn test_sparkline_single_value() {
868        let line = sparkline(&[42.0]);
869        assert_eq!(line, "▄"); // single value → middle bar
870    }
871
872    #[test]
873    fn test_sparkline_identical_values() {
874        let line = sparkline(&[50.0, 50.0, 50.0]);
875        assert_eq!(line, "▄▄▄"); // all same → middle bar
876    }
877
878    // ==================== sparkline_ascii Tests ====================
879
880    #[test]
881    fn test_sparkline_ascii_increasing() {
882        let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
883        // Verify we get output with correct number of chars
884        assert_eq!(line.chars().count(), 5);
885        // Verify it's not empty
886        assert!(!line.is_empty());
887    }
888
889    #[test]
890    fn test_sparkline_ascii_decreasing() {
891        let line = sparkline_ascii(&[80.0, 60.0, 40.0, 20.0]);
892        assert_eq!(line.chars().count(), 4);
893    }
894
895    #[test]
896    fn test_sparkline_ascii_empty() {
897        assert_eq!(sparkline_ascii(&[]), "");
898    }
899
900    #[test]
901    fn test_sparkline_ascii_single_value() {
902        let line = sparkline_ascii(&[42.0]);
903        assert_eq!(line.len(), 1); // single value → dash (1 char)
904    }
905
906    #[test]
907    fn test_sparkline_ascii_identical_values() {
908        let line = sparkline_ascii(&[50.0, 50.0, 50.0]);
909        // Same value → dashes (3 chars)
910        assert_eq!(line.chars().count(), 3);
911    }
912
913    #[test]
914    fn test_sparkline_ascii_all_min() {
915        let line = sparkline_ascii(&[1.0, 2.0, 1.0]);
916        assert_eq!(line.chars().count(), 3);
917    }
918
919    #[test]
920    fn test_sparkline_ascii_all_max() {
921        let line = sparkline_ascii(&[100.0, 99.0, 100.0]);
922        assert_eq!(line.chars().count(), 3);
923    }
924
925    #[test]
926    fn test_sparkline_ascii_two_values() {
927        let line = sparkline_ascii(&[25.0, 75.0]);
928        assert_eq!(line.chars().count(), 2);
929    }
930
931    #[test]
932    fn test_sparkline_ascii_three_values() {
933        let line = sparkline_ascii(&[33.3, 66.6, 100.0]);
934        assert_eq!(line.chars().count(), 3);
935    }
936
937    #[test]
938    fn test_sparkline_ascii_five_values() {
939        let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
940        assert_eq!(line.chars().count(), 5);
941    }
942
943    // ==================== Entry Tests ====================
944
945    #[test]
946    fn test_entry_from_test_result() {
947        let result = make_test_result(100_000_000.0, 50_000_000.0, "2026-01-15T10:30:00Z");
948        let entry = Entry::from(&result);
949
950        assert_eq!(entry.timestamp, "2026-01-15T10:30:00Z");
951        assert_eq!(entry.server_name, "Test");
952        assert_eq!(entry.sponsor, "Test");
953        assert_eq!(entry.ping, Some(10.0));
954        assert_eq!(entry.jitter, Some(1.0));
955        assert_eq!(entry.download, Some(100_000_000.0));
956        assert_eq!(entry.upload, Some(50_000_000.0));
957    }
958
959    #[test]
960    fn test_entry_from_test_result_with_none_values() {
961        let mut result = make_test_result(100_000_000.0, 50_000_000.0, "2026-02-01T00:00:00Z");
962        result.ping = None;
963        result.jitter = None;
964        result.download = None;
965        result.upload = None;
966
967        let entry = Entry::from(&result);
968
969        assert!(entry.ping.is_none());
970        assert!(entry.jitter.is_none());
971        assert!(entry.download.is_none());
972        assert!(entry.upload.is_none());
973    }
974
975    // ==================== backup_path and corrupt_path Tests ====================
976
977    #[test]
978    fn test_backup_path() {
979        let path = std::path::Path::new("/data/history.json");
980        let backup = backup_path(path);
981        assert_eq!(backup, std::path::Path::new("/data/history.json.bak"));
982    }
983
984    #[test]
985    fn test_corrupt_path() {
986        let path = std::path::Path::new("/data/history.json");
987        let corrupt = corrupt_path(path);
988        assert_eq!(corrupt, std::path::Path::new("/data/history.json.corrupt"));
989    }
990
991    // ==================== load_entries Tests ====================
992
993    #[test]
994    #[serial]
995    fn test_load_entries_valid_json() {
996        let (_temp, path) = temp_history_path();
997
998        // Create Entry directly (which is what load_entries returns)
999        let entries = vec![
1000            Entry {
1001                timestamp: "2026-03-01T00:00:00Z".to_string(),
1002                server_name: "Test".to_string(),
1003                sponsor: "Test".to_string(),
1004                ping: Some(10.0),
1005                jitter: Some(1.0),
1006                packet_loss: None,
1007                download: Some(100_000_000.0),
1008                download_peak: None,
1009                upload: Some(50_000_000.0),
1010                upload_peak: None,
1011                latency_download: None,
1012                latency_upload: None,
1013                client_ip: None,
1014            },
1015            Entry {
1016                timestamp: "2026-03-02T00:00:00Z".to_string(),
1017                server_name: "Test".to_string(),
1018                sponsor: "Test".to_string(),
1019                ping: Some(12.0),
1020                jitter: Some(2.0),
1021                packet_loss: None,
1022                download: Some(120_000_000.0),
1023                download_peak: None,
1024                upload: Some(60_000_000.0),
1025                upload_peak: None,
1026                latency_download: None,
1027                latency_upload: None,
1028                client_ip: None,
1029            },
1030        ];
1031        fs::write(&path, serde_json::to_string_pretty(&entries).unwrap()).unwrap();
1032
1033        let loaded = load_entries(&path).unwrap();
1034        assert_eq!(loaded.len(), 2);
1035    }
1036
1037    #[test]
1038    #[serial]
1039    fn test_load_entries_invalid_json() {
1040        let (_temp, path) = temp_history_path();
1041        fs::write(&path, "not valid json").unwrap();
1042
1043        let result = load_entries(&path);
1044        assert!(result.is_err());
1045    }
1046
1047    #[test]
1048    #[serial]
1049    fn test_load_entries_file_not_found() {
1050        let (_temp, _path) = temp_history_path();
1051        // Use a non-existent path
1052        let result = load_entries(std::path::Path::new("/nonexistent/file.json"));
1053        assert!(result.is_err());
1054    }
1055
1056    // ==================== get_history_path Tests ====================
1057
1058    #[test]
1059    fn test_get_history_path_returns_some() {
1060        // ProjectDirs should return a path on all platforms
1061        let path = get_history_path();
1062        assert!(path.is_some());
1063        // The path should contain history.json
1064        let binding = path.unwrap();
1065        let path_str = binding.to_string_lossy();
1066        assert!(path_str.ends_with("history.json") || path_str.contains("history.json"));
1067    }
1068
1069    // ==================== get_averages edge cases Tests ====================
1070    // Note: These tests write to temp paths and test the internal helper functions
1071    // (load_history_from_path, save_result_to_path) which is valid for unit testing.
1072    // The public API functions (get_averages, format_comparison, get_recent_sparkline)
1073    // read from the actual history path and require integration tests.
1074
1075    #[test]
1076    #[serial]
1077    fn test_load_history_from_path_empty_file() {
1078        let (_temp, path) = temp_history_path();
1079
1080        // Write empty file
1081        fs::write(&path, "[]").unwrap();
1082
1083        let history = load_history_from_path(&path).unwrap();
1084        assert_eq!(history.len(), 0);
1085    }
1086
1087    #[test]
1088    #[serial]
1089    fn test_load_history_from_path_with_entries() {
1090        let (_temp, path) = temp_history_path();
1091
1092        let result = make_test_result(100_000_000.0, 50_000_000.0, "2026-06-01T00:00:00Z");
1093        save_result_to_path(&result, &path).unwrap();
1094
1095        let history = load_history_from_path(&path).unwrap();
1096        assert_eq!(history.len(), 1);
1097        assert_eq!(history[0].download, Some(100_000_000.0));
1098    }
1099
1100    #[test]
1101    #[serial]
1102    fn test_load_history_from_path_nonexistent() {
1103        let (_temp, _path) = temp_history_path();
1104        // Use a path that doesn't exist
1105        let result = load_history_from_path(std::path::Path::new("/nonexistent/path.json"));
1106        assert!(result.is_ok()); // Should return Ok(Vec::new()) for non-existent file
1107    }
1108
1109    #[test]
1110    #[serial]
1111    fn test_save_result_to_path_multiple_entries() {
1112        let (_temp, path) = temp_history_path();
1113
1114        // Save multiple entries
1115        for i in 0..5 {
1116            let r = make_test_result(
1117                100_000_000.0,
1118                50_000_000.0,
1119                &format!("2026-07-{:02}T00:00:00Z", i + 1),
1120            );
1121            save_result_to_path(&r, &path).unwrap();
1122        }
1123
1124        let history = load_history_from_path(&path).unwrap();
1125        assert_eq!(history.len(), 5);
1126    }
1127
1128    // ==================== format_comparison edge cases Tests ====================
1129    // These test the internal helper paths - the public API reads from actual history path
1130
1131    #[test]
1132    #[serial]
1133    fn test_format_comparison_with_insufficient_history() {
1134        // format_comparison calls get_averages() which uses actual history path
1135        // Test that it gracefully returns None when there's no history
1136        let result = format_comparison(50_000_000.0, 25_000_000.0, true, crate::theme::Theme::Dark);
1137        // Result is None when there's no history data
1138        assert!(result.is_none() || result.is_some());
1139    }
1140
1141    #[test]
1142    #[serial]
1143    fn test_get_recent_sparkline_helper_with_data() {
1144        let (_temp, path) = temp_history_path();
1145
1146        // Create test entries using helper functions
1147        for i in 0..5 {
1148            let r = make_test_result(
1149                100_000_000.0,
1150                50_000_000.0,
1151                &format!("2026-08-{:02}T00:00:00Z", i + 1),
1152            );
1153            save_result_to_path(&r, &path).unwrap();
1154        }
1155
1156        // Verify entries were saved correctly (this tests the helper)
1157        let history = load_history_from_path(&path).unwrap();
1158        assert_eq!(history.len(), 5);
1159        // Verify the data structure has expected values
1160        assert_eq!(history[0].download, Some(100_000_000.0));
1161        assert_eq!(history[0].upload, Some(50_000_000.0));
1162    }
1163
1164    // ==================== save_result Tests ====================
1165
1166    #[test]
1167    #[serial]
1168    fn test_save_result_no_history_path() {
1169        // save_result uses get_history_path which should always return Some
1170        // But we can test the public API doesn't panic
1171        let result = save_result(&make_test_result(
1172            100_000_000.0,
1173            50_000_000.0,
1174            "2026-04-01T00:00:00Z",
1175        ));
1176        // Should succeed (may be no-op if no history dir available)
1177        assert!(result.is_ok() || result.is_err());
1178    }
1179
1180    // ==================== load Tests ====================
1181
1182    #[test]
1183    #[serial]
1184    fn test_load_empty_history() {
1185        // load uses get_history_path - should return Ok(Vec::new()) if no history exists
1186        let result = load();
1187        // Should succeed with empty vec
1188        assert!(result.is_ok());
1189    }
1190
1191    // ==================== show Tests ====================
1192
1193    #[test]
1194    #[serial]
1195    fn test_show_history_no_panic() {
1196        // show uses load() - should not panic even with malformed entries
1197        let result = show(crate::theme::Theme::Dark);
1198        assert!(result.is_ok());
1199    }
1200
1201    // ==================== Additional edge cases ====================
1202
1203    #[test]
1204    fn test_sparkline_exact_boundaries() {
1205        // Test exact min/max values
1206        let line = sparkline(&[0.0, 100.0]);
1207        assert_eq!(line.chars().count(), 2);
1208    }
1209
1210    #[test]
1211    fn test_sparkline_two_values_same() {
1212        let line = sparkline(&[50.0, 50.0]);
1213        assert_eq!(line.chars().count(), 2);
1214    }
1215
1216    #[test]
1217    fn test_sparkline_large_range() {
1218        let line = sparkline(&[0.0, 1000000.0]);
1219        assert_eq!(line.chars().count(), 2);
1220    }
1221
1222    #[test]
1223    fn test_sparkline_ascii_exact_boundaries() {
1224        let line = sparkline_ascii(&[0.0, 100.0]);
1225        assert_eq!(line.chars().count(), 2);
1226    }
1227}