| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- 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,
- }
- }
|