Skip to main content

purple_ssh/
history.rs

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
11/// Timestamps older than this are pruned on load and after each record().
12const RETENTION_SECS: u64 = 365 * 86400;
13
14/// Hard cap on stored timestamps per host to bound memory and serialisation cost.
15const MAX_TIMESTAMPS: usize = 10_000;
16
17/// A single history entry for a host.
18#[derive(Debug, Clone)]
19pub struct HistoryEntry {
20    pub alias: String,
21    pub last_connected: u64,
22    pub count: u32,
23    /// Individual connection timestamps (last 365 days) for activity charts.
24    pub timestamps: Vec<u64>,
25}
26
27/// Connection history tracking.
28#[derive(Debug, Clone, Default)]
29pub struct ConnectionHistory {
30    entries: HashMap<String, HistoryEntry>,
31    path: PathBuf,
32}
33
34impl ConnectionHistory {
35    /// Load connection history from disk.
36    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    /// Create a ConnectionHistory from pre-built entries (for demo use).
100    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    /// Record a connection to a host.
120    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    /// Move a host's entry from `old_alias` to `new_alias`. Called from the
149    /// host-form rename path so connection counts and timestamps survive a
150    /// rename. When both keys carry entries (defensive, should not occur in
151    /// practice because SSH config writes reject collisions) the two are
152    /// merged: counts sum, the most recent `last_connected` wins, and the
153    /// timestamp lists are concatenated then pruned by the same retention
154    /// and cap rules used on load.
155    ///
156    /// Returns `true` when the file changed.
157    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    /// Last connected timestamp for a host (0 if never connected).
190    pub fn last_connected(&self, alias: &str) -> u64 {
191        self.entries.get(alias).map_or(0, |e| e.last_connected)
192    }
193
194    /// Frecency score: count weighted by recency.
195    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    /// Format a timestamp as a human-readable "time ago" string.
210    pub fn format_time_ago(timestamp: u64) -> String {
211        if timestamp == 0 {
212            return String::new();
213        }
214        // In demo mode read from a frozen reference clock so visual goldens
215        // do not flake when render time straddles a minute boundary after
216        // demo-data build time.
217        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        // Sort by alias for deterministic output
244        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        // Save and reload to verify roundtrip
345        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; // 400 days ago — beyond 365-day retention
360        let recent = now - 10 * 86400; // 10 days ago — within retention
361
362        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        // Simulate load with retention pruning
372        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        // Should keep the most recent timestamps
414        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        // A 3-column line (no timestamps) should parse with empty timestamps
459        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        // Shared `now - 100` timestamp must be deduplicated.
554        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}