aboutsummaryrefslogtreecommitdiff
path: root/src/config.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs436
1 files changed, 436 insertions, 0 deletions
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<FavoriteEntry>,
+ #[serde(default)]
+ pub imported_filters: Vec<ImportedFilter>,
+ #[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<String>,
+ #[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<String>,
+ /// 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<String>,
+}
+
+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<String> {
+ 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<String>,
+ #[serde(default)]
+ imported_filters: Vec<ImportedFilter>,
+ #[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::<Config>(&content) {
+ return config;
+ }
+ // Fall back to legacy format (favorites as plain strings)
+ if let Ok(legacy) = toml::from_str::<LegacyConfig>(&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<ImportedFilter, String> {
+ 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<HoardomPaths> {
+ 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,
+ }
+}
+