use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct HoardomPaths { pub config_file: PathBuf, pub cache_dir: PathBuf, pub can_save: bool, pub caching_enabled: bool, } impl HoardomPaths { pub fn cache_file(&self, name: &str) -> PathBuf { self.cache_dir.join(name) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub settings: Settings, #[serde(default)] pub cache: CacheSettings, #[serde(default)] pub favorites: Vec, #[serde(default)] pub imported_filters: Vec, #[serde(default)] pub scratchpad: String, } /// faved domain with its last known status #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FavoriteEntry { pub domain: String, /// last known status: "available", "registered", "error", or "unknown" #[serde(default = "default_fav_status")] pub status: String, /// when it was last checked (RFC 3339) #[serde(default)] pub checked: String, /// true when status changed since last check (shows ! in TUI) #[serde(default)] pub changed: bool, } impl FavoriteEntry { pub fn new(domain: String) -> Self { Self { domain, status: "unknown".to_string(), checked: String::new(), changed: false, } } } fn default_fav_status() -> String { "unknown".to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { #[serde(default = "default_tld_list")] pub tld_list: String, #[serde(default)] pub show_all: bool, #[serde(default = "default_clear_on_search")] pub clear_on_search: bool, #[serde(default)] pub show_notes_panel: bool, #[serde(default)] pub last_fav_export_path: String, #[serde(default)] pub last_res_export_path: String, #[serde(default)] pub top_tlds: Vec, #[serde(default = "default_jobs")] pub jobs: u8, /// error types that shouldnt be retried /// valid: "rate_limit", "invalid_tld", "timeout", "unknown" #[serde(default = "default_noretry")] pub noretry: Vec, /// auto config backups on/off #[serde(default = "default_backups_enabled")] pub backups: bool, /// how many backup copies to keep #[serde(default = "default_backup_count")] pub backup_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CacheSettings { #[serde(default)] pub last_updated: String, /// 0 = never nag about stale cache #[serde(default = "default_outdated_cache_days")] pub outdated_cache: u32, /// auto refresh when outdated if true #[serde(default = "default_auto_update")] pub auto_update_cache: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportedFilter { pub name: String, pub tlds: Vec, } fn default_tld_list() -> String { crate::tlds::default_list_name().to_string() } fn default_outdated_cache_days() -> u32 { 7 } fn default_auto_update() -> bool { true } fn default_clear_on_search() -> bool { true } fn default_jobs() -> u8 { 32 } fn default_noretry() -> Vec { vec!["rate_limit".to_string(), "invalid_tld".to_string(), "forbidden".to_string()] } fn default_backups_enabled() -> bool { true } fn default_backup_count() -> u32 { 32 } impl Default for Settings { fn default() -> Self { Self { tld_list: default_tld_list(), show_all: false, clear_on_search: default_clear_on_search(), show_notes_panel: false, last_fav_export_path: String::new(), last_res_export_path: String::new(), top_tlds: Vec::new(), jobs: default_jobs(), noretry: default_noretry(), backups: default_backups_enabled(), backup_count: default_backup_count(), } } } impl Default for CacheSettings { fn default() -> Self { Self { last_updated: String::new(), outdated_cache: default_outdated_cache_days(), auto_update_cache: default_auto_update(), } } } impl Default for Config { fn default() -> Self { Self { settings: Settings::default(), cache: CacheSettings::default(), favorites: Vec::new(), imported_filters: Vec::new(), scratchpad: String::new(), } } } /// old config format where favorites were just strings #[derive(Debug, Deserialize)] struct LegacyConfig { #[serde(default)] settings: Settings, #[serde(default)] cache: CacheSettings, #[serde(default)] favorites: Vec, #[serde(default)] imported_filters: Vec, #[serde(default)] scratchpad: String, } // this implementation is partially containing ai slop i should remove no need for that idk why this was made to have legacy support by it but eh idc impl Config { pub fn load(path: &Path) -> Self { match std::fs::read_to_string(path) { Ok(content) => { // Try new format first if let Ok(config) = toml::from_str::(&content) { return config; } // Fall back to legacy format (favorites as plain strings) if let Ok(legacy) = toml::from_str::(&content) { return Config { settings: legacy.settings, cache: legacy.cache, favorites: legacy.favorites.into_iter().map(FavoriteEntry::new).collect(), imported_filters: legacy.imported_filters, scratchpad: legacy.scratchpad, }; } eprintln!("Warning: could not parse config file"); Config::default() } Err(_) => Config::default(), } } /// load config and backup it on startup if backups are on pub fn load_with_backup(path: &Path) -> Self { let config = Self::load(path); if config.settings.backups && path.exists() { if let Err(e) = Self::create_backup(path, config.settings.backup_count) { eprintln!("Warning: could not create config backup: {}", e); } } config } pub fn save(&self, path: &Path) -> Result<(), String> { // make sure parent dir exists if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create config directory: {}", e))?; } let body = toml::to_string_pretty(self) .map_err(|e| format!("Failed to serialize config: {}", e))?; let content = format!("\ # hoardom config - auto saved, comments are preserved on the line theyre on # # [settings] # noretry: error types that shouldnt be retried # \u{201c}rate_limit\u{201d} - server said slow down, retrying immediately wont help # \u{201c}invalid_tld\u{201d} - TLD is genuinely broken, no point retrying # \u{201c}forbidden\u{201d} - server returned 403, access denied, retrying wont fix it # \u{201c}timeout\u{201d} - uncomment if youd rather skip slow TLDs than wait # \u{201c}unknown\u{201d} - uncomment to skip any unrecognized errors too \n{}", body); std::fs::write(path, content) .map_err(|e| format!("Failed to write config file: {}", e))?; Ok(()) } /// copy current config into backups/ folder. /// keeps at most `max_count` backups, tosses the oldest. /// only call on startup and shutdown - NOT on every save. pub fn create_backup(config_path: &Path, max_count: u32) -> Result<(), String> { let parent = config_path.parent().ok_or("No parent directory")?; let backup_dir = parent.join("backups"); std::fs::create_dir_all(&backup_dir) .map_err(|e| format!("Failed to create backup dir: {}", e))?; // Timestamp-based filename: config_20260308_143022.toml let ts = chrono::Local::now().format("%Y%m%d_%H%M%S"); let backup_name = format!("config_{}.toml", ts); let backup_path = backup_dir.join(&backup_name); // dont backup if same-second backup already exists if backup_path.exists() { return Ok(()); } std::fs::copy(config_path, &backup_path) .map_err(|e| format!("Failed to copy config to backup: {}", e))?; // prune old backups: sort by name (timestamp order), keep newest N if max_count > 0 { let mut backups: Vec<_> = std::fs::read_dir(&backup_dir) .map_err(|e| format!("Failed to read backup dir: {}", e))? .filter_map(|e| e.ok()) .filter(|e| { e.file_name() .to_str() .map(|n| n.starts_with("config_") && n.ends_with(".toml")) .unwrap_or(false) }) .collect(); backups.sort_by_key(|e| e.file_name()); let excess = backups.len().saturating_sub(max_count as usize); for entry in backups.into_iter().take(excess) { let _ = std::fs::remove_file(entry.path()); } } Ok(()) } /// replaces filter with same name if theres one already pub fn import_filter(&mut self, filter: ImportedFilter) { self.imported_filters.retain(|f| f.name != filter.name); self.imported_filters.push(filter); } pub fn mark_cache_updated(&mut self) { self.cache.last_updated = chrono::Utc::now().to_rfc3339(); } /// -> (is_outdated, should_auto_update) pub fn check_cache_status(&self) -> (bool, bool) { if self.cache.last_updated.is_empty() { // never updated = always outdated, always auto update return (true, true); } let last = match chrono::DateTime::parse_from_rfc3339(&self.cache.last_updated) { Ok(dt) => dt.with_timezone(&chrono::Utc), Err(_) => return (true, true), // cant parse = treat as outdated }; let now = chrono::Utc::now(); let age_days = (now - last).num_days() as u32; if self.cache.outdated_cache == 0 { // warnings disabled, but if auto_update is on, update every run return (false, self.cache.auto_update_cache); } let is_outdated = age_days >= self.cache.outdated_cache; let should_auto = is_outdated && self.cache.auto_update_cache; (is_outdated, should_auto) } } pub fn parse_filter_file(path: &PathBuf) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| format!("Could not read filter file: {}", e))?; let filter: ImportedFilter = toml::from_str(&content) .map_err(|e| format!("Could not parse filter file: {}", e))?; if filter.name.is_empty() { return Err("Filter file must have a name defined".to_string()); } if filter.tlds.is_empty() { return Err("Filter file has no TLDs defined".to_string()); } Ok(filter) } /// resolve .hoardom dir, tries a few locations: /// /// priority: /// 1. explicit path via -e flag -> use as root dir (create .hoardom folder there) /// 2. debug builds: current directory /// 3. release builds: home directory /// 4. fallback: try the other option /// 5. nothing works: caching disabled, in-memory only pub fn resolve_paths(explicit: Option<&PathBuf>) -> HoardomPaths { let try_setup = |base: PathBuf| -> Option { let root = base; let config_file = root.join("config.toml"); let cache_dir = root.join("cache"); // try to create the directories if std::fs::create_dir_all(&cache_dir).is_ok() { Some(HoardomPaths { config_file, cache_dir, can_save: true, caching_enabled: true, }) } else { None } }; // explicit path given via -e flag if let Some(p) = explicit { // if user gave a path, use it as the .hoardom folder root let root = if p.extension().is_some() { // looks like they pointed at a file, use parent dir p.parent().unwrap_or(p).join(".hoardom") } else { p.clone() }; if let Some(paths) = try_setup(root) { return paths; } } // debug builds: current directory first #[cfg(debug_assertions)] { if let Ok(dir) = std::env::current_dir() { if let Some(paths) = try_setup(dir.join(".hoardom")) { return paths; } } // debug fallback: try home if let Some(home) = dirs::home_dir() { if let Some(paths) = try_setup(home.join(".hoardom")) { return paths; } } } // release builds: home directory first #[cfg(not(debug_assertions))] { if let Some(home) = dirs::home_dir() { if let Some(paths) = try_setup(home.join(".hoardom")) { return paths; } } // release fallback: try cwd if let Ok(dir) = std::env::current_dir() { if let Some(paths) = try_setup(dir.join(".hoardom")) { return paths; } } } // nothing works disable caching, use a dummy path eprintln!("Warning: could not create .hoardom directory anywhere, caching disabled"); HoardomPaths { config_file: PathBuf::from(".hoardom/config.toml"), cache_dir: PathBuf::from(".hoardom/cache"), can_save: false, caching_enabled: false, } }