purple-ssh 1.17.0

Manage SSH configs and launch connections from the terminal. TUI host manager with search, tags, tunnels, password management (keychain, 1Password, Bitwarden, pass, Vault), cloud sync (DigitalOcean, Vultr, Linode, Hetzner, UpCloud, Proxmox VE), self-update and round-trip fidelity for ~/.ssh/config.
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::fs_util;

/// A single history entry for a host.
#[derive(Debug, Clone)]
pub struct HistoryEntry {
    pub alias: String,
    pub last_connected: u64,
    pub count: u32,
}

/// Connection history tracking.
#[derive(Debug, Clone, Default)]
pub struct ConnectionHistory {
    pub entries: HashMap<String, HistoryEntry>,
    path: PathBuf,
}

impl ConnectionHistory {
    /// Load connection history from disk.
    pub fn load() -> Self {
        let path = match Self::history_path() {
            Some(p) => p,
            None => return Self::default(),
        };
        if !path.exists() {
            return Self {
                entries: HashMap::new(),
                path,
            };
        }
        let content = fs::read_to_string(&path).unwrap_or_default();
        let mut entries = HashMap::new();
        for line in content.lines() {
            let parts: Vec<&str> = line.splitn(3, '\t').collect();
            if parts.len() == 3 {
                if let (Ok(ts), Ok(count)) = (parts[1].parse::<u64>(), parts[2].parse::<u32>()) {
                    entries.insert(
                        parts[0].to_string(),
                        HistoryEntry {
                            alias: parts[0].to_string(),
                            last_connected: ts,
                            count,
                        },
                    );
                }
            }
        }
        Self { entries, path }
    }

    /// Record a connection to a host.
    pub fn record(&mut self, alias: &str) {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let entry = self
            .entries
            .entry(alias.to_string())
            .or_insert(HistoryEntry {
                alias: alias.to_string(),
                last_connected: 0,
                count: 0,
            });
        entry.last_connected = now;
        entry.count += 1;
        let _ = self.save();
    }

    /// Last connected timestamp for a host (0 if never connected).
    pub fn last_connected(&self, alias: &str) -> u64 {
        self.entries.get(alias).map_or(0, |e| e.last_connected)
    }

    /// Frecency score: count weighted by recency.
    pub fn frecency_score(&self, alias: &str) -> f64 {
        let entry = match self.entries.get(alias) {
            Some(e) => e,
            None => return 0.0,
        };
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let age_hours = (now.saturating_sub(entry.last_connected)) as f64 / 3600.0;
        let recency = 1.0 / (1.0 + age_hours / 24.0);
        entry.count as f64 * recency
    }

    /// Format a timestamp as a human-readable "time ago" string.
    pub fn format_time_ago(timestamp: u64) -> String {
        if timestamp == 0 {
            return String::new();
        }
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let diff = now.saturating_sub(timestamp);
        if diff < 60 {
            "<1m ago".to_string()
        } else if diff < 3600 {
            format!("{}m ago", diff / 60)
        } else if diff < 86400 {
            format!("{}h ago", diff / 3600)
        } else if diff < 604800 {
            format!("{}d ago", diff / 86400)
        } else {
            format!("{}w ago", diff / 604800)
        }
    }

    fn save(&self) -> std::io::Result<()> {
        // Sort by alias for deterministic output
        let mut sorted: Vec<_> = self.entries.values().collect();
        sorted.sort_by(|a, b| a.alias.cmp(&b.alias));
        let mut content = String::new();
        for (i, e) in sorted.iter().enumerate() {
            if i > 0 {
                content.push('\n');
            }
            content.push_str(&e.alias);
            content.push('\t');
            content.push_str(&e.last_connected.to_string());
            content.push('\t');
            content.push_str(&e.count.to_string());
        }
        fs_util::atomic_write(&self.path, content.as_bytes())
    }

    fn history_path() -> Option<PathBuf> {
        dirs::home_dir().map(|h| h.join(".purple/history.tsv"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_frecency_score_unknown_alias() {
        let history = ConnectionHistory::default();
        assert_eq!(history.frecency_score("unknown"), 0.0);
    }

    #[test]
    fn test_format_time_ago_zero() {
        assert_eq!(ConnectionHistory::format_time_ago(0), "");
    }

    #[test]
    fn test_format_time_ago_recent() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        assert_eq!(ConnectionHistory::format_time_ago(now), "<1m ago");
        assert_eq!(
            ConnectionHistory::format_time_ago(now - 300),
            "5m ago"
        );
        assert_eq!(
            ConnectionHistory::format_time_ago(now - 7200),
            "2h ago"
        );
        assert_eq!(
            ConnectionHistory::format_time_ago(now - 172800),
            "2d ago"
        );
    }
}