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