use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::lookup::LookupResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub domain: String,
pub timestamp: DateTime<Utc>,
pub result: LookupResult,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LookupHistory {
pub entries: BTreeMap<String, Vec<HistoryEntry>>,
}
const MAX_ENTRIES_PER_DOMAIN: usize = 50;
const MAX_DOMAINS: usize = 1000;
const MAX_ENTRY_AGE_DAYS: i64 = 365;
crate::fsutil::persisted_store!(LookupHistory, "history.json", json, "history");
impl LookupHistory {
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);
if entries.len() > MAX_ENTRIES_PER_DOMAIN {
let drain_count = entries.len() - MAX_ENTRIES_PER_DOMAIN;
entries.drain(..drain_count);
}
self.prune();
}
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()
});
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);
}
}
pub fn get(&self, domain: &str) -> Vec<&HistoryEntry> {
self.entries
.get(&history_key(domain))
.map(|entries| entries.iter().collect())
.unwrap_or_default()
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
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() {
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"
);
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() {
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() {
let mut h = LookupHistory::default();
h.record("old.example", make_lookup_result("x"));
h.entries.get_mut("old.example").unwrap()[0].timestamp =
Utc::now() - chrono::Duration::days(MAX_ENTRY_AGE_DAYS + 10);
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);
}
#[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"
);
}
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");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&backup);
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()
);
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() {
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");
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);
}
}
}