1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use log::warn;
7
8use crate::fs_util;
9
10const RETENTION_SECS: u64 = 365 * 86400;
12
13const MAX_TIMESTAMPS: usize = 10_000;
15
16#[derive(Debug, Clone)]
18pub struct HistoryEntry {
19 pub alias: String,
20 pub last_connected: u64,
21 pub count: u32,
22 pub timestamps: Vec<u64>,
24}
25
26#[derive(Debug, Clone, Default)]
28pub struct ConnectionHistory {
29 pub entries: HashMap<String, HistoryEntry>,
30 path: PathBuf,
31}
32
33impl ConnectionHistory {
34 pub fn load() -> Self {
36 let path = match Self::history_path() {
37 Some(p) => p,
38 None => return Self::default(),
39 };
40 if !path.exists() {
41 return Self {
42 entries: HashMap::new(),
43 path,
44 };
45 }
46 let content = match fs::read_to_string(&path) {
47 Ok(c) => c,
48 Err(e) => {
49 if e.kind() != std::io::ErrorKind::NotFound {
50 warn!("[config] Failed to read connection history: {e}");
51 }
52 return Self {
53 entries: HashMap::new(),
54 path,
55 };
56 }
57 };
58 let mut entries = HashMap::new();
59 for line in content.lines() {
60 let parts: Vec<&str> = line.splitn(4, '\t').collect();
61 if parts.len() >= 3 {
62 if let (Ok(ts), Ok(count)) = (parts[1].parse::<u64>(), parts[2].parse::<u32>()) {
63 let timestamps = if parts.len() == 4 && !parts[3].is_empty() {
64 parts[3]
65 .split(',')
66 .filter_map(|s| s.parse::<u64>().ok())
67 .collect()
68 } else {
69 Vec::new()
70 };
71 entries.insert(
72 parts[0].to_string(),
73 HistoryEntry {
74 alias: parts[0].to_string(),
75 last_connected: ts,
76 count,
77 timestamps,
78 },
79 );
80 }
81 }
82 }
83 let cutoff = SystemTime::now()
84 .duration_since(UNIX_EPOCH)
85 .unwrap_or_default()
86 .as_secs()
87 .saturating_sub(RETENTION_SECS);
88 for entry in entries.values_mut() {
89 entry.timestamps.retain(|&t| t >= cutoff);
90 if entry.timestamps.len() > MAX_TIMESTAMPS {
91 let excess = entry.timestamps.len() - MAX_TIMESTAMPS;
92 entry.timestamps.drain(..excess);
93 }
94 }
95 Self { entries, path }
96 }
97
98 pub fn from_entries(entries: HashMap<String, HistoryEntry>) -> Self {
100 Self {
101 entries,
102 path: PathBuf::new(),
103 }
104 }
105
106 pub fn record(&mut self, alias: &str) {
108 let now = SystemTime::now()
109 .duration_since(UNIX_EPOCH)
110 .unwrap_or_default()
111 .as_secs();
112 let entry = self
113 .entries
114 .entry(alias.to_string())
115 .or_insert(HistoryEntry {
116 alias: alias.to_string(),
117 last_connected: 0,
118 count: 0,
119 timestamps: Vec::new(),
120 });
121 entry.last_connected = now;
122 entry.count = entry.count.saturating_add(1);
123 entry.timestamps.push(now);
124 let cutoff = now.saturating_sub(RETENTION_SECS);
125 entry.timestamps.retain(|&t| t >= cutoff);
126 if entry.timestamps.len() > MAX_TIMESTAMPS {
127 let excess = entry.timestamps.len() - MAX_TIMESTAMPS;
128 entry.timestamps.drain(..excess);
129 }
130 if let Err(e) = self.save() {
131 warn!("[config] Failed to save connection history: {e}");
132 }
133 }
134
135 pub fn rename(&mut self, old_alias: &str, new_alias: &str) -> bool {
145 if old_alias == new_alias {
146 return false;
147 }
148 let Some(mut moved) = self.entries.remove(old_alias) else {
149 return false;
150 };
151 moved.alias = new_alias.to_string();
152 if let Some(existing) = self.entries.remove(new_alias) {
153 moved.count = moved.count.saturating_add(existing.count);
154 moved.last_connected = moved.last_connected.max(existing.last_connected);
155 moved.timestamps.extend(existing.timestamps);
156 moved.timestamps.sort_unstable();
157 moved.timestamps.dedup();
158 let cutoff = SystemTime::now()
159 .duration_since(UNIX_EPOCH)
160 .unwrap_or_default()
161 .as_secs()
162 .saturating_sub(RETENTION_SECS);
163 moved.timestamps.retain(|&t| t >= cutoff);
164 if moved.timestamps.len() > MAX_TIMESTAMPS {
165 let excess = moved.timestamps.len() - MAX_TIMESTAMPS;
166 moved.timestamps.drain(..excess);
167 }
168 }
169 self.entries.insert(new_alias.to_string(), moved);
170 if let Err(e) = self.save() {
171 warn!("[config] Failed to save connection history after rename: {e}");
172 }
173 true
174 }
175
176 pub fn last_connected(&self, alias: &str) -> u64 {
178 self.entries.get(alias).map_or(0, |e| e.last_connected)
179 }
180
181 pub fn frecency_score(&self, alias: &str) -> f64 {
183 let entry = match self.entries.get(alias) {
184 Some(e) => e,
185 None => return 0.0,
186 };
187 let now = SystemTime::now()
188 .duration_since(UNIX_EPOCH)
189 .unwrap_or_default()
190 .as_secs();
191 let age_hours = (now.saturating_sub(entry.last_connected)) as f64 / 3600.0;
192 let recency = 1.0 / (1.0 + age_hours / 24.0);
193 entry.count as f64 * recency
194 }
195
196 pub fn format_time_ago(timestamp: u64) -> String {
198 if timestamp == 0 {
199 return String::new();
200 }
201 let now = if crate::demo_flag::is_demo() {
205 crate::demo_flag::now_secs()
206 } else {
207 SystemTime::now()
208 .duration_since(UNIX_EPOCH)
209 .unwrap_or_default()
210 .as_secs()
211 };
212 let diff = now.saturating_sub(timestamp);
213 if diff < 60 {
214 "<1m".to_string()
215 } else if diff < 3600 {
216 format!("{}m", diff / 60)
217 } else if diff < 86400 {
218 format!("{}h", diff / 3600)
219 } else if diff < 604800 {
220 format!("{}d", diff / 86400)
221 } else {
222 format!("{}w", diff / 604800)
223 }
224 }
225
226 fn save(&self) -> std::io::Result<()> {
227 if crate::demo_flag::is_demo() {
228 return Ok(());
229 }
230 let mut sorted: Vec<_> = self.entries.values().collect();
232 sorted.sort_by(|a, b| a.alias.cmp(&b.alias));
233 let mut content = String::new();
234 for (i, e) in sorted.iter().enumerate() {
235 if i > 0 {
236 content.push('\n');
237 }
238 content.push_str(&e.alias);
239 content.push('\t');
240 content.push_str(&e.last_connected.to_string());
241 content.push('\t');
242 content.push_str(&e.count.to_string());
243 if !e.timestamps.is_empty() {
244 content.push('\t');
245 let ts_strs: Vec<String> = e.timestamps.iter().map(|t| t.to_string()).collect();
246 content.push_str(&ts_strs.join(","));
247 }
248 }
249 if !content.is_empty() {
250 content.push('\n');
251 }
252 fs_util::atomic_write(&self.path, content.as_bytes())
253 }
254
255 fn history_path() -> Option<PathBuf> {
256 #[cfg(test)]
257 {
258 if let Some(p) = test_path::get() {
259 return Some(p);
260 }
261 }
262 dirs::home_dir().map(|h| h.join(".purple/history.tsv"))
263 }
264}
265
266#[cfg(test)]
272pub mod test_path {
273 use std::cell::RefCell;
274 use std::path::PathBuf;
275
276 thread_local! {
277 static OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
278 }
279
280 pub fn set(path: PathBuf) {
281 OVERRIDE.with(|cell| *cell.borrow_mut() = Some(path));
282 }
283
284 pub fn clear() {
285 OVERRIDE.with(|cell| *cell.borrow_mut() = None);
286 }
287
288 pub fn get() -> Option<PathBuf> {
289 OVERRIDE.with(|cell| cell.borrow().clone())
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_frecency_score_unknown_alias() {
299 let history = ConnectionHistory::default();
300 assert_eq!(history.frecency_score("unknown"), 0.0);
301 }
302
303 #[test]
304 fn test_format_time_ago_zero() {
305 assert_eq!(ConnectionHistory::format_time_ago(0), "");
306 }
307
308 #[test]
309 fn test_timestamps_parsing_roundtrip() {
310 let now = SystemTime::now()
311 .duration_since(UNIX_EPOCH)
312 .unwrap()
313 .as_secs();
314 let tsv = format!(
315 "myhost\t{}\t5\t{},{},{}",
316 now,
317 now - 100,
318 now - 200,
319 now - 300
320 );
321 let dir = std::env::temp_dir().join(format!(
322 "purple_test_history_{:?}",
323 std::thread::current().id()
324 ));
325 let _ = std::fs::create_dir_all(&dir);
326 let path = dir.join("history.tsv");
327 std::fs::write(&path, &tsv).unwrap();
328
329 let mut history = ConnectionHistory {
330 entries: HashMap::new(),
331 path: path.clone(),
332 };
333 let content = std::fs::read_to_string(&path).unwrap();
334 for line in content.lines() {
335 let parts: Vec<&str> = line.splitn(4, '\t').collect();
336 if parts.len() >= 3 {
337 if let (Ok(ts), Ok(count)) = (parts[1].parse::<u64>(), parts[2].parse::<u32>()) {
338 let timestamps = if parts.len() == 4 && !parts[3].is_empty() {
339 parts[3]
340 .split(',')
341 .filter_map(|s| s.parse::<u64>().ok())
342 .collect()
343 } else {
344 Vec::new()
345 };
346 history.entries.insert(
347 parts[0].to_string(),
348 HistoryEntry {
349 alias: parts[0].to_string(),
350 last_connected: ts,
351 count,
352 timestamps,
353 },
354 );
355 }
356 }
357 }
358
359 let entry = history.entries.get("myhost").unwrap();
360 assert_eq!(entry.count, 5);
361 assert_eq!(entry.timestamps.len(), 3);
362 assert_eq!(entry.timestamps[0], now - 100);
363
364 history.save().unwrap();
366 let reloaded = std::fs::read_to_string(&path).unwrap();
367 assert!(reloaded.contains("myhost"));
368 assert!(reloaded.contains(&(now - 100).to_string()));
369
370 let _ = std::fs::remove_dir_all(&dir);
371 }
372
373 #[test]
374 fn test_timestamps_retention_prunes_old() {
375 let now = SystemTime::now()
376 .duration_since(UNIX_EPOCH)
377 .unwrap()
378 .as_secs();
379 let old = now - 400 * 86400; let recent = now - 10 * 86400; let dir = std::env::temp_dir().join(format!(
383 "purple_test_retention_{:?}",
384 std::thread::current().id()
385 ));
386 let _ = std::fs::create_dir_all(&dir);
387 let path = dir.join("history.tsv");
388 let tsv = format!("host1\t{}\t2\t{},{}", now, old, recent);
389 std::fs::write(&path, &tsv).unwrap();
390
391 let mut entries = HashMap::new();
393 let cutoff = now.saturating_sub(RETENTION_SECS);
394 entries.insert(
395 "host1".to_string(),
396 HistoryEntry {
397 alias: "host1".to_string(),
398 last_connected: now,
399 count: 2,
400 timestamps: vec![old, recent],
401 },
402 );
403 for entry in entries.values_mut() {
404 entry.timestamps.retain(|&t| t >= cutoff);
405 }
406
407 let entry = entries.get("host1").unwrap();
408 assert_eq!(entry.timestamps.len(), 1, "old timestamp should be pruned");
409 assert_eq!(entry.timestamps[0], recent);
410
411 let _ = std::fs::remove_dir_all(&dir);
412 }
413
414 #[test]
415 fn test_timestamps_cap() {
416 let now = SystemTime::now()
417 .duration_since(UNIX_EPOCH)
418 .unwrap()
419 .as_secs();
420 let mut timestamps: Vec<u64> = (0..MAX_TIMESTAMPS + 500)
421 .map(|i| now - (i as u64))
422 .collect();
423 timestamps.sort();
424
425 let cutoff = now.saturating_sub(RETENTION_SECS);
426 timestamps.retain(|&t| t >= cutoff);
427 if timestamps.len() > MAX_TIMESTAMPS {
428 let excess = timestamps.len() - MAX_TIMESTAMPS;
429 timestamps.drain(..excess);
430 }
431
432 assert!(timestamps.len() <= MAX_TIMESTAMPS);
433 assert_eq!(*timestamps.last().unwrap(), now);
435 }
436
437 #[test]
438 fn test_retention_keeps_nine_months() {
439 let now = SystemTime::now()
440 .duration_since(UNIX_EPOCH)
441 .unwrap()
442 .as_secs();
443 let nine_months = now - 270 * 86400;
444 let six_months = now - 180 * 86400;
445 let recent = now - 86400;
446
447 let cutoff = now.saturating_sub(RETENTION_SECS);
448 let mut timestamps = vec![nine_months, six_months, recent];
449 timestamps.retain(|&t| t >= cutoff);
450
451 assert_eq!(
452 timestamps.len(),
453 3,
454 "9-month-old timestamps must be retained"
455 );
456 assert_eq!(timestamps[0], nine_months);
457 }
458
459 #[test]
460 fn test_retention_prunes_beyond_one_year() {
461 let now = SystemTime::now()
462 .duration_since(UNIX_EPOCH)
463 .unwrap()
464 .as_secs();
465 let thirteen_months = now - 400 * 86400;
466 let recent = now - 86400;
467
468 let cutoff = now.saturating_sub(RETENTION_SECS);
469 let mut timestamps = vec![thirteen_months, recent];
470 timestamps.retain(|&t| t >= cutoff);
471
472 assert_eq!(timestamps.len(), 1, "13-month-old timestamp must be pruned");
473 assert_eq!(timestamps[0], recent);
474 }
475
476 #[test]
477 fn test_timestamps_empty_fourth_column() {
478 let now = SystemTime::now()
480 .duration_since(UNIX_EPOCH)
481 .unwrap()
482 .as_secs();
483 let line = format!("oldhost\t{}\t10", now);
484 let parts: Vec<&str> = line.splitn(4, '\t').collect();
485 assert_eq!(parts.len(), 3);
486 let timestamps: Vec<u64> = if parts.len() == 4 && !parts[3].is_empty() {
487 parts[3]
488 .split(',')
489 .filter_map(|s| s.parse::<u64>().ok())
490 .collect()
491 } else {
492 Vec::new()
493 };
494 assert!(timestamps.is_empty());
495 }
496
497 #[test]
498 fn test_format_time_ago_recent() {
499 let now = SystemTime::now()
500 .duration_since(UNIX_EPOCH)
501 .unwrap()
502 .as_secs();
503 assert_eq!(ConnectionHistory::format_time_ago(now), "<1m");
504 assert_eq!(ConnectionHistory::format_time_ago(now - 300), "5m");
505 assert_eq!(ConnectionHistory::format_time_ago(now - 7200), "2h");
506 assert_eq!(ConnectionHistory::format_time_ago(now - 172800), "2d");
507 }
508
509 fn make_entry(alias: &str, last: u64, count: u32, timestamps: Vec<u64>) -> HistoryEntry {
510 HistoryEntry {
511 alias: alias.to_string(),
512 last_connected: last,
513 count,
514 timestamps,
515 }
516 }
517
518 #[test]
519 fn rename_moves_entry_under_new_key() {
520 let dir = tempfile::tempdir().unwrap();
521 let path = dir.path().join("history.tsv");
522 let mut history = ConnectionHistory {
523 entries: HashMap::new(),
524 path: path.clone(),
525 };
526 let now = 1_700_000_000;
527 history.entries.insert(
528 "web-old".to_string(),
529 make_entry("web-old", now, 7, vec![now - 60, now]),
530 );
531
532 assert!(history.rename("web-old", "web-new"));
533 assert!(!history.entries.contains_key("web-old"));
534 let moved = history.entries.get("web-new").expect("entry under new key");
535 assert_eq!(moved.alias, "web-new");
536 assert_eq!(moved.count, 7);
537 assert_eq!(moved.last_connected, now);
538 assert_eq!(moved.timestamps, vec![now - 60, now]);
539 let saved = std::fs::read_to_string(&path).unwrap();
540 assert!(saved.starts_with("web-new\t"));
541 assert!(!saved.contains("web-old"));
542 }
543
544 #[test]
545 fn rename_merges_when_new_key_already_exists() {
546 let dir = tempfile::tempdir().unwrap();
547 let path = dir.path().join("history.tsv");
548 let mut history = ConnectionHistory {
549 entries: HashMap::new(),
550 path,
551 };
552 let now = SystemTime::now()
553 .duration_since(UNIX_EPOCH)
554 .unwrap()
555 .as_secs();
556 history.entries.insert(
557 "a".to_string(),
558 make_entry("a", now - 100, 3, vec![now - 200, now - 100]),
559 );
560 history.entries.insert(
561 "b".to_string(),
562 make_entry("b", now - 50, 5, vec![now - 100, now - 50]),
563 );
564
565 assert!(history.rename("a", "b"));
566 let merged = history.entries.get("b").expect("merged entry");
567 assert_eq!(merged.count, 8, "counts sum on collision");
568 assert_eq!(
569 merged.last_connected,
570 now - 50,
571 "most recent timestamp wins"
572 );
573 assert_eq!(merged.timestamps, vec![now - 200, now - 100, now - 50]);
575 assert!(!history.entries.contains_key("a"));
576 }
577
578 #[test]
579 fn rename_noop_when_same_alias() {
580 let mut history = ConnectionHistory::default();
581 history
582 .entries
583 .insert("a".to_string(), make_entry("a", 1, 1, vec![1]));
584 assert!(!history.rename("a", "a"));
585 assert!(history.entries.contains_key("a"));
586 }
587
588 #[test]
589 fn rename_noop_when_old_absent() {
590 let mut history = ConnectionHistory::default();
591 assert!(!history.rename("ghost", "phantom"));
592 assert!(history.entries.is_empty());
593 }
594}