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