From 8623ef0ee74ff48a5ee24ee032f5b549f662f09d Mon Sep 17 00:00:00 2001 From: UMTS at Teleco Date: Sun, 8 Mar 2026 07:30:34 +0100 Subject: goofy ah --- src/config.rs | 436 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 src/config.rs (limited to 'src/config.rs') diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..650f616 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,436 @@ +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 { + 4 +} + +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 { + 6 +} + +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, +} + +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, + } +} + -- cgit v1.2.3-70-g09d2