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 = 20;
const MAX_DOMAINS: usize = 500;
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, mut result: LookupResult) {
slim(&mut result);
let key = history_key(domain);
let entry = HistoryEntry {
domain: key.clone(),
timestamp: Utc::now(),
result,
};
self.entries.entry(key).or_default().push(entry);
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);
let excess = v.len().saturating_sub(MAX_ENTRIES_PER_DOMAIN);
v.drain(..excess);
v.iter_mut().for_each(|e| slim(&mut e.result));
!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 slim(result: &mut LookupResult) {
let LookupResult::Rdap { data, .. } = result else {
return;
};
data.notices.clear();
data.remarks.clear();
data.extra.clear();
let mut pending = vec![&mut data.entities];
while let Some(entities) = pending.pop() {
for entity in entities.iter_mut() {
entity.remarks.clear();
pending.push(&mut entity.entities);
}
}
}
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,
dns_nameservers: 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 rdap_result() -> LookupResult {
let rdap: crate::rdap::RdapResponse = serde_json::from_value(serde_json::json!({
"objectClassName": "domain",
"ldhName": "example.com",
"status": ["client transfer prohibited"],
"events": [{"eventAction": "expiration", "eventDate": "2030-01-01T00:00:00Z"}],
"entities": [{
"roles": ["registrar"],
"vcardArray": ["vcard", [["fn", {}, "text", "Example Registrar"]]],
"remarks": [{"description": ["entity remark"]}],
"entities": [{"roles": ["abuse"], "remarks": [{"description": ["nested"]}]}]
}],
"nameservers": [{"ldhName": "ns1.example.com"}],
"links": [{"rel": "self", "href": "https://rdap.example/domain/example.com"}],
"notices": [{"title": "Terms of Use", "description": ["a long legal text"]}],
"remarks": [{"description": ["object remark"]}],
"vendorExtension_blob": "x".repeat(4096),
}))
.expect("fixture RDAP response");
LookupResult::Rdap {
data: Box::new(rdap),
whois_fallback: None,
}
}
#[test]
fn recorded_results_are_slimmed_but_keep_what_readers_use() {
let full = rdap_result();
let mut history = LookupHistory::default();
history.record("example.com", full.clone());
let stored = &history.get("example.com")[0].result;
let LookupResult::Rdap { data, .. } = stored else {
panic!("RDAP result expected");
};
assert!(data.notices.is_empty() && data.remarks.is_empty() && data.extra.is_empty());
assert!(data.entities[0].remarks.is_empty());
assert!(data.entities[0].entities[0].remarks.is_empty());
assert!(
serde_json::to_string(stored).unwrap().len()
< serde_json::to_string(&full).unwrap().len() / 2
);
assert_eq!(stored.registrar(), full.registrar());
assert_eq!(stored.expiration_date(), full.expiration_date());
assert!(!crate::drift::DriftReport::from_lookups("example.com", &full, stored).has_drift());
let info = crate::DomainInfo::from_lookup_result(stored);
assert_eq!(
info.rdap_url.as_deref(),
Some("https://rdap.example/domain/example.com")
);
}
#[test]
fn a_file_written_under_the_old_caps_is_trimmed_on_the_next_record() {
let mut old = LookupHistory::default();
let entry = |result| HistoryEntry {
domain: "example.com".to_string(),
timestamp: Utc::now(),
result,
};
old.entries.insert(
"example.com".to_string(),
(0..50).map(|_| entry(rdap_result())).collect(),
);
let json = serde_json::to_string(&old).unwrap();
let mut loaded: LookupHistory = serde_json::from_str(&json).expect("old format loads");
loaded.record("other.test", make_lookup_result("other.test"));
let kept = loaded.get("example.com");
assert_eq!(kept.len(), MAX_ENTRIES_PER_DOMAIN);
assert!(kept.iter().all(|e| matches!(
&e.result,
LookupResult::Rdap { data, .. } if data.notices.is_empty()
)));
}
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).expect("load");
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).expect("load");
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);
}
}
}