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 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 entries(&self) -> &HashMap<String, HistoryEntry> {
107 &self.entries
108 }
109
110 pub fn entry(&self, alias: &str) -> Option<&HistoryEntry> {
111 self.entries.get(alias)
112 }
113
114 pub fn upsert_entry(&mut self, entry: HistoryEntry) {
115 self.entries.insert(entry.alias.clone(), entry);
116 }
117
118 pub fn record(&mut self, alias: &str) {
120 let now = SystemTime::now()
121 .duration_since(UNIX_EPOCH)
122 .unwrap_or_default()
123 .as_secs();
124 let entry = self
125 .entries
126 .entry(alias.to_string())
127 .or_insert(HistoryEntry {
128 alias: alias.to_string(),
129 last_connected: 0,
130 count: 0,
131 timestamps: Vec::new(),
132 });
133 entry.last_connected = now;
134 entry.count = entry.count.saturating_add(1);
135 entry.timestamps.push(now);
136 let cutoff = now.saturating_sub(RETENTION_SECS);
137 entry.timestamps.retain(|&t| t >= cutoff);
138 if entry.timestamps.len() > MAX_TIMESTAMPS {
139 let excess = entry.timestamps.len() - MAX_TIMESTAMPS;
140 entry.timestamps.drain(..excess);
141 }
142 if let Err(e) = self.save() {
143 warn!("[config] Failed to save connection history: {e}");
144 }
145 }
146
147 pub fn rename(&mut self, old_alias: &str, new_alias: &str) -> bool {
157 if old_alias == new_alias {
158 return false;
159 }
160 let Some(mut moved) = self.entries.remove(old_alias) else {
161 return false;
162 };
163 moved.alias = new_alias.to_string();
164 if let Some(existing) = self.entries.remove(new_alias) {
165 moved.count = moved.count.saturating_add(existing.count);
166 moved.last_connected = moved.last_connected.max(existing.last_connected);
167 moved.timestamps.extend(existing.timestamps);
168 moved.timestamps.sort_unstable();
169 moved.timestamps.dedup();
170 let cutoff = SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .unwrap_or_default()
173 .as_secs()
174 .saturating_sub(RETENTION_SECS);
175 moved.timestamps.retain(|&t| t >= cutoff);
176 if moved.timestamps.len() > MAX_TIMESTAMPS {
177 let excess = moved.timestamps.len() - MAX_TIMESTAMPS;
178 moved.timestamps.drain(..excess);
179 }
180 }
181 self.entries.insert(new_alias.to_string(), moved);
182 if let Err(e) = self.save() {
183 warn!("[config] Failed to save connection history after rename: {e}");
184 }
185 true
186 }
187
188 pub fn last_connected(&self, alias: &str) -> u64 {
190 self.entries.get(alias).map_or(0, |e| e.last_connected)
191 }
192
193 pub fn frecency_score(&self, alias: &str) -> f64 {
195 let entry = match self.entries.get(alias) {
196 Some(e) => e,
197 None => return 0.0,
198 };
199 let now = SystemTime::now()
200 .duration_since(UNIX_EPOCH)
201 .unwrap_or_default()
202 .as_secs();
203 let age_hours = (now.saturating_sub(entry.last_connected)) as f64 / 3600.0;
204 let recency = 1.0 / (1.0 + age_hours / 24.0);
205 entry.count as f64 * recency
206 }
207
208 pub fn format_time_ago(timestamp: u64) -> String {
210 if timestamp == 0 {
211 return String::new();
212 }
213 let now = if crate::demo_flag::is_demo() {
217 crate::demo_flag::now_secs()
218 } else {
219 SystemTime::now()
220 .duration_since(UNIX_EPOCH)
221 .unwrap_or_default()
222 .as_secs()
223 };
224 let diff = now.saturating_sub(timestamp);
225 if diff < 60 {
226 "<1m".to_string()
227 } else if diff < 3600 {
228 format!("{}m", diff / 60)
229 } else if diff < 86400 {
230 format!("{}h", diff / 3600)
231 } else if diff < 604800 {
232 format!("{}d", diff / 86400)
233 } else {
234 format!("{}w", diff / 604800)
235 }
236 }
237
238 fn save(&self) -> std::io::Result<()> {
239 if crate::demo_flag::is_demo() {
240 return Ok(());
241 }
242 let mut sorted: Vec<_> = self.entries.values().collect();
244 sorted.sort_by(|a, b| a.alias.cmp(&b.alias));
245 let mut content = String::new();
246 for (i, e) in sorted.iter().enumerate() {
247 if i > 0 {
248 content.push('\n');
249 }
250 content.push_str(&e.alias);
251 content.push('\t');
252 content.push_str(&e.last_connected.to_string());
253 content.push('\t');
254 content.push_str(&e.count.to_string());
255 if !e.timestamps.is_empty() {
256 content.push('\t');
257 let ts_strs: Vec<String> = e.timestamps.iter().map(|t| t.to_string()).collect();
258 content.push_str(&ts_strs.join(","));
259 }
260 }
261 if !content.is_empty() {
262 content.push('\n');
263 }
264 fs_util::atomic_write(&self.path, content.as_bytes())
265 }
266
267 fn history_path() -> Option<PathBuf> {
268 #[cfg(test)]
269 {
270 if let Some(p) = test_path::get() {
271 return Some(p);
272 }
273 }
274 dirs::home_dir().map(|h| h.join(".purple/history.tsv"))
275 }
276}
277
278#[cfg(test)]
284pub mod test_path {
285 use std::cell::RefCell;
286 use std::path::PathBuf;
287
288 thread_local! {
289 static OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
290 }
291
292 pub fn set(path: PathBuf) {
293 OVERRIDE.with(|cell| *cell.borrow_mut() = Some(path));
294 }
295
296 pub fn clear() {
297 OVERRIDE.with(|cell| *cell.borrow_mut() = None);
298 }
299
300 pub fn get() -> Option<PathBuf> {
301 OVERRIDE.with(|cell| cell.borrow().clone())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_frecency_score_unknown_alias() {
311 let history = ConnectionHistory::default();
312 assert_eq!(history.frecency_score("unknown"), 0.0);
313 }
314
315 #[test]
316 fn test_format_time_ago_zero() {
317 assert_eq!(ConnectionHistory::format_time_ago(0), "");
318 }
319
320 #[test]
321 fn test_timestamps_parsing_roundtrip() {
322 let now = SystemTime::now()
323 .duration_since(UNIX_EPOCH)
324 .unwrap()
325 .as_secs();
326 let tsv = format!(
327 "myhost\t{}\t5\t{},{},{}",
328 now,
329 now - 100,
330 now - 200,
331 now - 300
332 );
333 let dir = std::env::temp_dir().join(format!(
334 "purple_test_history_{:?}",
335 std::thread::current().id()
336 ));
337 let _ = std::fs::create_dir_all(&dir);
338 let path = dir.join("history.tsv");
339 std::fs::write(&path, &tsv).unwrap();
340
341 let mut history = ConnectionHistory {
342 entries: HashMap::new(),
343 path: path.clone(),
344 };
345 let content = std::fs::read_to_string(&path).unwrap();
346 for line in content.lines() {
347 let parts: Vec<&str> = line.splitn(4, '\t').collect();
348 if parts.len() >= 3 {
349 if let (Ok(ts), Ok(count)) = (parts[1].parse::<u64>(), parts[2].parse::<u32>()) {
350 let timestamps = if parts.len() == 4 && !parts[3].is_empty() {
351 parts[3]
352 .split(',')
353 .filter_map(|s| s.parse::<u64>().ok())
354 .collect()
355 } else {
356 Vec::new()
357 };
358 history.entries.insert(
359 parts[0].to_string(),
360 HistoryEntry {
361 alias: parts[0].to_string(),
362 last_connected: ts,
363 count,
364 timestamps,
365 },
366 );
367 }
368 }
369 }
370
371 let entry = history.entries.get("myhost").unwrap();
372 assert_eq!(entry.count, 5);
373 assert_eq!(entry.timestamps.len(), 3);
374 assert_eq!(entry.timestamps[0], now - 100);
375
376 history.save().unwrap();
378 let reloaded = std::fs::read_to_string(&path).unwrap();
379 assert!(reloaded.contains("myhost"));
380 assert!(reloaded.contains(&(now - 100).to_string()));
381
382 let _ = std::fs::remove_dir_all(&dir);
383 }
384
385 #[test]
386 fn test_timestamps_retention_prunes_old() {
387 let now = SystemTime::now()
388 .duration_since(UNIX_EPOCH)
389 .unwrap()
390 .as_secs();
391 let old = now - 400 * 86400; let recent = now - 10 * 86400; let dir = std::env::temp_dir().join(format!(
395 "purple_test_retention_{:?}",
396 std::thread::current().id()
397 ));
398 let _ = std::fs::create_dir_all(&dir);
399 let path = dir.join("history.tsv");
400 let tsv = format!("host1\t{}\t2\t{},{}", now, old, recent);
401 std::fs::write(&path, &tsv).unwrap();
402
403 let mut entries = HashMap::new();
405 let cutoff = now.saturating_sub(RETENTION_SECS);
406 entries.insert(
407 "host1".to_string(),
408 HistoryEntry {
409 alias: "host1".to_string(),
410 last_connected: now,
411 count: 2,
412 timestamps: vec![old, recent],
413 },
414 );
415 for entry in entries.values_mut() {
416 entry.timestamps.retain(|&t| t >= cutoff);
417 }
418
419 let entry = entries.get("host1").unwrap();
420 assert_eq!(entry.timestamps.len(), 1, "old timestamp should be pruned");
421 assert_eq!(entry.timestamps[0], recent);
422
423 let _ = std::fs::remove_dir_all(&dir);
424 }
425
426 #[test]
427 fn test_timestamps_cap() {
428 let now = SystemTime::now()
429 .duration_since(UNIX_EPOCH)
430 .unwrap()
431 .as_secs();
432 let mut timestamps: Vec<u64> = (0..MAX_TIMESTAMPS + 500)
433 .map(|i| now - (i as u64))
434 .collect();
435 timestamps.sort();
436
437 let cutoff = now.saturating_sub(RETENTION_SECS);
438 timestamps.retain(|&t| t >= cutoff);
439 if timestamps.len() > MAX_TIMESTAMPS {
440 let excess = timestamps.len() - MAX_TIMESTAMPS;
441 timestamps.drain(..excess);
442 }
443
444 assert!(timestamps.len() <= MAX_TIMESTAMPS);
445 assert_eq!(*timestamps.last().unwrap(), now);
447 }
448
449 #[test]
450 fn test_retention_keeps_nine_months() {
451 let now = SystemTime::now()
452 .duration_since(UNIX_EPOCH)
453 .unwrap()
454 .as_secs();
455 let nine_months = now - 270 * 86400;
456 let six_months = now - 180 * 86400;
457 let recent = now - 86400;
458
459 let cutoff = now.saturating_sub(RETENTION_SECS);
460 let mut timestamps = vec![nine_months, six_months, recent];
461 timestamps.retain(|&t| t >= cutoff);
462
463 assert_eq!(
464 timestamps.len(),
465 3,
466 "9-month-old timestamps must be retained"
467 );
468 assert_eq!(timestamps[0], nine_months);
469 }
470
471 #[test]
472 fn test_retention_prunes_beyond_one_year() {
473 let now = SystemTime::now()
474 .duration_since(UNIX_EPOCH)
475 .unwrap()
476 .as_secs();
477 let thirteen_months = now - 400 * 86400;
478 let recent = now - 86400;
479
480 let cutoff = now.saturating_sub(RETENTION_SECS);
481 let mut timestamps = vec![thirteen_months, recent];
482 timestamps.retain(|&t| t >= cutoff);
483
484 assert_eq!(timestamps.len(), 1, "13-month-old timestamp must be pruned");
485 assert_eq!(timestamps[0], recent);
486 }
487
488 #[test]
489 fn test_timestamps_empty_fourth_column() {
490 let now = SystemTime::now()
492 .duration_since(UNIX_EPOCH)
493 .unwrap()
494 .as_secs();
495 let line = format!("oldhost\t{}\t10", now);
496 let parts: Vec<&str> = line.splitn(4, '\t').collect();
497 assert_eq!(parts.len(), 3);
498 let timestamps: Vec<u64> = if parts.len() == 4 && !parts[3].is_empty() {
499 parts[3]
500 .split(',')
501 .filter_map(|s| s.parse::<u64>().ok())
502 .collect()
503 } else {
504 Vec::new()
505 };
506 assert!(timestamps.is_empty());
507 }
508
509 #[test]
510 fn test_format_time_ago_recent() {
511 let now = SystemTime::now()
512 .duration_since(UNIX_EPOCH)
513 .unwrap()
514 .as_secs();
515 assert_eq!(ConnectionHistory::format_time_ago(now), "<1m");
516 assert_eq!(ConnectionHistory::format_time_ago(now - 300), "5m");
517 assert_eq!(ConnectionHistory::format_time_ago(now - 7200), "2h");
518 assert_eq!(ConnectionHistory::format_time_ago(now - 172800), "2d");
519 }
520
521 fn make_entry(alias: &str, last: u64, count: u32, timestamps: Vec<u64>) -> HistoryEntry {
522 HistoryEntry {
523 alias: alias.to_string(),
524 last_connected: last,
525 count,
526 timestamps,
527 }
528 }
529
530 #[test]
531 fn rename_moves_entry_under_new_key() {
532 let dir = tempfile::tempdir().unwrap();
533 let path = dir.path().join("history.tsv");
534 let mut history = ConnectionHistory {
535 entries: HashMap::new(),
536 path: path.clone(),
537 };
538 let now = 1_700_000_000;
539 history.entries.insert(
540 "web-old".to_string(),
541 make_entry("web-old", now, 7, vec![now - 60, now]),
542 );
543
544 assert!(history.rename("web-old", "web-new"));
545 assert!(!history.entries.contains_key("web-old"));
546 let moved = history.entries.get("web-new").expect("entry under new key");
547 assert_eq!(moved.alias, "web-new");
548 assert_eq!(moved.count, 7);
549 assert_eq!(moved.last_connected, now);
550 assert_eq!(moved.timestamps, vec![now - 60, now]);
551 let saved = std::fs::read_to_string(&path).unwrap();
552 assert!(saved.starts_with("web-new\t"));
553 assert!(!saved.contains("web-old"));
554 }
555
556 #[test]
557 fn rename_merges_when_new_key_already_exists() {
558 let dir = tempfile::tempdir().unwrap();
559 let path = dir.path().join("history.tsv");
560 let mut history = ConnectionHistory {
561 entries: HashMap::new(),
562 path,
563 };
564 let now = SystemTime::now()
565 .duration_since(UNIX_EPOCH)
566 .unwrap()
567 .as_secs();
568 history.entries.insert(
569 "a".to_string(),
570 make_entry("a", now - 100, 3, vec![now - 200, now - 100]),
571 );
572 history.entries.insert(
573 "b".to_string(),
574 make_entry("b", now - 50, 5, vec![now - 100, now - 50]),
575 );
576
577 assert!(history.rename("a", "b"));
578 let merged = history.entries.get("b").expect("merged entry");
579 assert_eq!(merged.count, 8, "counts sum on collision");
580 assert_eq!(
581 merged.last_connected,
582 now - 50,
583 "most recent timestamp wins"
584 );
585 assert_eq!(merged.timestamps, vec![now - 200, now - 100, now - 50]);
587 assert!(!history.entries.contains_key("a"));
588 }
589
590 #[test]
591 fn rename_noop_when_same_alias() {
592 let mut history = ConnectionHistory::default();
593 history
594 .entries
595 .insert("a".to_string(), make_entry("a", 1, 1, vec![1]));
596 assert!(!history.rename("a", "a"));
597 assert!(history.entries.contains_key("a"));
598 }
599
600 #[test]
601 fn rename_noop_when_old_absent() {
602 let mut history = ConnectionHistory::default();
603 assert!(!history.rename("ghost", "phantom"));
604 assert!(history.entries.is_empty());
605 }
606}