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 fs::write(path, json)?;
83
84 Ok(())
85}
86
87pub 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
101pub 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
115pub 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]; 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
166pub 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
189pub fn format_comparison(download_mbps: f64, upload_mbps: f64, nc: bool) -> Option<String> {
192 let (avg_dl, avg_ul) = get_averages()?;
193
194 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 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 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 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 fs::write(&path, "{invalid json}").unwrap();
416
417 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 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 assert_eq!(history[0].timestamp, "2026-01-05T00:00:00Z");
445 }
446}