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    pub 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    /// Record a connection to a host.
107    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    /// Move a host's entry from `old_alias` to `new_alias`. Called from the
136    /// host-form rename path so connection counts and timestamps survive a
137    /// rename. When both keys carry entries (defensive, should not occur in
138    /// practice because SSH config writes reject collisions) the two are
139    /// merged: counts sum, the most recent `last_connected` wins, and the
140    /// timestamp lists are concatenated then pruned by the same retention
141    /// and cap rules used on load.
142    ///
143    /// Returns `true` when the file changed.
144    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    /// Last connected timestamp for a host (0 if never connected).
177    pub fn last_connected(&self, alias: &str) -> u64 {
178        self.entries.get(alias).map_or(0, |e| e.last_connected)
179    }
180
181    /// Frecency score: count weighted by recency.
182    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    /// Format a timestamp as a human-readable "time ago" string.
197    pub fn format_time_ago(timestamp: u64) -> String {
198        if timestamp == 0 {
199            return String::new();
200        }
201        // In demo mode read from a frozen reference clock so visual goldens
202        // do not flake when render time straddles a minute boundary after
203        // demo-data build time.
204        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        // Sort by alias for deterministic output
231        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/// Thread-local path override for tests. Mirrors the pattern in
267/// `crate::app::jump::test_path` so a test can isolate
268/// `ConnectionHistory::load()` from `~/.purple/history.tsv` without
269/// mutating `HOME`. Thread-local so parallel `cargo test` threads
270/// cannot see each other's overrides.
271#[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        // Save and reload to verify roundtrip
365        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; // 400 days ago — beyond 365-day retention
380        let recent = now - 10 * 86400; // 10 days ago — within retention
381
382        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        // Simulate load with retention pruning
392        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        // Should keep the most recent timestamps
434        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        // A 3-column line (no timestamps) should parse with empty timestamps
479        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        // Shared `now - 100` timestamp must be deduplicated.
574        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}