seer-core 0.49.1

Core library for Seer domain name utilities
Documentation
//! Lookup history cache for storing past lookup results to disk.
//!
//! Results are persisted to `~/.seer/history.json` with a maximum of 50 entries
//! per domain to prevent unbounded growth.

use std::collections::BTreeMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::lookup::LookupResult;

/// A single cached lookup entry with timestamp.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
    pub domain: String,
    pub timestamp: DateTime<Utc>,
    pub result: LookupResult,
}

/// On-disk cache of past lookup results, keyed by domain.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LookupHistory {
    pub entries: BTreeMap<String, Vec<HistoryEntry>>,
}

/// Maximum number of history entries retained per domain.
const MAX_ENTRIES_PER_DOMAIN: usize = 50;

/// Maximum number of distinct domains retained. The per-domain cap above
/// bounds entries within a domain, but the number of distinct domain keys was
/// previously unbounded — long-lived/automated use could grow `history.json`
/// without limit. When exceeded, the domain whose most-recent entry is oldest
/// is evicted (LRU by most-recent activity). (issue #59)
const MAX_DOMAINS: usize = 1000;

/// Entries older than this are pruned. Bounds growth and the per-save rewrite
/// cost under long-lived use. (issue #59)
const MAX_ENTRY_AGE_DAYS: i64 = 365;

crate::fsutil::persisted_store!(LookupHistory, "history.json", json, "history");

impl LookupHistory {
    /// Records a lookup result for the given domain, trimming old entries if needed.
    pub fn record(&mut self, domain: &str, result: LookupResult) {
        let key = history_key(domain);
        let entry = HistoryEntry {
            domain: key.clone(),
            timestamp: Utc::now(),
            result,
        };
        let entries = self.entries.entry(key).or_default();
        entries.push(entry);
        // Keep at most MAX_ENTRIES_PER_DOMAIN entries
        if entries.len() > MAX_ENTRIES_PER_DOMAIN {
            let drain_count = entries.len() - MAX_ENTRIES_PER_DOMAIN;
            entries.drain(..drain_count);
        }
        self.prune();
    }

    /// Bounds total growth: drops entries older than [`MAX_ENTRY_AGE_DAYS`],
    /// removes any domain left with no entries, then evicts least-recently-active
    /// domains until at most [`MAX_DOMAINS`] remain (issue #59).
    fn prune(&mut self) {
        let cutoff = Utc::now() - chrono::Duration::days(MAX_ENTRY_AGE_DAYS);
        self.entries.retain(|_, v| {
            v.retain(|e| e.timestamp >= cutoff);
            !v.is_empty()
        });
        // Evict whole domains (least-recently-active first) until under the cap.
        // `record` adds one domain at a time, so this normally runs at most once.
        while self.entries.len() > MAX_DOMAINS {
            let Some(victim) = self
                .entries
                .iter()
                .min_by_key(|(_, v)| v.iter().map(|e| e.timestamp).max())
                .map(|(k, _)| k.clone())
            else {
                break;
            };
            self.entries.remove(&victim);
        }
    }

    /// Returns all history entries for a domain, newest last.
    pub fn get(&self, domain: &str) -> Vec<&HistoryEntry> {
        self.entries
            .get(&history_key(domain))
            .map(|entries| entries.iter().collect())
            .unwrap_or_default()
    }

    /// Clears all history entries.
    pub fn clear(&mut self) {
        self.entries.clear();
    }
}

/// Canonical history key for a domain: the crate's `normalize_domain`
/// (strips scheme/`www.`/port, lowercases, IDN→punycode), so
/// `www.example.com` and `https://EXAMPLE.COM` coalesce with `example.com`
/// on both the write (`record`) and read (`get`) paths. Callers pass raw
/// user input, so keying by plain `to_lowercase()` made
/// `lookup www.example.com` + `drift example.com` miss each other (false
/// "no baseline"). Inputs that fail validation fall back to lowercasing so
/// record/get stay infallible and consistent with each other. Legacy files
/// keyed under un-normalized names are not migrated: reads normalize the
/// lookup key, so legacy entries are simply superseded by new writes.
fn history_key(domain: &str) -> String {
    crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::availability::AvailabilityResult;
    use std::path::PathBuf;

    fn make_lookup_result(domain: &str) -> LookupResult {
        LookupResult::Available {
            data: Box::new(AvailabilityResult::new(domain, true, "high", "test")),
            rdap_error: "test".to_string(),
            whois_error: "test".to_string(),
            whois_data: None,
        }
    }

    #[test]
    fn test_history_default() {
        let history = LookupHistory::default();
        assert!(history.entries.is_empty());
    }

    #[test]
    fn test_history_record_and_get() {
        let mut history = LookupHistory::default();
        history.record("example.com", make_lookup_result("example.com"));
        history.record("example.com", make_lookup_result("example.com"));
        history.record("test.org", make_lookup_result("test.org"));

        let entries = history.get("example.com");
        assert_eq!(entries.len(), 2);

        let entries = history.get("test.org");
        assert_eq!(entries.len(), 1);

        let entries = history.get("nonexistent.com");
        assert!(entries.is_empty());
    }

    #[test]
    fn test_history_case_insensitive() {
        let mut history = LookupHistory::default();
        history.record("EXAMPLE.COM", make_lookup_result("EXAMPLE.COM"));

        let entries = history.get("example.com");
        assert_eq!(entries.len(), 1);
    }

    #[test]
    fn history_key_coalesces_www_scheme_and_case_variants() {
        // `seer lookup www.example.com` followed by `seer drift example.com`
        // must find the baseline: all spellings share one normalized key.
        let mut history = LookupHistory::default();
        history.record("www.example.com", make_lookup_result("www.example.com"));
        history.record(
            "https://EXAMPLE.COM/path",
            make_lookup_result("example.com"),
        );

        assert_eq!(
            history.get("example.com").len(),
            2,
            "www./scheme/case variants must land under the same history key"
        );
        assert_eq!(history.entries.len(), 1, "exactly one key stored");
        assert!(
            history.entries.contains_key("example.com"),
            "the stored key is the normalized form"
        );
        // Reads normalize too, so any variant retrieves the shared entries.
        assert_eq!(history.get("WWW.example.com").len(), 2);
    }

    #[test]
    fn test_history_max_entries() {
        let mut history = LookupHistory::default();
        for _ in 0..60 {
            history.record("example.com", make_lookup_result("example.com"));
        }
        let entries = history.get("example.com");
        assert_eq!(entries.len(), MAX_ENTRIES_PER_DOMAIN);
    }

    #[test]
    fn history_global_domain_cap_bounds_distinct_domains() {
        // History caps entries PER domain, but the number of distinct domains
        // was unbounded. A global cap must bound total domains (issue #59).
        let mut h = LookupHistory::default();
        for i in 0..(MAX_DOMAINS + 50) {
            h.record(&format!("d{i}.example"), make_lookup_result("x"));
        }
        assert!(
            h.entries.len() <= MAX_DOMAINS,
            "distinct domains must be capped at {MAX_DOMAINS}, got {}",
            h.entries.len()
        );
    }

    #[test]
    fn history_prunes_entries_older_than_max_age() {
        // Entries older than the max age are trimmed so the file can't grow
        // without bound under long-lived/automated use (issue #59).
        let mut h = LookupHistory::default();
        h.record("old.example", make_lookup_result("x"));
        // Backdate the entry beyond the retention window.
        h.entries.get_mut("old.example").unwrap()[0].timestamp =
            Utc::now() - chrono::Duration::days(MAX_ENTRY_AGE_DAYS + 10);
        // Any subsequent record triggers a prune.
        h.record("new.example", make_lookup_result("x"));
        assert!(
            h.get("old.example").is_empty(),
            "entry older than the retention window must be pruned"
        );
        assert!(!h.get("new.example").is_empty(), "fresh entry retained");
    }

    #[test]
    fn test_history_clear() {
        let mut history = LookupHistory::default();
        history.record("a.com", make_lookup_result("a.com"));
        history.record("b.com", make_lookup_result("b.com"));
        assert_eq!(history.entries.len(), 2);

        history.clear();
        assert!(history.entries.is_empty());
    }

    #[test]
    fn test_history_serialization_roundtrip() {
        let mut history = LookupHistory::default();
        history.record("example.com", make_lookup_result("example.com"));

        let json = serde_json::to_string(&history).unwrap();
        let parsed: LookupHistory = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.entries.len(), 1);
        assert_eq!(parsed.get("example.com").len(), 1);
    }

    /// Regression: history must round-trip a WHOIS-bearing result.
    ///
    /// `test_history_serialization_roundtrip` above only exercises the
    /// `Available` variant (`whois_data: None`), so it missed that
    /// `WhoisResponse.raw_response` (`skip_serializing`) failed to deserialize
    /// without `default`: every saved history containing a WHOIS lookup was
    /// rejected as "missing field `raw_response`" on the next load, moved to
    /// `.corrupt`, and silently dropped — so `seer history` and the TUI History
    /// lens were always empty after a real lookup.
    #[test]
    fn history_roundtrips_a_whois_bearing_result() {
        let whois = crate::whois::WhoisResponse::parse(
            "example.com",
            "whois.verisign-grs.com",
            "Domain Name: example.com\nRegistrar: Example Registrar\n",
        );
        let result = LookupResult::Whois {
            data: whois,
            rdap_error: None,
            rdap_fallback: None,
        };
        let mut history = LookupHistory::default();
        history.record("example.com", result);

        let json = serde_json::to_string(&history).expect("serialize");
        let parsed: LookupHistory = serde_json::from_str(&json)
            .expect("WHOIS-bearing history must deserialize (raw_response needs default)");
        assert_eq!(
            parsed.get("example.com").len(),
            1,
            "the WHOIS entry survives"
        );
    }

    /// Creates a unique temporary file path for a load-from-disk test.
    /// Returned path does not exist and the parent directory is created.
    /// The caller is responsible for cleaning up on drop (via the helper).
    fn unique_temp_history_path(tag: &str) -> PathBuf {
        let mut dir = std::env::temp_dir();
        dir.push(format!("seer-history-test-{}-{}", tag, std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        dir.push("history.json");
        dir
    }

    #[test]
    fn load_from_path_returns_default_and_backs_up_corrupt_file() {
        let path = unique_temp_history_path("corrupt");
        let backup = path.with_extension("corrupt");

        // Clean any stragglers from a previous run.
        let _ = std::fs::remove_file(&path);
        let _ = std::fs::remove_file(&backup);

        // Write an intentionally corrupt JSON file.
        std::fs::write(&path, b"{ this is not valid json ").expect("seed file");

        let loaded = LookupHistory::load_from_path(&path);
        assert!(
            loaded.entries.is_empty(),
            "corrupt file load must return default"
        );
        assert!(
            !path.exists(),
            "original file should have been renamed away"
        );
        assert!(
            backup.exists(),
            "backup .corrupt file should exist at {}",
            backup.display()
        );

        // Clean up.
        let _ = std::fs::remove_file(&backup);
        if let Some(parent) = path.parent() {
            let _ = std::fs::remove_dir_all(parent);
        }
    }

    #[test]
    fn load_from_path_returns_default_when_missing() {
        let path = unique_temp_history_path("missing");
        let _ = std::fs::remove_file(&path);

        let loaded = LookupHistory::load_from_path(&path);
        assert!(loaded.entries.is_empty());

        if let Some(parent) = path.parent() {
            let _ = std::fs::remove_dir_all(parent);
        }
    }

    #[test]
    fn concurrent_saves_do_not_corrupt_the_file() {
        // Two same-process writers saving to the same target concurrently:
        // with a shared (PID-only) temp path one writer truncates the other's
        // finished bytes and the loser's rename fails (or publishes a torn
        // file). With per-call temp paths every save succeeds and the last
        // rename wins with a complete file.
        let path = unique_temp_history_path("concurrent");
        let _ = std::fs::remove_file(&path);

        let mut a = LookupHistory::default();
        a.record("a.example", make_lookup_result("a.example"));
        let mut b = LookupHistory::default();
        for i in 0..100 {
            b.record(&format!("b{i}.example"), make_lookup_result("x"));
        }

        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
        let spawn_saver =
            |history: LookupHistory, path: PathBuf, barrier: std::sync::Arc<std::sync::Barrier>| {
                std::thread::spawn(move || {
                    for _ in 0..50 {
                        barrier.wait();
                        history.save_to_path(&path).expect("concurrent save failed");
                    }
                })
            };
        let ta = spawn_saver(a, path.clone(), barrier.clone());
        let tb = spawn_saver(b, path.clone(), barrier);
        ta.join().expect("thread A panicked");
        tb.join().expect("thread B panicked");

        // Whichever writer won the last rename, the file must be complete.
        let content = std::fs::read_to_string(&path).expect("saved file exists");
        serde_json::from_str::<LookupHistory>(&content)
            .expect("concurrently saved history must parse (no torn rename)");

        if let Some(parent) = path.parent() {
            let _ = std::fs::remove_dir_all(parent);
        }
    }
}