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
54fn 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
65fn 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 if history.len() > 100 {
78 history.remove(0);
79 }
80
81 let json = serde_json::to_string_pretty(&history)?;
82
83 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
97pub 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
111pub 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
125pub 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]; 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
176pub 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
199pub fn format_comparison(download_mbps: f64, upload_mbps: f64, nc: bool) -> Option<String> {
202 let (avg_dl, avg_ul) = get_averages()?;
203
204 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#[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 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#[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 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 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 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 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 fs::write(&path, "{invalid json}").unwrap();
482
483 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 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 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 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, "▄"); }
537
538 #[test]
539 fn test_sparkline_identical_values() {
540 let line = sparkline(&[50.0, 50.0, 50.0]);
541 assert_eq!(line, "▄▄▄"); }
543}