config.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. use serde::{Deserialize, Serialize};
  2. use std::path::{Path, PathBuf};
  3. #[derive(Debug, Clone)]
  4. pub struct HoardomPaths {
  5. pub config_file: PathBuf,
  6. pub cache_dir: PathBuf,
  7. pub can_save: bool,
  8. pub caching_enabled: bool,
  9. }
  10. impl HoardomPaths {
  11. pub fn cache_file(&self, name: &str) -> PathBuf {
  12. self.cache_dir.join(name)
  13. }
  14. }
  15. #[derive(Debug, Clone, Serialize, Deserialize)]
  16. pub struct Config {
  17. #[serde(default)]
  18. pub settings: Settings,
  19. #[serde(default)]
  20. pub cache: CacheSettings,
  21. #[serde(default)]
  22. pub favorites: Vec<FavoriteEntry>,
  23. #[serde(default)]
  24. pub imported_filters: Vec<ImportedFilter>,
  25. #[serde(default)]
  26. pub scratchpad: String,
  27. }
  28. /// faved domain with its last known status
  29. #[derive(Debug, Clone, Serialize, Deserialize)]
  30. pub struct FavoriteEntry {
  31. pub domain: String,
  32. /// last known status: "available", "registered", "error", or "unknown"
  33. #[serde(default = "default_fav_status")]
  34. pub status: String,
  35. /// when it was last checked (RFC 3339)
  36. #[serde(default)]
  37. pub checked: String,
  38. /// true when status changed since last check (shows ! in TUI)
  39. #[serde(default)]
  40. pub changed: bool,
  41. }
  42. impl FavoriteEntry {
  43. pub fn new(domain: String) -> Self {
  44. Self {
  45. domain,
  46. status: "unknown".to_string(),
  47. checked: String::new(),
  48. changed: false,
  49. }
  50. }
  51. }
  52. fn default_fav_status() -> String {
  53. "unknown".to_string()
  54. }
  55. #[derive(Debug, Clone, Serialize, Deserialize)]
  56. pub struct Settings {
  57. #[serde(default = "default_tld_list")]
  58. pub tld_list: String,
  59. #[serde(default)]
  60. pub show_all: bool,
  61. #[serde(default = "default_clear_on_search")]
  62. pub clear_on_search: bool,
  63. #[serde(default)]
  64. pub show_notes_panel: bool,
  65. #[serde(default)]
  66. pub last_fav_export_path: String,
  67. #[serde(default)]
  68. pub last_res_export_path: String,
  69. #[serde(default)]
  70. pub top_tlds: Vec<String>,
  71. #[serde(default = "default_jobs")]
  72. pub jobs: u8,
  73. /// error types that shouldnt be retried
  74. /// valid: "rate_limit", "invalid_tld", "timeout", "unknown"
  75. #[serde(default = "default_noretry")]
  76. pub noretry: Vec<String>,
  77. /// auto config backups on/off
  78. #[serde(default = "default_backups_enabled")]
  79. pub backups: bool,
  80. /// how many backup copies to keep
  81. #[serde(default = "default_backup_count")]
  82. pub backup_count: u32,
  83. }
  84. #[derive(Debug, Clone, Serialize, Deserialize)]
  85. pub struct CacheSettings {
  86. #[serde(default)]
  87. pub last_updated: String,
  88. /// 0 = never nag about stale cache
  89. #[serde(default = "default_outdated_cache_days")]
  90. pub outdated_cache: u32,
  91. /// auto refresh when outdated if true
  92. #[serde(default = "default_auto_update")]
  93. pub auto_update_cache: bool,
  94. }
  95. #[derive(Debug, Clone, Serialize, Deserialize)]
  96. pub struct ImportedFilter {
  97. pub name: String,
  98. pub tlds: Vec<String>,
  99. }
  100. fn default_tld_list() -> String {
  101. crate::tlds::default_list_name().to_string()
  102. }
  103. fn default_outdated_cache_days() -> u32 {
  104. 7
  105. }
  106. fn default_auto_update() -> bool {
  107. true
  108. }
  109. fn default_clear_on_search() -> bool {
  110. true
  111. }
  112. fn default_jobs() -> u8 {
  113. 4
  114. }
  115. fn default_noretry() -> Vec<String> {
  116. vec!["rate_limit".to_string(), "invalid_tld".to_string(), "forbidden".to_string()]
  117. }
  118. fn default_backups_enabled() -> bool {
  119. true
  120. }
  121. fn default_backup_count() -> u32 {
  122. 6
  123. }
  124. impl Default for Settings {
  125. fn default() -> Self {
  126. Self {
  127. tld_list: default_tld_list(),
  128. show_all: false,
  129. clear_on_search: default_clear_on_search(),
  130. show_notes_panel: false,
  131. last_fav_export_path: String::new(),
  132. last_res_export_path: String::new(),
  133. top_tlds: Vec::new(),
  134. jobs: default_jobs(),
  135. noretry: default_noretry(),
  136. backups: default_backups_enabled(),
  137. backup_count: default_backup_count(),
  138. }
  139. }
  140. }
  141. impl Default for CacheSettings {
  142. fn default() -> Self {
  143. Self {
  144. last_updated: String::new(),
  145. outdated_cache: default_outdated_cache_days(),
  146. auto_update_cache: default_auto_update(),
  147. }
  148. }
  149. }
  150. impl Default for Config {
  151. fn default() -> Self {
  152. Self {
  153. settings: Settings::default(),
  154. cache: CacheSettings::default(),
  155. favorites: Vec::new(),
  156. imported_filters: Vec::new(),
  157. scratchpad: String::new(),
  158. }
  159. }
  160. }
  161. /// old config format where favorites were just strings
  162. #[derive(Debug, Deserialize)]
  163. struct LegacyConfig {
  164. #[serde(default)]
  165. settings: Settings,
  166. #[serde(default)]
  167. cache: CacheSettings,
  168. #[serde(default)]
  169. favorites: Vec<String>,
  170. #[serde(default)]
  171. imported_filters: Vec<ImportedFilter>,
  172. #[serde(default)]
  173. scratchpad: String,
  174. }
  175. impl Config {
  176. pub fn load(path: &Path) -> Self {
  177. match std::fs::read_to_string(path) {
  178. Ok(content) => {
  179. // Try new format first
  180. if let Ok(config) = toml::from_str::<Config>(&content) {
  181. return config;
  182. }
  183. // Fall back to legacy format (favorites as plain strings)
  184. if let Ok(legacy) = toml::from_str::<LegacyConfig>(&content) {
  185. return Config {
  186. settings: legacy.settings,
  187. cache: legacy.cache,
  188. favorites: legacy.favorites.into_iter().map(FavoriteEntry::new).collect(),
  189. imported_filters: legacy.imported_filters,
  190. scratchpad: legacy.scratchpad,
  191. };
  192. }
  193. eprintln!("Warning: could not parse config file");
  194. Config::default()
  195. }
  196. Err(_) => Config::default(),
  197. }
  198. }
  199. /// load config and backup it on startup if backups are on
  200. pub fn load_with_backup(path: &Path) -> Self {
  201. let config = Self::load(path);
  202. if config.settings.backups && path.exists() {
  203. if let Err(e) = Self::create_backup(path, config.settings.backup_count) {
  204. eprintln!("Warning: could not create config backup: {}", e);
  205. }
  206. }
  207. config
  208. }
  209. pub fn save(&self, path: &Path) -> Result<(), String> {
  210. // make sure parent dir exists
  211. if let Some(parent) = path.parent() {
  212. std::fs::create_dir_all(parent)
  213. .map_err(|e| format!("Failed to create config directory: {}", e))?;
  214. }
  215. let body = toml::to_string_pretty(self)
  216. .map_err(|e| format!("Failed to serialize config: {}", e))?;
  217. let content = format!("\
  218. # hoardom config - auto saved, comments are preserved on the line theyre on
  219. #
  220. # [settings]
  221. # noretry: error types that shouldnt be retried
  222. # \u{201c}rate_limit\u{201d} - server said slow down, retrying immediately wont help
  223. # \u{201c}invalid_tld\u{201d} - TLD is genuinely broken, no point retrying
  224. # \u{201c}forbidden\u{201d} - server returned 403, access denied, retrying wont fix it
  225. # \u{201c}timeout\u{201d} - uncomment if youd rather skip slow TLDs than wait
  226. # \u{201c}unknown\u{201d} - uncomment to skip any unrecognized errors too
  227. \n{}", body);
  228. std::fs::write(path, content)
  229. .map_err(|e| format!("Failed to write config file: {}", e))?;
  230. Ok(())
  231. }
  232. /// copy current config into backups/ folder.
  233. /// keeps at most `max_count` backups, tosses the oldest.
  234. /// only call on startup and shutdown - NOT on every save.
  235. pub fn create_backup(config_path: &Path, max_count: u32) -> Result<(), String> {
  236. let parent = config_path.parent().ok_or("No parent directory")?;
  237. let backup_dir = parent.join("backups");
  238. std::fs::create_dir_all(&backup_dir)
  239. .map_err(|e| format!("Failed to create backup dir: {}", e))?;
  240. // Timestamp-based filename: config_20260308_143022.toml
  241. let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
  242. let backup_name = format!("config_{}.toml", ts);
  243. let backup_path = backup_dir.join(&backup_name);
  244. // dont backup if same-second backup already exists
  245. if backup_path.exists() {
  246. return Ok(());
  247. }
  248. std::fs::copy(config_path, &backup_path)
  249. .map_err(|e| format!("Failed to copy config to backup: {}", e))?;
  250. // prune old backups: sort by name (timestamp order), keep newest N
  251. if max_count > 0 {
  252. let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)
  253. .map_err(|e| format!("Failed to read backup dir: {}", e))?
  254. .filter_map(|e| e.ok())
  255. .filter(|e| {
  256. e.file_name()
  257. .to_str()
  258. .map(|n| n.starts_with("config_") && n.ends_with(".toml"))
  259. .unwrap_or(false)
  260. })
  261. .collect();
  262. backups.sort_by_key(|e| e.file_name());
  263. let excess = backups.len().saturating_sub(max_count as usize);
  264. for entry in backups.into_iter().take(excess) {
  265. let _ = std::fs::remove_file(entry.path());
  266. }
  267. }
  268. Ok(())
  269. }
  270. /// replaces filter with same name if theres one already
  271. pub fn import_filter(&mut self, filter: ImportedFilter) {
  272. self.imported_filters.retain(|f| f.name != filter.name);
  273. self.imported_filters.push(filter);
  274. }
  275. pub fn mark_cache_updated(&mut self) {
  276. self.cache.last_updated = chrono::Utc::now().to_rfc3339();
  277. }
  278. /// -> (is_outdated, should_auto_update)
  279. pub fn check_cache_status(&self) -> (bool, bool) {
  280. if self.cache.last_updated.is_empty() {
  281. // never updated = always outdated, always auto update
  282. return (true, true);
  283. }
  284. let last = match chrono::DateTime::parse_from_rfc3339(&self.cache.last_updated) {
  285. Ok(dt) => dt.with_timezone(&chrono::Utc),
  286. Err(_) => return (true, true), // cant parse = treat as outdated
  287. };
  288. let now = chrono::Utc::now();
  289. let age_days = (now - last).num_days() as u32;
  290. if self.cache.outdated_cache == 0 {
  291. // warnings disabled, but if auto_update is on, update every run
  292. return (false, self.cache.auto_update_cache);
  293. }
  294. let is_outdated = age_days >= self.cache.outdated_cache;
  295. let should_auto = is_outdated && self.cache.auto_update_cache;
  296. (is_outdated, should_auto)
  297. }
  298. }
  299. pub fn parse_filter_file(path: &PathBuf) -> Result<ImportedFilter, String> {
  300. let content = std::fs::read_to_string(path)
  301. .map_err(|e| format!("Could not read filter file: {}", e))?;
  302. let filter: ImportedFilter = toml::from_str(&content)
  303. .map_err(|e| format!("Could not parse filter file: {}", e))?;
  304. if filter.name.is_empty() {
  305. return Err("Filter file must have a name defined".to_string());
  306. }
  307. if filter.tlds.is_empty() {
  308. return Err("Filter file has no TLDs defined".to_string());
  309. }
  310. Ok(filter)
  311. }
  312. /// resolve .hoardom dir, tries a few locations:
  313. ///
  314. /// priority:
  315. /// 1. explicit path via -e flag -> use as root dir (create .hoardom folder there)
  316. /// 2. debug builds: current directory
  317. /// 3. release builds: home directory
  318. /// 4. fallback: try the other option
  319. /// 5. nothing works: caching disabled, in-memory only
  320. pub fn resolve_paths(explicit: Option<&PathBuf>) -> HoardomPaths {
  321. let try_setup = |base: PathBuf| -> Option<HoardomPaths> {
  322. let root = base;
  323. let config_file = root.join("config.toml");
  324. let cache_dir = root.join("cache");
  325. // try to create the directories
  326. if std::fs::create_dir_all(&cache_dir).is_ok() {
  327. Some(HoardomPaths {
  328. config_file,
  329. cache_dir,
  330. can_save: true,
  331. caching_enabled: true,
  332. })
  333. } else {
  334. None
  335. }
  336. };
  337. // explicit path given via -e flag
  338. if let Some(p) = explicit {
  339. // if user gave a path, use it as the .hoardom folder root
  340. let root = if p.extension().is_some() {
  341. // looks like they pointed at a file, use parent dir
  342. p.parent().unwrap_or(p).join(".hoardom")
  343. } else {
  344. p.clone()
  345. };
  346. if let Some(paths) = try_setup(root) {
  347. return paths;
  348. }
  349. }
  350. // debug builds: current directory first
  351. #[cfg(debug_assertions)]
  352. {
  353. if let Ok(dir) = std::env::current_dir() {
  354. if let Some(paths) = try_setup(dir.join(".hoardom")) {
  355. return paths;
  356. }
  357. }
  358. // debug fallback: try home
  359. if let Some(home) = dirs::home_dir() {
  360. if let Some(paths) = try_setup(home.join(".hoardom")) {
  361. return paths;
  362. }
  363. }
  364. }
  365. // release builds: home directory first
  366. #[cfg(not(debug_assertions))]
  367. {
  368. if let Some(home) = dirs::home_dir() {
  369. if let Some(paths) = try_setup(home.join(".hoardom")) {
  370. return paths;
  371. }
  372. }
  373. // release fallback: try cwd
  374. if let Ok(dir) = std::env::current_dir() {
  375. if let Some(paths) = try_setup(dir.join(".hoardom")) {
  376. return paths;
  377. }
  378. }
  379. }
  380. // nothing works - disable caching, use a dummy path
  381. eprintln!("Warning: could not create .hoardom directory anywhere, caching disabled");
  382. HoardomPaths {
  383. config_file: PathBuf::from(".hoardom/config.toml"),
  384. cache_dir: PathBuf::from(".hoardom/cache"),
  385. can_save: false,
  386. caching_enabled: false,
  387. }
  388. }