use std::collections::BTreeMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SeerError};
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;
impl LookupHistory {
pub fn path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".seer").join("history.json"))
}
pub fn load() -> Self {
let Some(path) = Self::path() else {
return Self::default();
};
Self::load_from_path(&path)
}
pub(crate) fn load_from_path(path: &std::path::Path) -> Self {
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<LookupHistory>(&content) {
Ok(h) => h,
Err(e) => {
let backup = path.with_extension("corrupt");
if let Err(rename_err) = std::fs::rename(path, &backup) {
tracing::error!(
path = %path.display(),
error = %rename_err,
"failed to back up corrupt history",
);
} else {
tracing::warn!(
path = %path.display(),
backup = %backup.display(),
error = %e,
"history file corrupt; moved to backup",
);
}
LookupHistory::default()
}
},
Err(_) => Self::default(),
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path()
.ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
self.save_to_path(&path)
}
pub(crate) fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| SeerError::ConfigError(e.to_string()))?;
let tmp_path = unique_tmp_path(path, "json");
std::fs::write(&tmp_path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
SeerError::ConfigError(e.to_string())
})?;
Ok(())
}
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())
}
fn unique_tmp_path(path: &std::path::Path, ext: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SAVE_COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = SAVE_COUNTER.fetch_add(1, Ordering::Relaxed);
path.with_extension(format!("{}.{}.{}.tmp", ext, std::process::id(), seq))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::availability::AvailabilityResult;
fn make_lookup_result(domain: &str) -> LookupResult {
LookupResult::Available {
data: Box::new(AvailabilityResult {
domain: domain.to_string(),
available: true,
confidence: "high".to_string(),
method: "test".to_string(),
details: None,
}),
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 tmp_paths_are_unique_per_call() {
let target = std::path::Path::new("/some/dir/history.json");
assert_ne!(
unique_tmp_path(target, "json"),
unique_tmp_path(target, "json"),
"two saves in one process must not share a temp path"
);
}
#[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);
}
}
}