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;
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()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| SeerError::ConfigError(e.to_string()))?;
std::fs::write(&path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
Ok(())
}
pub fn record(&mut self, domain: &str, result: LookupResult) {
let entry = HistoryEntry {
domain: domain.to_lowercase(),
timestamp: Utc::now(),
result,
};
let entries = self.entries.entry(domain.to_lowercase()).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);
}
}
pub fn get(&self, domain: &str) -> Vec<&HistoryEntry> {
self.entries
.get(&domain.to_lowercase())
.map(|entries| entries.iter().collect())
.unwrap_or_default()
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
#[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 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 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);
}
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);
}
}
}