diff options
| author | UMTS at Teleco <crt@teleco.ch> | 2026-03-08 07:30:34 +0100 |
|---|---|---|
| committer | UMTS at Teleco <crt@teleco.ch> | 2026-03-08 07:30:34 +0100 |
| commit | 8623ef0ee74ff48a5ee24ee032f5b549f662f09d (patch) | |
| tree | 7f11543d05cfe0e7bd5aaca31ff1d4c86a271fd0 /src | |
goofy ah
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 203 | ||||
| -rw-r--r-- | src/config.rs | 436 | ||||
| -rw-r--r-- | src/lookup.rs | 860 | ||||
| -rw-r--r-- | src/main.rs | 418 | ||||
| -rw-r--r-- | src/output.rs | 209 | ||||
| -rw-r--r-- | src/tlds.rs | 179 | ||||
| -rw-r--r-- | src/tui.rs | 2870 | ||||
| -rw-r--r-- | src/types.rs | 93 |
8 files changed, 5268 insertions, 0 deletions
diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..297e4e3 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,203 @@ +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "hoardom", version = "0.0.1", about = "Domain hoarding made less painful")] +#[command(disable_help_flag = true, disable_version_flag = true)] +pub struct Args { + /// One or more domain names to search for + #[arg(value_name = "DOMAIN")] + pub domains: Vec<String>, + + // -- Mode -- + /// Default non interactive mode + #[arg(long = "cli", default_value_t = false)] + pub cli_mode: bool, + + /// Easy to use Terminal based Graphical user interface + #[arg(long = "tui", default_value_t = false)] + pub tui_mode: bool, + + // -- Basics -- + /// Define where environement file should be saved + #[arg(short = 'e', long = "environement")] + pub env_path: Option<PathBuf>, + + /// Show all in list even when unavailable + #[arg(short = 'a', long = "all", default_value_t = false)] + pub show_all: bool, + + // -- Advanced -- + /// Out in CSV, Path is optional. If path isnt given will be printed to terminal with no logs + #[arg(short = 'c', long = "csv")] + pub csv: Option<Option<PathBuf>>, + + /// Built in TLD list to use (from Lists.toml) + #[arg(short = 'l', long = "list")] + pub tld_list: Option<String>, + + /// Import a custom toml list for this session + #[arg(short = 'i', long = "import-filter")] + pub import_filter: Option<PathBuf>, + + /// Set certain TLDs to show up as first result (comma separated) + #[arg(short = 't', long = "top", value_delimiter = ',')] + pub top_tlds: Option<Vec<String>>, + + /// Only search these TLDs (comma separated) + #[arg(short = 'o', long = "onlytop", value_delimiter = ',')] + pub only_top: Option<Vec<String>>, + + /// How many suggestions to look up and try to show (defaults to 0 aka disabled) + #[arg(short = 's', long = "suggestions")] + pub suggestions: Option<usize>, + + // -- Various -- + /// Number of concurrent lookup requests (default: 1) + #[arg(short = 'j', long = "jobs")] + pub jobs: Option<u8>, + + /// Set the global delay in seconds between lookup requests + #[arg(short = 'D', long = "delay")] + pub delay: Option<f64>, + + /// Retry NUMBER amount of times if domain lookup errors out + #[arg(short = 'R', long = "retry")] + pub retry: Option<u32>, + + /// Verbose output for debugging + #[arg(short = 'V', long = "verbose", default_value_t = false)] + pub verbose: bool, + + /// Search for names/domains in text file, one domain per new line + #[arg(short = 'A', long = "autosearch")] + pub autosearch: Option<PathBuf>, + + /// Use a monochrome color scheme + #[arg(short = 'C', long = "no-color", default_value_t = false)] + pub no_color: bool, + + /// Do not use unicode only plain ASCII + #[arg(short = 'U', long = "no-unicode", default_value_t = false)] + pub no_unicode: bool, + + /// Disable the mouse integration for TUI + #[arg(short = 'M', long = "no-mouse", default_value_t = false)] + pub no_mouse: bool, + + /// Force refresh the RDAP bootstrap cache + #[arg(short = 'r', long = "refresh-cache", default_value_t = false)] + pub refresh_cache: bool, + + /// Basic Help + #[arg(short = 'h', long = "help", default_value_t = false)] + pub help: bool, + + /// Show full help + #[arg(short = 'H', long = "fullhelp", default_value_t = false)] + pub fullhelp: bool, +} + +impl Args { + pub fn is_tui(&self) -> bool { + self.tui_mode + } + + pub fn effective_list(&self) -> String { + self.tld_list + .clone() + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| crate::tlds::default_list_name().to_string()) + } + + pub fn effective_suggestions(&self) -> usize { + self.suggestions.unwrap_or(0) + } + + pub fn effective_retry(&self) -> u32 { + self.retry.unwrap_or(1) + } + + pub fn effective_delay(&self) -> f64 { + self.delay.unwrap_or(0.0) + } + + pub fn effective_jobs(&self) -> u8 { + self.jobs.unwrap_or(1).max(1) + } +} + +pub fn print_help() { + println!( + "hoardom {} - Domain hoarding made less painful +Mode : +--cli Default none interactive mode +--tui Easy to use Terminal based Graphical user interface + +Basics : +-e --environement=PATH Define where .hoardom folder should be + Defaults to /home/USER/.hoardom/ + Stores settings, imported lists, favs, cache etc. +-a --all Show all in list even when unavailable + (Unless changed after launch in TUI mode) + +-H --fullhelp Show full help", + env!("CARGO_PKG_VERSION") + ); +} + +pub fn print_fullhelp() { + println!( + "hoardom {} - they are inside your walls! + +## if you see this send a fax to +41 43 543 04 47 that mentions hoardom to know your fate ## + +Mode : +--cli Default none interactive mode +--tui Easy to use Terminal based Graphical user interface + +Basics : +-e --environement=PATH Define where .hoardom folder should be + Defaults to /home/USER/.hoardom/ + Stores settings, imported lists, favs, cache etc. +-a --all Show all in list even when unavailable + (Unless changed after launch in TUI mode) + +Advanced : +-c --csv=PATH Out in CSV,Path is optional + if path isnt given will be printed to terminal with no logs +-l --list=LIST Built in TLD Lists are : {} + Selects which list is applied + (Unless changed after launch in TUI mode) +-i --import-filter=PATH Import a custom toml list for this session +-t --top=TLD,TLD Set certain TLDs to show up as first result + for when you need a domain in your country or for searching + a specific one. + (Unless changed after launch in TUI mode) +-o --onlytop=TLD,TLD Only search these TLDs +-s --suggestions=NUMBER How many suggestions to look up and try to show from list + Number of available alternativ domains to try and find when + Searching for full domain name directly. + Defaults to 0 (aka disabled) + +Various : +-j --jobs=NUMBER Number of concurrent lookup requests + How many TLDs to look up at the same time (default: 1) +-D --delay=DELAY Set the global delay in Seconds between lookup requests +-R --retry=NUMBER Retry NUMBER amount of times if domain lookup errors out +-V --verbose Verbose output for debugging +-A --autosearch=FILE Search for names/domains in text file one domain per new line, + lines starting with invalid character for a domain are ignored + (allows for commenting) +-C --no-color Use a monochrome color scheme +-U --no-unicode Do not use unicode only plain ASCII +-M --no-mouse Disable the mouse integration for TUI +-r --refresh-cache Force refresh the RDAP bootstrap cache + +Help : +-h --help Basic Help +-H --fullhelp You just did this", + env!("CARGO_PKG_VERSION"), + crate::tlds::list_names().join(", ") + ); +} 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, + } +} + diff --git a/src/lookup.rs b/src/lookup.rs new file mode 100644 index 0000000..f5b3177 --- /dev/null +++ b/src/lookup.rs @@ -0,0 +1,860 @@ +use crate::tlds::WhoisOverrides; +use crate::types::{DomainResult, DomainStatus, ErrorKind}; +use futures::stream::{self, StreamExt}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(feature = "builtin-whois")] +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +#[cfg(feature = "builtin-whois")] +use tokio::net::TcpStream; + +// IANA RDAP bootstrap URL, set in Cargo.toml [package.metadata.hoardom] +const RDAP_BOOTSTRAP_URL: &str = env!("HOARDOM_RDAP_BOOTSTRAP_URL"); + +// TLD -> RDAP server map, grabbed once and reused +pub struct RdapBootstrap { + tld_map: HashMap<String, String>, + raw_json: Option<String>, +} + +impl RdapBootstrap { + pub async fn fetch(client: &reqwest::Client, verbose: bool) -> Result<Self, String> { + if verbose { + eprintln!("[verbose] Fetching RDAP bootstrap from {}", RDAP_BOOTSTRAP_URL); + } + + let resp = client + .get(RDAP_BOOTSTRAP_URL) + .send() + .await + .map_err(|e| format!("Failed to fetch RDAP bootstrap: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("RDAP bootstrap returned HTTP {}", resp.status())); + } + + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read RDAP bootstrap body: {}", e))?; + + let json: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse RDAP bootstrap JSON: {}", e))?; + + let tld_map = Self::parse_bootstrap_json(&json); + + if verbose { + eprintln!("[verbose] RDAP bootstrap loaded, {} TLDs mapped", tld_map.len()); + } + + Ok(Self { tld_map, raw_json: Some(body) }) + } + + pub fn load_cached(cache_path: &Path, verbose: bool) -> Result<Self, String> { + if verbose { + eprintln!("[verbose] Loading cached RDAP bootstrap from {}", cache_path.display()); + } + let body = std::fs::read_to_string(cache_path) + .map_err(|e| format!("Could not read cached bootstrap: {}", e))?; + let json: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| format!("Could not parse cached bootstrap: {}", e))?; + let tld_map = Self::parse_bootstrap_json(&json); + if verbose { + eprintln!("[verbose] Cached RDAP bootstrap loaded, {} TLDs mapped", tld_map.len()); + } + Ok(Self { tld_map, raw_json: Some(body) }) + } + + pub fn save_cache(&self, cache_path: &Path) -> Result<(), String> { + if let Some(ref json) = self.raw_json { + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create cache dir: {}", e))?; + } + std::fs::write(cache_path, json) + .map_err(|e| format!("Failed to write cache file: {}", e))?; + } + Ok(()) + } + + fn parse_bootstrap_json(json: &serde_json::Value) -> HashMap<String, String> { + let mut tld_map = HashMap::new(); + // bootstrap format: { "services": [ [ ["tld1", "tld2"], ["https://rdap.server.example/"] ], ... ] } + if let Some(services) = json.get("services").and_then(|s| s.as_array()) { + for service in services { + if let Some(arr) = service.as_array() { + if arr.len() >= 2 { + let tlds = arr[0].as_array(); + let urls = arr[1].as_array(); + if let (Some(tlds), Some(urls)) = (tlds, urls) { + if let Some(base_url) = urls.first().and_then(|u| u.as_str()) { + let base = base_url.trim_end_matches('/').to_string(); + for tld in tlds { + if let Some(t) = tld.as_str() { + tld_map.insert(t.to_lowercase(), base.clone()); + } + } + } + } + } + } + } + } + tld_map + } + + pub fn get_server(&self, tld: &str) -> Option<&String> { + self.tld_map.get(&tld.to_lowercase()) + } +} + +pub async fn lookup_domain( + client: &reqwest::Client, + bootstrap: &RdapBootstrap, + whois_overrides: &WhoisOverrides, + name: &str, + tld: &str, + verbose: bool, +) -> DomainResult { + let full = format!("{}.{}", name, tld); + + let base_url = match bootstrap.get_server(tld) { + Some(url) => url.clone(), + None => { + // no RDAP server for this TLD, fall back to WHOIS + if verbose { + eprintln!("[verbose] No RDAP server for {}, falling back to WHOIS", tld); + } + return whois_lookup(whois_overrides, name, tld, verbose).await; + } + }; + + let url = format!("{}/domain/{}", base_url, full); + + if verbose { + eprintln!("[verbose] Looking up: {}", url); + } + + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + if verbose { + eprintln!("[verbose] Request error for {}: {}", full, e); + } + let kind = if e.is_timeout() { + ErrorKind::Timeout + } else { + ErrorKind::Unknown + }; + return DomainResult::new(name, tld, DomainStatus::Error { + kind, + message: "unknown error".to_string(), + }); + } + }; + + let status_code = resp.status(); + + if verbose { + eprintln!("[verbose] {} -> HTTP {}", full, status_code); + } + + // 404 = not found in RDAP = domain is available (not registered) + if status_code == 404 { + return DomainResult::new(name, tld, DomainStatus::Available); + } + + // 400 = probably invalid query + if status_code == 400 { + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::InvalidTld, + message: "invalid tld".to_string(), + }); + } + + // 429 = rate limited + if status_code == 429 { + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::RateLimit, + message: "rate limited".to_string(), + }); + } + + // 403 = forbidden (some registries block queries) + if status_code == 403 { + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Forbidden, + message: "forbidden".to_string(), + }); + } + + // anything else thats not success + if !status_code.is_success() { + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Unknown, + message: format!("HTTP {}", status_code), + }); + } + + // 200 = domain exists, try to parse expiry from RDAP json + let expiry = match resp.json::<serde_json::Value>().await { + Ok(json) => extract_expiry(&json), + Err(_) => None, + }; + + DomainResult::new(name, tld, DomainStatus::Registered { expiry }) +} + +fn extract_expiry(json: &serde_json::Value) -> Option<String> { + // RDAP stores events as an array, expiration is eventAction = "expiration" + if let Some(events) = json.get("events").and_then(|e| e.as_array()) { + for event in events { + if let Some(action) = event.get("eventAction").and_then(|a| a.as_str()) { + if action == "expiration" { + if let Some(date) = event.get("eventDate").and_then(|d| d.as_str()) { + // RDAP dates are ISO 8601, just grab the date part + return Some(date.chars().take(10).collect()); + } + } + } + } + } + None +} + +// ---- WHOIS fallback for TLDs not in RDAP bootstrap ---- + +// -- No whois feature: just return an error -- +#[cfg(not(any(feature = "system-whois", feature = "builtin-whois")))] +async fn whois_lookup(_whois_overrides: &WhoisOverrides, name: &str, tld: &str, _verbose: bool) -> DomainResult { + DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::InvalidTld, + message: "no RDAP server (whois disabled)".to_string(), + }) +} + +// -- System whois: shells out to the systems whois binary -- +#[cfg(feature = "system-whois")] +async fn whois_lookup(_whois_overrides: &WhoisOverrides, name: &str, tld: &str, verbose: bool) -> DomainResult { + let full = format!("{}.{}", name, tld); + let whois_cmd = env!("HOARDOM_WHOIS_CMD"); + let whois_flags = env!("HOARDOM_WHOIS_FLAGS"); + + if verbose { + if whois_flags.is_empty() { + eprintln!("[verbose] System WHOIS: {} {}", whois_cmd, full); + } else { + eprintln!("[verbose] System WHOIS: {} {} {}", whois_cmd, whois_flags, full); + } + } + + let mut cmd = tokio::process::Command::new(whois_cmd); + // add flags if any are configured + if !whois_flags.is_empty() { + for flag in whois_flags.split_whitespace() { + cmd.arg(flag); + } + } + cmd.arg(&full); + + let output = match tokio::time::timeout( + Duration::from_secs(15), + cmd.output(), + ).await { + Ok(Ok(out)) => out, + Ok(Err(e)) => { + if verbose { + eprintln!("[verbose] System whois error for {}: {}", full, e); + } + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Unknown, + message: format!("whois command failed: {}", e), + }); + } + Err(_) => { + if verbose { + eprintln!("[verbose] System whois timeout for {}", full); + } + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Timeout, + message: "whois timeout".to_string(), + }); + } + }; + + let response_str = String::from_utf8_lossy(&output.stdout); + + if verbose { + eprintln!("[verbose] WHOIS response for {} ({} bytes)", full, output.stdout.len()); + } + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if verbose { + eprintln!("[verbose] whois stderr: {}", stderr.trim()); + } + // some whois commands exit non-zero for "not found" but still give useful stdout + if !response_str.is_empty() { + return parse_whois_response(name, tld, &response_str); + } + return DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Unknown, + message: "whois command returned error".to_string(), + }); + } + + parse_whois_response(name, tld, &response_str) +} + +// -- Builtin whois: raw TCP to whois servers directly -- + +/// try a whois server, returns the response string or errors out +#[cfg(feature = "builtin-whois")] +async fn try_whois_server(server: &str, domain: &str, verbose: bool) -> Result<String, &'static str> { + let addr = format!("{}:43", server); + + let stream = match tokio::time::timeout( + Duration::from_secs(4), + TcpStream::connect(&addr), + ).await { + Ok(Ok(s)) => s, + Ok(Err(_)) => return Err("connect error"), + Err(_) => return Err("connect timeout"), + }; + + if verbose { + eprintln!("[verbose] WHOIS connected: {} -> {}", domain, server); + } + + let (mut reader, mut writer) = stream.into_split(); + + let query = format!("{}\r\n", domain); + if writer.write_all(query.as_bytes()).await.is_err() { + return Err("write error"); + } + + let mut response = Vec::new(); + match tokio::time::timeout( + Duration::from_secs(8), + reader.read_to_end(&mut response), + ).await { + Ok(Ok(_)) => {} + Ok(Err(_)) => return Err("read error"), + Err(_) => return Err("read timeout"), + } + + Ok(String::from_utf8_lossy(&response).to_string()) +} + +/// candidate whois servers for a TLD based on common naming patterns +#[cfg(feature = "builtin-whois")] +fn whois_candidates(tld: &str) -> Vec<String> { + // most registries follow one of these patterns + vec![ + format!("whois.nic.{}", tld), + format!("whois.{}", tld), + format!("{}.whois-servers.net", tld), + ] +} + +#[cfg(feature = "builtin-whois")] +async fn whois_lookup(whois_overrides: &WhoisOverrides, name: &str, tld: &str, verbose: bool) -> DomainResult { + let full = format!("{}.{}", name, tld); + + // if Lists.toml has an explicit server ("tld:server"), use ONLY that one + if let Some(server) = whois_overrides.get_server(tld) { + if verbose { + eprintln!("[verbose] WHOIS (override): {} -> {}", full, server); + } + return match try_whois_server(server, &full, verbose).await { + Ok(resp) if !resp.is_empty() => parse_whois_response(name, tld, &resp), + Ok(_) => DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Unknown, + message: "empty whois response".to_string(), + }), + Err(e) => DomainResult::new(name, tld, DomainStatus::Error { + kind: if e.contains("timeout") { ErrorKind::Timeout } else { ErrorKind::Unknown }, + message: format!("whois {}: {}", server, e), + }), + }; + } + + // no override: try common server patterns until one responds + let candidates = whois_candidates(tld); + + if verbose { + eprintln!("[verbose] WHOIS probing {} candidates for .{}", candidates.len(), tld); + } + + for server in &candidates { + match try_whois_server(server, &full, verbose).await { + Ok(resp) if !resp.is_empty() => { + return parse_whois_response(name, tld, &resp); + } + Ok(_) => { + if verbose { + eprintln!("[verbose] WHOIS {} returned empty for {}", server, full); + } + } + Err(e) => { + if verbose { + eprintln!("[verbose] WHOIS {} failed for {}: {}", server, full, e); + } + } + } + } + + // nothing worked + DomainResult::new(name, tld, DomainStatus::Error { + kind: ErrorKind::Unknown, + message: "no whois server reachable".to_string(), + }) +} + +fn parse_whois_response(name: &str, tld: &str, response: &str) -> DomainResult { + let lower = response.to_lowercase(); + + // common "not found" / "available" patterns across registrars + let available_patterns = [ + "no match for", + "not found", + "no entries found", + "no data found", + "status: free", + "status: available", + "is free", + "no object found", + "object not found", + "nothing found", + "domain not found", + "no information available", + "we do not have an entry", + ]; + + for pattern in &available_patterns { + if lower.contains(pattern) { + return DomainResult::new(name, tld, DomainStatus::Available); + } + } + + // if we got a response and it wasnt "not found", domain is probably registered + // try to extract expiry date + let expiry = extract_whois_expiry(&lower); + + DomainResult::new(name, tld, DomainStatus::Registered { expiry }) +} + +fn extract_whois_expiry(response: &str) -> Option<String> { + let expiry_patterns = [ + "expiry date:", + "expiration date:", + "registry expiry date:", + "registrar registration expiration date:", + "paid-till:", + "expires:", + "expire:", + "renewal date:", + "expires on:", + ]; + + for line in response.lines() { + let trimmed = line.trim().to_lowercase(); + for pattern in &expiry_patterns { + if trimmed.starts_with(pattern) { + let value = trimmed[pattern.len()..].trim(); + // try to extract a date-looking thing (first 10 chars if it looks like YYYY-MM-DD) + if value.len() >= 10 { + let date_part: String = value.chars().take(10).collect(); + // basic sanity check: contains digits and dashes + if date_part.contains('-') && date_part.chars().any(|c| c.is_ascii_digit()) { + return Some(date_part); + } + } + // maybe its in a different format, just return what we got + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +pub async fn lookup_with_retry( + client: &reqwest::Client, + bootstrap: &RdapBootstrap, + whois_overrides: &WhoisOverrides, + name: &str, + tld: &str, + retries: u32, + noretry: &[ErrorKind], + verbose: bool, +) -> DomainResult { + let mut result = lookup_domain(client, bootstrap, whois_overrides, name, tld, verbose).await; + + for attempt in 1..=retries { + if !result.is_error() { + break; + } + // skip retry if the error kind is in the noretry list + if let DomainStatus::Error { kind, .. } = &result.status { + if noretry.contains(kind) { + if verbose { + eprintln!("[verbose] Not retrying {}.{} (error kind in noretry list)", name, tld); + } + break; + } + } + if verbose { + eprintln!("[verbose] Retry {}/{} for {}.{}", attempt, retries, name, tld); + } + tokio::time::sleep(Duration::from_millis(500)).await; + result = lookup_domain(client, bootstrap, whois_overrides, name, tld, verbose).await; + } + + result +} + +pub fn build_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent(format!("hoardom/{}", env!("CARGO_PKG_VERSION"))) + .build() + .expect("Failed to create HTTP client") +} + +pub async fn lookup_all( + name: &str, + tlds: &[&str], + delay_secs: f64, + retries: u32, + verbose: bool, + cache_path: Option<&Path>, + force_refresh: bool, + jobs: u8, + whois_overrides: &WhoisOverrides, + noretry: &[ErrorKind], + on_progress: impl Fn(usize, usize), +) -> Vec<DomainResult> { + let client = build_client(); + + let bootstrap = match resolve_bootstrap(&client, cache_path, force_refresh, verbose).await { + Some(b) => b, + None => return Vec::new(), + }; + + let total = tlds.len(); + let concurrent = (jobs as usize).max(1); + + if concurrent <= 1 { + // sequential path (original behaviour) + let mut results = Vec::with_capacity(total); + let delay = Duration::from_secs_f64(delay_secs); + for (i, tld) in tlds.iter().enumerate() { + let result = lookup_with_retry(&client, &bootstrap, whois_overrides, name, tld, retries, noretry, verbose).await; + results.push(result); + on_progress(i + 1, total); + if delay_secs > 0.0 && i + 1 < total { + tokio::time::sleep(delay).await; + } + } + results + } else { + // concurrent path + let bootstrap = Arc::new(bootstrap); + let client = Arc::new(client); + let whois_overrides = Arc::new(whois_overrides.clone()); + let noretry = Arc::new(noretry.to_vec()); + let name_owned = name.to_string(); + + let mut stream = stream::iter(tlds.iter().enumerate()) + .map(|(i, tld)| { + let client = Arc::clone(&client); + let bootstrap = Arc::clone(&bootstrap); + let whois_overrides = Arc::clone(&whois_overrides); + let noretry = Arc::clone(&noretry); + let name = name_owned.clone(); + let tld = tld.to_string(); + async move { + let result = lookup_with_retry(&client, &bootstrap, &whois_overrides, &name, &tld, retries, &noretry, verbose).await; + (i, result) + } + }) + .buffer_unordered(concurrent); + + let mut results: Vec<(usize, DomainResult)> = Vec::with_capacity(total); + let mut done_count = 0usize; + while let Some(item) = stream.next().await { + results.push(item); + done_count += 1; + on_progress(done_count, total); + } + + // sort by original order + results.sort_by_key(|(i, _)| *i); + + results.into_iter().map(|(_, r)| r).collect() + } +} + +pub async fn refresh_cache(cache_path: &Path, verbose: bool) -> Result<(), String> { + let client = build_client(); + let bootstrap = RdapBootstrap::fetch(&client, verbose).await?; + bootstrap.save_cache(cache_path)?; + eprintln!("RDAP bootstrap cache refreshed ({} TLDs)", bootstrap.tld_map.len()); + Ok(()) +} + +async fn resolve_bootstrap( + client: &reqwest::Client, + cache_path: Option<&Path>, + force_refresh: bool, + verbose: bool, +) -> Option<RdapBootstrap> { + // try loading bootstrap from cache first (unless force refresh) + let cached = if !force_refresh { + if let Some(cp) = cache_path { + if cp.exists() { + match RdapBootstrap::load_cached(cp, verbose) { + Ok(b) => Some(b), + Err(e) => { + if verbose { + eprintln!("[verbose] Cache load failed: {}, fetching fresh", e); + } + None + } + } + } else { + None + } + } else { + None + } + } else { + if verbose { + eprintln!("[verbose] Force refresh requested, skipping cache"); + } + None + }; + + match cached { + Some(b) => Some(b), + None => { + match RdapBootstrap::fetch(client, verbose).await { + Ok(b) => { + if let Some(cp) = cache_path { + if let Err(e) = b.save_cache(cp) { + if verbose { + eprintln!("[verbose] Failed to save cache: {}", e); + } + } else if verbose { + eprintln!("[verbose] RDAP bootstrap cached to {}", cp.display()); + } + } + Some(b) + } + Err(e) => { + eprintln!("Error: {}", e); + eprintln!("Cannot perform lookups without RDAP bootstrap data."); + None + } + } + } + } +} + +pub enum StreamMsg { + Result { result: DomainResult, sort_index: usize }, + Progress { current: usize, total: usize }, + Error(String), + Done, +} + +pub struct LookupStream { + pub receiver: tokio::sync::mpsc::Receiver<StreamMsg>, + pub handle: tokio::task::JoinHandle<()>, +} + +pub type LookupBatch = Vec<(String, Vec<String>)>; + +// spawns a bg task, sends results via channel so TUI gets em live +pub fn lookup_streaming( + name: String, + tlds: Vec<String>, + delay_secs: f64, + retries: u32, + verbose: bool, + cache_path: Option<PathBuf>, + force_refresh: bool, + jobs: u8, + whois_overrides: WhoisOverrides, + noretry: Vec<ErrorKind>, +) -> LookupStream { + let (tx, rx) = tokio::sync::mpsc::channel(64); + + let handle = tokio::spawn(async move { + let client = build_client(); + + let bootstrap = match resolve_bootstrap( + &client, + cache_path.as_deref(), + force_refresh, + verbose, + ).await { + Some(b) => b, + None => { + let _ = tx.send(StreamMsg::Error("Failed to load RDAP bootstrap".to_string())).await; + let _ = tx.send(StreamMsg::Done).await; + return; + } + }; + + let total = tlds.len(); + let concurrent = (jobs as usize).max(1); + + if concurrent <= 1 { + let delay = Duration::from_secs_f64(delay_secs); + for (i, tld) in tlds.iter().enumerate() { + let result = lookup_with_retry(&client, &bootstrap, &whois_overrides, &name, tld, retries, &noretry, verbose).await; + let _ = tx.send(StreamMsg::Result { result, sort_index: i }).await; + let _ = tx.send(StreamMsg::Progress { current: i + 1, total }).await; + if delay_secs > 0.0 && i + 1 < total { + tokio::time::sleep(delay).await; + } + } + } else { + let bootstrap = Arc::new(bootstrap); + let client = Arc::new(client); + let whois_overrides = Arc::new(whois_overrides); + let noretry = Arc::new(noretry); + let tx2 = tx.clone(); + + let mut stream = stream::iter(tlds.into_iter().enumerate()) + .map(|(idx, tld)| { + let client = Arc::clone(&client); + let bootstrap = Arc::clone(&bootstrap); + let whois_overrides = Arc::clone(&whois_overrides); + let noretry = Arc::clone(&noretry); + let name = name.clone(); + async move { + let result = lookup_with_retry(&client, &bootstrap, &whois_overrides, &name, &tld, retries, &noretry, verbose).await; + (idx, result) + } + }) + .buffer_unordered(concurrent); + + let mut done_count = 0usize; + while let Some((idx, result)) = stream.next().await { + done_count += 1; + let _ = tx2.send(StreamMsg::Result { result, sort_index: idx }).await; + let _ = tx2.send(StreamMsg::Progress { current: done_count, total }).await; + } + } + + let _ = tx.send(StreamMsg::Done).await; + }); + + LookupStream { + receiver: rx, + handle, + } +} + +pub fn lookup_many_streaming( + batches: LookupBatch, + delay_secs: f64, + retries: u32, + verbose: bool, + cache_path: Option<PathBuf>, + force_refresh: bool, + jobs: u8, + whois_overrides: WhoisOverrides, + noretry: Vec<ErrorKind>, +) -> LookupStream { + if batches.len() == 1 { + let (name, tlds) = batches.into_iter().next().unwrap(); + return lookup_streaming(name, tlds, delay_secs, retries, verbose, cache_path, force_refresh, jobs, whois_overrides, noretry); + } + + let (tx, rx) = tokio::sync::mpsc::channel(64); + + let handle = tokio::spawn(async move { + let client = build_client(); + + let bootstrap = match resolve_bootstrap( + &client, + cache_path.as_deref(), + force_refresh, + verbose, + ).await { + Some(b) => b, + None => { + let _ = tx.send(StreamMsg::Error("Failed to load RDAP bootstrap".to_string())).await; + let _ = tx.send(StreamMsg::Done).await; + return; + } + }; + + let total: usize = batches.iter().map(|(_, tlds)| tlds.len()).sum(); + let concurrent = (jobs as usize).max(1); + + if concurrent <= 1 { + let delay = Duration::from_secs_f64(delay_secs); + let mut current = 0usize; + let mut global_idx = 0usize; + for (name, tlds) in batches { + for tld in tlds { + let result = lookup_with_retry(&client, &bootstrap, &whois_overrides, &name, &tld, retries, &noretry, verbose).await; + current += 1; + let _ = tx.send(StreamMsg::Result { result, sort_index: global_idx }).await; + let _ = tx.send(StreamMsg::Progress { current, total }).await; + if delay_secs > 0.0 && current < total { + tokio::time::sleep(delay).await; + } + global_idx += 1; + } + } + } else { + let bootstrap = Arc::new(bootstrap); + let client = Arc::new(client); + let whois_overrides = Arc::new(whois_overrides); + let noretry = Arc::new(noretry); + let tx2 = tx.clone(); + + // flatten all (name, tld) pairs with their global index + let pairs: Vec<(usize, String, String)> = batches + .into_iter() + .flat_map(|(name, tlds)| tlds.into_iter().map(move |tld| (name.clone(), tld))) + .enumerate() + .map(|(idx, (name, tld))| (idx, name, tld)) + .collect(); + + let mut stream = stream::iter(pairs.into_iter()) + .map(|(idx, name, tld)| { + let client = Arc::clone(&client); + let bootstrap = Arc::clone(&bootstrap); + let whois_overrides = Arc::clone(&whois_overrides); + let noretry = Arc::clone(&noretry); + async move { + let result = lookup_with_retry(&client, &bootstrap, &whois_overrides, &name, &tld, retries, &noretry, verbose).await; + (idx, result) + } + }) + .buffer_unordered(concurrent); + + let mut done_count = 0usize; + while let Some((idx, result)) = stream.next().await { + done_count += 1; + let _ = tx2.send(StreamMsg::Result { result, sort_index: idx }).await; + let _ = tx2.send(StreamMsg::Progress { current: done_count, total }).await; + } + } + + let _ = tx.send(StreamMsg::Done).await; + }); + + LookupStream { receiver: rx, handle } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..aa0b993 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,418 @@ +mod cli; +mod config; +mod lookup; +mod output; +mod tlds; +mod tui; +mod types; + +use clap::Parser; +use cli::{print_fullhelp, print_help, Args}; +use config::{parse_filter_file, resolve_paths, Config}; +use tlds::{apply_top_tlds, get_tlds_or_default, whois_overrides}; +use types::{DomainResult, ErrorKind}; + +#[derive(Debug)] +struct AggregatedResult { + domain_idx: usize, + tld_idx: usize, + result: DomainResult, +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + + // handle help flags + if args.help { + print_help(); + return; + } + if args.fullhelp { + print_fullhelp(); + return; + } + + // resolve .hoardom directory structure + let paths = resolve_paths(args.env_path.as_ref()); + let mut config = Config::load_with_backup(&paths.config_file); + + if !paths.can_save { + eprintln!("Warning: favorites and settings wont be saved (no writable location found)"); + } + + // handle -r refresh cache flag + if args.refresh_cache { + if !paths.caching_enabled { + eprintln!("Caching is disabled (no writable location). Nothing to refresh."); + return; + } + let cache_file = paths.cache_file("rdap_bootstrap.json"); + match lookup::refresh_cache(&cache_file, args.verbose).await { + Ok(()) => { + config.mark_cache_updated(); + if paths.can_save { + let _ = config.save(&paths.config_file); + } + } + Err(e) => eprintln!("Error refreshing cache: {}", e), + } + return; + } + + // check if cache is stale and auto update if needed + let cache_file = if paths.caching_enabled { + Some(paths.cache_file("rdap_bootstrap.json")) + } else { + None + }; + + let force_refresh = if let Some(ref cf) = cache_file { + let (is_outdated, should_auto) = config.check_cache_status(); + if is_outdated && !should_auto { + eprintln!("Warning: RDAP cache is outdated. Run `hoardom -r` to refresh."); + } + // force refresh if auto update says so, or if cache file doesnt exist yet + should_auto || !cf.exists() + } else { + false + }; + + // import custom filter if given + if let Some(filter_path) = &args.import_filter { + match parse_filter_file(filter_path) { + Ok(filter) => { + config.import_filter(filter); + if paths.can_save { + let _ = config.save(&paths.config_file); + } + } + Err(e) => { + eprintln!("Error importing filter: {}", e); + return; + } + } + } + + // whois server overrides are baked into Lists.toml ("tld:server" syntax) + let overrides = whois_overrides(); + + // parse noretry config into ErrorKind list + let noretry: Vec<ErrorKind> = config.settings.noretry.iter() + .filter_map(|s| ErrorKind::from_config_str(s)) + .collect(); + + // TUI mode + if args.is_tui() { + if let Err(e) = tui::run_tui(&args, &config, paths.clone(), cache_file.clone(), force_refresh, overrides.clone(), noretry.clone()).await { + eprintln!("TUI error: {}", e); + } + // save cache timestamp after TUI session if we refreshed + if force_refresh && paths.can_save { + config.mark_cache_updated(); + let _ = config.save(&paths.config_file); + } + return; + } + + // CLI needs at least one domain unless autosearch was given + if args.domains.is_empty() { + if let Some(file_path) = &args.autosearch { + run_autosearch(&args, file_path, cache_file.as_deref(), force_refresh, overrides, &noretry).await; + if force_refresh && paths.can_save { + config.mark_cache_updated(); + let _ = config.save(&paths.config_file); + } + return; + } + print_help(); + return; + } + + let base_tlds = build_base_tlds(&args); + let total_lookups = estimate_total_lookups(&args.domains, &base_tlds); + let mut completed_lookups = 0usize; + let mut refresh_remaining = force_refresh; + let mut aggregated_results = Vec::new(); + + for (domain_idx, raw_domain) in args.domains.iter().enumerate() { + let (search_name, specific_tld) = parse_domain_input(raw_domain); + let effective_tlds = build_effective_tlds(&base_tlds, specific_tld.as_deref()); + + if effective_tlds.is_empty() { + eprintln!("No TLDs to search. Check your filter settings."); + return; + } + + let results = lookup::lookup_all( + &search_name, + &effective_tlds, + args.effective_delay(), + args.effective_retry(), + args.verbose, + cache_file.as_deref(), + refresh_remaining, + args.effective_jobs(), + overrides, + &noretry, + |current, _total| { + output::print_progress(completed_lookups + current, total_lookups.max(1)); + }, + ) + .await; + + refresh_remaining = false; + completed_lookups += effective_tlds.len(); + + for result in results { + let tld_idx = effective_tlds + .iter() + .position(|tld| *tld == result.tld) + .unwrap_or(usize::MAX - 1); + aggregated_results.push(AggregatedResult { + domain_idx, + tld_idx, + result, + }); + } + + // Suggestions only kick in when directly searching a single full domain + if args.domains.len() == 1 && args.effective_suggestions() > 0 { + if let Some(exact_tld) = specific_tld.as_deref() { + let exact_registered = aggregated_results.iter().any(|item| { + item.result.name == search_name + && item.result.tld == exact_tld + && !item.result.is_available() + }); + + if exact_registered { + let suggestion_tlds: Vec<&'static str> = base_tlds + .iter() + .copied() + .filter(|tld| *tld != exact_tld) + .collect(); + + if !suggestion_tlds.is_empty() { + let suggestion_results = lookup::lookup_all( + &search_name, + &suggestion_tlds, + args.effective_delay(), + args.effective_retry(), + args.verbose, + cache_file.as_deref(), + false, + args.effective_jobs(), + overrides, + &noretry, + |_current, _total| {}, + ) + .await; + + for result in suggestion_results + .into_iter() + .filter(|result| result.is_available()) + .take(args.effective_suggestions()) + { + let tld_idx = base_tlds + .iter() + .position(|tld| *tld == result.tld) + .unwrap_or(usize::MAX); + aggregated_results.push(AggregatedResult { + domain_idx, + tld_idx, + result, + }); + } + } + } + } + } + } + + let results = sort_aggregated_results(aggregated_results); + + // save cache timestamp if we refreshed + if force_refresh && paths.can_save { + config.mark_cache_updated(); + let _ = config.save(&paths.config_file); + } + + // print errors first + output::print_errors(&results, args.verbose); + + // CSV output + if let Some(csv_opt) = &args.csv { + match csv_opt { + Some(path) => { + // write to file + match output::write_csv_file(&results, path) { + Ok(()) => eprintln!("CSV written to {}", path.display()), + Err(e) => eprintln!("Error writing CSV: {}", e), + } + } + None => { + // print to stdout, no logs + output::print_csv(&results); + } + } + return; + } + + // table output + if args.show_all { + output::print_full_table(&results, args.no_color, args.no_unicode); + } else { + output::print_available_table(&results, args.no_color, args.no_unicode); + } +} + +async fn run_autosearch( + args: &Args, + file_path: &std::path::PathBuf, + cache_path: Option<&std::path::Path>, + force_refresh: bool, + overrides: &tlds::WhoisOverrides, + noretry: &[ErrorKind], +) { + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Could not read autosearch file: {}", e); + return; + } + }; + + let base_tlds = build_base_tlds(args); + + // collect all search entries, grouping by name so "zapplex.de" + "zapplex.nl" become one batch + let mut batches: Vec<(String, Vec<String>)> = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(first) = trimmed.chars().next() { + if !first.is_alphanumeric() { + continue; + } + } + + let (search_name, specific_tld) = if trimmed.contains('.') { + let parts: Vec<&str> = trimmed.splitn(2, '.').collect(); + (parts[0].to_string(), Some(parts[1].to_string())) + } else { + (trimmed.to_string(), None) + }; + + let effective_tlds = build_effective_tlds(&base_tlds, specific_tld.as_deref()); + + let entry = if let Some(pos) = batches.iter().position(|(name, _)| *name == search_name) { + &mut batches[pos].1 + } else { + batches.push((search_name, Vec::new())); + &mut batches.last_mut().unwrap().1 + }; + for tld in effective_tlds { + if !entry.contains(&tld.to_string()) { + entry.push(tld.to_string()); + } + } + } + + if batches.is_empty() { + eprintln!("No valid search terms in file"); + return; + } + + let total_lookups: usize = batches.iter().map(|(_, tlds)| tlds.len()).sum(); + let mut completed = 0usize; + let mut all_results: Vec<DomainResult> = Vec::new(); + let mut refresh_remaining = force_refresh; + + for (search_name, tlds) in &batches { + let tld_refs: Vec<&str> = tlds.iter().map(|s| s.as_str()).collect(); + + let results = lookup::lookup_all( + search_name, + &tld_refs, + args.effective_delay(), + args.effective_retry(), + args.verbose, + cache_path, + refresh_remaining, + args.effective_jobs(), + overrides, + noretry, + |current, _total| { + output::print_progress(completed + current, total_lookups.max(1)); + }, + ) + .await; + + refresh_remaining = false; + completed += tlds.len(); + all_results.extend(results); + } + + output::print_errors(&all_results, args.verbose); + + if args.show_all { + output::print_full_table(&all_results, args.no_color, args.no_unicode); + } else { + output::print_available_table(&all_results, args.no_color, args.no_unicode); + } +} + +fn build_base_tlds(args: &Args) -> Vec<&'static str> { + let tld_list = args.effective_list(); + let mut tld_vec = get_tlds_or_default(&tld_list); + + if let Some(ref only) = args.only_top { + tld_vec = only + .iter() + .filter(|s| !s.is_empty()) + .map(|s| -> &'static str { Box::leak(s.clone().into_boxed_str()) }) + .collect(); + } + + if let Some(ref top) = args.top_tlds { + tld_vec = apply_top_tlds(tld_vec, top); + } + + tld_vec +} + +fn parse_domain_input(raw_domain: &str) -> (String, Option<String>) { + if raw_domain.contains('.') { + let parts: Vec<&str> = raw_domain.splitn(2, '.').collect(); + (parts[0].to_string(), Some(parts[1].to_string())) + } else { + (raw_domain.to_string(), None) + } +} + +fn build_effective_tlds(base_tlds: &[&'static str], specific_tld: Option<&str>) -> Vec<&'static str> { + if let Some(tld) = specific_tld { + vec![Box::leak(tld.to_string().into_boxed_str()) as &'static str] + } else { + base_tlds.to_vec() + } +} + +fn estimate_total_lookups(domains: &[String], base_tlds: &[&'static str]) -> usize { + domains + .iter() + .map(|domain| if domain.contains('.') { 1 } else { base_tlds.len() }) + .sum() +} + +fn sort_aggregated_results(mut aggregated: Vec<AggregatedResult>) -> Vec<DomainResult> { + aggregated.sort_by(|a, b| { + a.tld_idx + .cmp(&b.tld_idx) + .then(a.domain_idx.cmp(&b.domain_idx)) + }); + aggregated.into_iter().map(|item| item.result).collect() +} + diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..fe3b141 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,209 @@ +use crate::types::{DomainResult, DomainStatus, ErrorKind}; +use colored::*; +use std::io::Write; +use std::path::PathBuf; + +pub fn print_available_table(results: &[DomainResult], no_color: bool, no_unicode: bool) { + let available: Vec<&DomainResult> = results.iter().filter(|r| r.is_available()).collect(); + + if available.is_empty() { + println!("No available domains found."); + return; + } + + let max_len = available.iter().map(|r| r.full.len()).max().unwrap_or(20); + let width = max_len + 4; // padding + + let title = "Available Domains"; + let title_padded = format!("{:^width$}", title, width = width); + + if no_unicode { + // ASCII box + let border = format!("+{}+", "-".repeat(width)); + println!("{}", border); + if no_color { + println!("|{}|", title_padded); + } else { + println!("|{}|", title_padded.green()); + } + println!("+{}+", "-".repeat(width)); + for r in &available { + println!("| {:<pad$} |", r.full, pad = width - 2); + } + println!("{}", border); + } else { + // Unicode box + let top = format!("┌{}┐", "─".repeat(width)); + let sep = format!("├{}┤", "─".repeat(width)); + let bot = format!("└{}┘", "─".repeat(width)); + println!("{}", top); + if no_color { + println!("│{}│", title_padded); + } else { + println!("│{}│", title_padded.green()); + } + println!("{}", sep); + for r in &available { + println!("│ {:<pad$} │", r.full, pad = width - 2); + } + println!("{}", bot); + } +} + +pub fn print_full_table(results: &[DomainResult], no_color: bool, no_unicode: bool) { + if results.is_empty() { + println!("No results."); + return; + } + + // calc column widths + let domain_w = results.iter().map(|r| r.full.len()).max().unwrap_or(10).max(7); + let status_w = 10; // "registered" is the longest + let note_w = results.iter().map(|r| r.note_str().len()).max().unwrap_or(4).max(4); + + let domain_col = domain_w + 2; + let status_col = status_w + 2; + let note_col = note_w + 2; + + if no_unicode { + print_full_table_ascii(results, domain_col, status_col, note_col, no_color); + } else { + print_full_table_unicode(results, domain_col, status_col, note_col, no_color); + } +} + +fn print_full_table_unicode( + results: &[DomainResult], + dc: usize, + sc: usize, + nc: usize, + no_color: bool, +) { + let top = format!("┌{}┬{}┬{}┐", "─".repeat(dc), "─".repeat(sc), "─".repeat(nc)); + let sep = format!("├{}┼{}┼{}┤", "─".repeat(dc), "─".repeat(sc), "─".repeat(nc)); + let bot = format!("└{}┴{}┴{}┘", "─".repeat(dc), "─".repeat(sc), "─".repeat(nc)); + + println!("{}", top); + println!( + "│{:^dc$}│{:^sc$}│{:^nc$}│", + "Domains", + "Status", + "Note", + dc = dc, + sc = sc, + nc = nc, + ); + println!("{}", sep); + + for r in results { + let domain_str = format!(" {:<width$} ", r.full, width = dc - 2); + let status_str = format!(" {:<width$} ", r.status_str(), width = sc - 2); + let note_str = format!(" {:<width$} ", r.note_str(), width = nc - 2); + + if no_color { + println!("│{}│{}│{}│", domain_str, status_str, note_str); + } else { + let colored_domain = color_domain(&domain_str, &r.status); + println!("│{}│{}│{}│", colored_domain, status_str, note_str); + } + } + + println!("{}", bot); +} + +fn print_full_table_ascii( + results: &[DomainResult], + dc: usize, + sc: usize, + nc: usize, + no_color: bool, +) { + let border = format!("+{}+{}+{}+", "-".repeat(dc), "-".repeat(sc), "-".repeat(nc)); + + println!("{}", border); + println!( + "|{:^dc$}|{:^sc$}|{:^nc$}|", + "Domains", + "Status", + "Note", + dc = dc, + sc = sc, + nc = nc, + ); + println!("{}", border); + + for r in results { + let domain_str = format!(" {:<width$} ", r.full, width = dc - 2); + let status_str = format!(" {:<width$} ", r.status_str(), width = sc - 2); + let note_str = format!(" {:<width$} ", r.note_str(), width = nc - 2); + + if no_color { + println!("|{}|{}|{}|", domain_str, status_str, note_str); + } else { + let colored_domain = color_domain(&domain_str, &r.status); + println!("|{}|{}|{}|", colored_domain, status_str, note_str); + } + } + + println!("{}", border); +} + +fn color_domain(domain: &str, status: &DomainStatus) -> ColoredString { + match status { + DomainStatus::Available => domain.green(), + DomainStatus::Registered { .. } => domain.red(), + DomainStatus::Error { kind, .. } => match kind { + ErrorKind::InvalidTld => domain.yellow(), + _ => domain.blue(), + }, + } +} + +pub fn print_csv(results: &[DomainResult]) { + println!("Domains, Status, Note"); + for r in results { + println!("{}, {}, {}", r.full, r.status_str(), r.note_str()); + } +} + +pub fn write_csv_file(results: &[DomainResult], path: &PathBuf) -> Result<(), String> { + let mut file = std::fs::File::create(path) + .map_err(|e| format!("Could not create CSV file: {}", e))?; + writeln!(file, "Domains, Status, Note") + .map_err(|e| format!("Write error: {}", e))?; + for r in results { + writeln!(file, "{}, {}, {}", r.full, r.status_str(), r.note_str()) + .map_err(|e| format!("Write error: {}", e))?; + } + Ok(()) +} + +pub fn print_errors(results: &[DomainResult], verbose: bool) { + for r in results { + if let DomainStatus::Error { kind, message } = &r.status { + match kind { + ErrorKind::InvalidTld => { + eprintln!("Error for {}, tld does not seem to exist", r.full); + } + _ => { + if verbose { + eprintln!("Error for {}, {} (raw: {})", r.full, message, message); + } else { + eprintln!( + "Error for {}, unknown error (enable verbose to see raw error)", + r.full + ); + } + } + } + } + } +} + +pub fn print_progress(current: usize, total: usize) { + let percent = (current as f64 / total as f64 * 100.0) as u32; + eprint!("\rParsing results : {}%", percent); + if current == total { + eprintln!("\rParsing results : Done "); + } +} diff --git a/src/tlds.rs b/src/tlds.rs new file mode 100644 index 0000000..2835c24 --- /dev/null +++ b/src/tlds.rs @@ -0,0 +1,179 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + +/// whois server overrides from Lists.toml ("io:whois.nic.io" style entries) +#[derive(Debug, Clone, Default)] +pub struct WhoisOverrides { + map: HashMap<String, String>, +} + +impl WhoisOverrides { + pub fn get_server(&self, tld: &str) -> Option<&str> { + self.map.get(&tld.to_lowercase()).map(|s| s.as_str()) + } +} + +/// a named TLD list from Lists.toml +struct NamedList { + name: String, + tlds: Vec<String>, +} + +struct ParsedLists { + lists: Vec<NamedList>, + whois_overrides: WhoisOverrides, +} + +/// parse a single entry: "tld" or "tld:whois_server" +fn parse_entry(entry: &str) -> (String, Option<String>) { + if let Some(pos) = entry.find(':') { + (entry[..pos].to_string(), Some(entry[pos + 1..].to_string())) + } else { + (entry.to_string(), None) + } +} + +/// parse entries, pull out TLD names and whois overrides +fn parse_list(entries: &[toml::Value], overrides: &mut HashMap<String, String>) -> Vec<String> { + entries + .iter() + .filter_map(|v| v.as_str()) + .map(|entry| { + let (tld, server) = parse_entry(entry); + if let Some(s) = server { + overrides.insert(tld.to_lowercase(), s); + } + tld + }) + .collect() +} + +static PARSED_LISTS: OnceLock<ParsedLists> = OnceLock::new(); + +fn parsed_lists() -> &'static ParsedLists { + PARSED_LISTS.get_or_init(|| { + let raw: toml::Value = toml::from_str(include_str!("../Lists.toml")) + .expect("Lists.toml must be valid TOML"); + + let table = raw.as_table().expect("Lists.toml must be a TOML table"); + + // Build list names in the order build.rs discovered them + let ordered_names: Vec<&str> = env!("HOARDOM_LIST_NAMES").split(',').collect(); + + let mut overrides = HashMap::new(); + let mut lists = Vec::new(); + + for name in &ordered_names { + if let Some(toml::Value::Array(arr)) = table.get(*name) { + let tlds = parse_list(arr, &mut overrides); + lists.push(NamedList { + name: name.to_string(), + tlds, + }); + } + } + + ParsedLists { + lists, + whois_overrides: WhoisOverrides { map: overrides }, + } + }) +} + +/// list names from Lists.toml, in order +pub fn list_names() -> Vec<&'static str> { + parsed_lists() + .lists + .iter() + .map(|l| l.name.as_str()) + .collect() +} + +/// first list name (the default) +pub fn default_list_name() -> &'static str { + list_names().first().copied().unwrap_or("standard") +} + +/// get TLDs for a list name (case insensitive), None if not found +pub fn get_tlds(name: &str) -> Option<Vec<&'static str>> { + let lower = name.to_lowercase(); + parsed_lists() + .lists + .iter() + .find(|l| l.name == lower) + .map(|l| l.tlds.iter().map(String::as_str).collect()) +} + +/// get TLDs for a list name, falls back to default if not found +pub fn get_tlds_or_default(name: &str) -> Vec<&'static str> { + get_tlds(name).unwrap_or_else(|| get_tlds(default_list_name()).unwrap_or_default()) +} + +/// the builtin whois overrides from Lists.toml +pub fn whois_overrides() -> &'static WhoisOverrides { + &parsed_lists().whois_overrides +} + +pub fn apply_top_tlds(tlds: Vec<&'static str>, top: &[String]) -> Vec<&'static str> { + let mut result: Vec<&'static str> = Vec::with_capacity(tlds.len()); + // first add the top ones in the order specified + for t in top { + let lower = t.to_lowercase(); + if let Some(&found) = tlds.iter().find(|&&tld| tld.to_lowercase() == lower) { + if !result.contains(&found) { + result.push(found); + } + } + } + // then add the rest + for tld in &tlds { + if !result.contains(tld) { + result.push(tld); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_entry_bare() { + let (tld, server) = parse_entry("com"); + assert_eq!(tld, "com"); + assert_eq!(server, None); + } + + #[test] + fn test_parse_entry_with_override() { + let (tld, server) = parse_entry("io:whois.nic.io"); + assert_eq!(tld, "io"); + assert_eq!(server, Some("whois.nic.io".to_string())); + } + + #[test] + fn test_whois_overrides_populated() { + let overrides = whois_overrides(); + // io should have an override since our Lists.toml has "io:whois.nic.io" + assert!(overrides.get_server("io").is_some()); + // com should not (it has RDAP) + assert!(overrides.get_server("com").is_none()); + } + + #[test] + fn test_top_tlds_reorder() { + let tlds = vec!["com", "net", "org", "ch", "de"]; + let top = vec!["ch".to_string(), "de".to_string()]; + let result = apply_top_tlds(tlds, &top); + assert_eq!(result, vec!["ch", "de", "com", "net", "org"]); + } + + #[test] + fn test_top_tlds_missing_ignored() { + let tlds = vec!["com", "net"]; + let top = vec!["swiss".to_string()]; + let result = apply_top_tlds(tlds, &top); + assert_eq!(result, vec!["com", "net"]); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..f6d9238 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,2870 @@ +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::cli::Args; +use crate::config::Config; +use crate::config::FavoriteEntry; +use crate::lookup; +use crate::tlds::{apply_top_tlds, get_tlds_or_default, list_names, default_list_name}; +use crate::types::{DomainResult, DomainStatus, ErrorKind}; + +// note : this will be the worst shitshow of code you will probably have looked at in youre entire life +// it works and is somewhat stable but i didnt feel like sorting it into nice modules and all. +// have fun + +// names and labels +const APP_NAME: &str = "hoardom"; +const APP_DESC: &str = "Domain hoarding made less painful"; +const CLOSE_BUTTON_LABEL: &str = "[X]"; +const EXPORT_BUTTON_LABEL: &str = "[Export](F2)"; +const HELP_BUTTON_LABEL: &str = "[Help](F1)"; +const SEARCH_BUTTON_LABEL: &str = "[Search]"; +const STOP_BUTTON_LABEL: &str = "[Stop](s)"; +const CLEAR_BUTTON_LABEL: &str = "[Clear](C)"; + + +// Layout tuning constants +const TOPBAR_HEIGHT: u16 = 1; +const SEARCH_PANEL_HEIGHT: u16 = 3; +const CONTENT_MIN_HEIGHT: u16 = 5; + +const SIDEBAR_TARGET_WIDTH_PERCENT: u16 = 30; +const SIDEBAR_MIN_WIDTH: u16 = 24; +const SIDEBAR_MAX_WIDTH: u16 = 26; + +const SCRATCHPAD_TARGET_WIDTH_PERCENT: u16 = 30; +const SCRATCHPAD_MIN_WIDTH: u16 = 20; +const SCRATCHPAD_MAX_WIDTH: u16 = 32; + +const RESULTS_MIN_WIDTH: u16 = 24; + +const FAVORITES_MIN_HEIGHT: u16 = 4; +const SETTINGS_PANEL_HEIGHT: u16 = 8; +const SETTINGS_PANEL_MIN_HEIGHT: u16 = SETTINGS_PANEL_HEIGHT; +const SETTINGS_PANEL_MAX_HEIGHT: u16 = SETTINGS_PANEL_HEIGHT; + +const DROPDOWN_MAX_WIDTH: u16 = 26; +const DROPDOWN_MAX_HEIGHT: u16 = 10; +const EXPORT_POPUP_WIDTH: u16 = 82; +const EXPORT_POPUP_HEIGHT: u16 = 10; + +const MIN_UI_WIDTH: u16 = SIDEBAR_MIN_WIDTH + RESULTS_MIN_WIDTH + SCRATCHPAD_MIN_WIDTH; +const MIN_UI_HEIGHT: u16 = TOPBAR_HEIGHT + SEARCH_PANEL_HEIGHT + CONTENT_MIN_HEIGHT + 5; + +#[derive(Debug, Clone, PartialEq)] +enum Focus { + Search, + Scratchpad, + Results, + Favorites, + Settings, +} + +fn escape_csv(value: &str) -> String { + if value.contains([',', '"', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn export_favorites_txt(path: &Path, favorites: &[FavoriteEntry]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create export directory: {}", e))?; + } + let text: Vec<&str> = favorites.iter().map(|f| f.domain.as_str()).collect(); + std::fs::write(path, text.join("\n")) + .map_err(|e| format!("Failed to export favorites: {}", e)) +} + +fn export_results_csv(path: &Path, results: &[&DomainResult]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create export directory: {}", e))?; + } + + let mut lines = vec!["domain,status,details".to_string()]; + for result in results { + lines.push(format!( + "{},{},{}", + escape_csv(&result.full), + escape_csv(result.status_str()), + escape_csv(&result.note_str()), + )); + } + + std::fs::write(path, lines.join("\n")) + .map_err(|e| format!("Failed to export results: {}", e)) +} + +#[derive(Debug, Clone, PartialEq)] +enum DropdownState { + Closed, + Open(usize), // which option is highlighted +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExportMode { + FavoritesTxt, + ResultsCsv, +} + +impl ExportMode { + + fn default_file_name(self) -> &'static str { + match self { + ExportMode::FavoritesTxt => "hoardom-favorites.txt", + ExportMode::ResultsCsv => "hoardom-results.csv", + } + } + + fn toggled(self) -> Self { + match self { + ExportMode::FavoritesTxt => ExportMode::ResultsCsv, + ExportMode::ResultsCsv => ExportMode::FavoritesTxt, + } + } +} + +#[derive(Debug, Clone)] +struct ExportPopup { + mode: ExportMode, + selected_row: usize, + path: String, + cursor_pos: usize, + status: Option<String>, + status_success: bool, + confirm_overwrite: bool, + close_at: Option<Instant>, +} + +struct App { + search_input: String, + cursor_pos: usize, + results: Vec<(usize, DomainResult)>, + results_state: ListState, + favorites: Vec<FavoriteEntry>, + favorites_state: ListState, + focus: Focus, + show_unavailable: bool, + clear_on_search: bool, + tld_list_name: String, + settings_selected: Option<usize>, + show_notes_panel: bool, + last_fav_export_path: String, + last_res_export_path: String, + scratchpad: String, + scratchpad_cursor: usize, + dropdown: DropdownState, + searching: bool, + search_progress: (usize, usize), + search_started_at: Option<Instant>, + last_search_duration: Option<Duration>, + status_msg: Option<String>, + config_path: PathBuf, + can_save: bool, + cache_path: Option<PathBuf>, + force_refresh: bool, + mouse_enabled: bool, + top_tlds: Option<Vec<String>>, + only_top: Option<Vec<String>>, + imported_lists: Vec<crate::config::ImportedFilter>, + cache_settings: crate::config::CacheSettings, + verbose: bool, + delay: f64, + retries: u32, + jobs: u8, + panel_rects: PanelRects, // updated each frame for mouse hit detection + stream_rx: Option<tokio::sync::mpsc::Receiver<lookup::StreamMsg>>, + stream_task: Option<tokio::task::JoinHandle<()>>, + patch: crate::tlds::WhoisOverrides, + noretry: Vec<ErrorKind>, + should_quit: bool, + show_help: bool, + export_popup: Option<ExportPopup>, + fav_check_rx: Option<tokio::sync::mpsc::Receiver<lookup::StreamMsg>>, + fav_check_task: Option<tokio::task::JoinHandle<()>>, + checking_favorites: bool, + backups_enabled: bool, + backup_count: u32, +} + +#[derive(Debug, Clone, Default)] +struct PanelRects { + topbar: Option<Rect>, + export_button: Option<Rect>, + help_button: Option<Rect>, + help_popup: Option<Rect>, + dropdown: Option<Rect>, + search: Option<Rect>, + search_button: Option<Rect>, + cancel_button: Option<Rect>, + clear_button: Option<Rect>, + export_popup: Option<Rect>, + export_mode_favorites: Option<Rect>, + export_mode_results: Option<Rect>, + export_path: Option<Rect>, + export_cancel: Option<Rect>, + export_save: Option<Rect>, + scratchpad: Option<Rect>, + results: Option<Rect>, + favorites: Option<Rect>, + fav_check_button: Option<Rect>, + settings: Option<Rect>, +} + +impl App { + fn new(args: &Args, config: &Config, config_path: PathBuf, can_save: bool, cache_path: Option<PathBuf>, force_refresh: bool, whois_overrides: crate::tlds::WhoisOverrides, noretry: Vec<ErrorKind>) -> Self { + let tld_list_name = args + .tld_list + .as_ref() + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| config.settings.tld_list.clone()); + + Self { + search_input: String::new(), + cursor_pos: 0, + results: Vec::new(), + results_state: ListState::default(), + favorites: config.favorites.clone(), + favorites_state: ListState::default(), + focus: Focus::Search, + show_unavailable: config.settings.show_all || args.show_all, + clear_on_search: config.settings.clear_on_search, + tld_list_name, + settings_selected: Some(0), + show_notes_panel: config.settings.show_notes_panel, + last_fav_export_path: config.settings.last_fav_export_path.clone(), + last_res_export_path: config.settings.last_res_export_path.clone(), + scratchpad: config.scratchpad.clone(), + scratchpad_cursor: config.scratchpad.len(), + dropdown: DropdownState::Closed, + searching: false, + search_progress: (0, 0), + search_started_at: None, + last_search_duration: None, + status_msg: None, + config_path, + can_save, + cache_path, + force_refresh, + mouse_enabled: !args.no_mouse, + top_tlds: args.top_tlds.clone(), + only_top: args.only_top.clone(), + imported_lists: config.imported_filters.clone(), + cache_settings: config.cache.clone(), + verbose: args.verbose, + delay: args.effective_delay(), + retries: args.effective_retry(), + jobs: if args.jobs.is_some() { args.effective_jobs() } else { config.settings.jobs.max(1) }, + panel_rects: PanelRects::default(), + stream_rx: None, + stream_task: None, + patch: whois_overrides, + noretry, + should_quit: false, + show_help: false, + export_popup: None, + fav_check_rx: None, + fav_check_task: None, + checking_favorites: false, + backups_enabled: config.settings.backups, + backup_count: config.settings.backup_count, + } + } + + fn get_effective_tlds(&self) -> Vec<&'static str> { + let mut tld_vec = self.base_tlds_for_selection(); + + if let Some(ref only) = self.only_top { + tld_vec = only + .iter() + .filter(|s| !s.is_empty()) + .map(|s| -> &'static str { Box::leak(s.clone().into_boxed_str()) }) + .collect(); + } + + if let Some(ref top) = self.top_tlds { + tld_vec = apply_top_tlds(tld_vec, top); + } + + tld_vec + } + + fn base_tlds_for_selection(&self) -> Vec<&'static str> { + if let Some(tlds) = crate::tlds::get_tlds(&self.tld_list_name) { + tlds + } else if let Some(imported) = self + .imported_lists + .iter() + .find(|list| list.name == self.tld_list_name) + { + imported + .tlds + .iter() + .map(|s| -> &'static str { Box::leak(s.clone().into_boxed_str()) }) + .collect() + } else { + get_tlds_or_default(default_list_name()) + } + } + + fn list_options(&self) -> Vec<String> { + let mut options: Vec<String> = list_names().iter().map(|s| s.to_string()).collect(); + for imported in &self.imported_lists { + if !options.contains(&imported.name) { + options.push(imported.name.clone()); + } + } + options + } + + fn parsed_queries(&self) -> Vec<String> { + self.search_input + .split(|c: char| c.is_whitespace() || c == ',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .collect() + } + + fn default_export_dir() -> PathBuf { + #[cfg(debug_assertions)] + { + if let Ok(dir) = std::env::current_dir() { + return dir; + } + } + + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) + } + + fn suggested_export_path(&self, mode: ExportMode) -> String { + let last_export_path = match mode { + ExportMode::FavoritesTxt => &self.last_fav_export_path, + ExportMode::ResultsCsv => &self.last_res_export_path, + }; + + let base = if last_export_path.is_empty() { + Self::default_export_dir().join(mode.default_file_name()) + } else { + let last = PathBuf::from(last_export_path); + if last.is_dir() { + last.join(mode.default_file_name()) + } else { + last + } + }; + base.display().to_string() + } + + fn open_export_popup(&mut self) { + let mode = ExportMode::FavoritesTxt; + let path = self.suggested_export_path(mode); + self.export_popup = Some(ExportPopup { + mode, + selected_row: 0, + cursor_pos: path.len(), + path, + status: None, + status_success: false, + confirm_overwrite: false, + close_at: None, + }); + } + + fn set_export_mode(&mut self, mode: ExportMode) { + let suggested_path = self.suggested_export_path(mode); + if let Some(popup) = &mut self.export_popup { + popup.mode = mode; + popup.path = suggested_path; + popup.cursor_pos = popup.path.len(); + popup.status = None; + popup.status_success = false; + popup.confirm_overwrite = false; + popup.close_at = None; + } + } + + fn clear_results(&mut self) { + self.results.clear(); + self.results_state.select(None); + self.search_progress = (0, 0); + self.status_msg = Some("Results cleared".to_string()); + } + fn save_config(&self) { + if !self.can_save { + return; + } + let config = Config { + settings: crate::config::Settings { + tld_list: self.tld_list_name.clone(), + show_all: self.show_unavailable, + clear_on_search: self.clear_on_search, + show_notes_panel: self.show_notes_panel, + last_fav_export_path: self.last_fav_export_path.clone(), + last_res_export_path: self.last_res_export_path.clone(), + top_tlds: self.top_tlds.clone().unwrap_or_default(), + jobs: self.jobs, + noretry: self.noretry.iter().map(|k| k.to_config_str().to_string()).collect(), + backups: self.backups_enabled, + backup_count: self.backup_count, + }, + favorites: self.favorites.clone(), + imported_filters: self.imported_lists.clone(), + cache: self.cache_settings.clone(), + scratchpad: self.scratchpad.clone(), + }; + let _ = config.save(&self.config_path); + } + + fn add_favorite(&mut self, domain: &str) { + let d = domain.to_lowercase(); + if !self.favorites.iter().any(|f| f.domain == d) { + // check if we just looked this domain up - inherit its status + let status = self.results.iter() + .find(|(_, r)| r.full.to_lowercase() == d) + .map(|(_, r)| r.status_str().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let checked = if status != "unknown" { + chrono::Utc::now().to_rfc3339() + } else { + String::new() + }; + self.favorites.push(FavoriteEntry { + domain: d, + status, + checked, + changed: false, + }); + self.save_config(); + } + } + + fn remove_focused_favorite(&mut self) { + if let Some(idx) = self.favorites_state.selected() { + if idx < self.favorites.len() { + self.favorites.remove(idx); + // adjust selection + if !self.favorites.is_empty() { + if idx >= self.favorites.len() { + self.favorites_state.select(Some(self.favorites.len() - 1)); + } + } else { + self.favorites_state.select(None); + } + self.save_config(); + } + } + } + + fn visible_results(&self) -> Vec<&DomainResult> { + if self.show_unavailable { + self.results.iter().map(|(_, r)| r).collect() + } else { + self.results.iter().filter(|(_, r)| r.is_available()).map(|(_, r)| r).collect() + } + } + + fn set_tld_list_by_index(&mut self, idx: usize) { + let options = self.list_options(); + if let Some(selected) = options.get(idx) { + self.tld_list_name = selected.clone(); + } else { + self.tld_list_name = default_list_name().to_string(); + } + self.save_config(); + } + + fn tld_list_index(&self) -> usize { + self.list_options() + .iter() + .position(|option| option == &self.tld_list_name) + .unwrap_or(0) + } + + fn set_focus(&mut self, focus: Focus) { + self.focus = focus; + if self.focus == Focus::Settings && self.settings_selected.is_none() { + self.settings_selected = Some(0); + } + } + + fn panel_at(&self, col: u16, row: u16) -> Option<Focus> { + let pos = (col, row); + if let Some(r) = self.panel_rects.search { + if contains_pos(r, pos) { + return Some(Focus::Search); + } + } + if let Some(r) = self.panel_rects.scratchpad { + if contains_pos(r, pos) { + return Some(Focus::Scratchpad); + } + } + if let Some(r) = self.panel_rects.results { + if contains_pos(r, pos) { + return Some(Focus::Results); + } + } + if let Some(r) = self.panel_rects.favorites { + if contains_pos(r, pos) { + return Some(Focus::Favorites); + } + } + if let Some(r) = self.panel_rects.settings { + if contains_pos(r, pos) { + return Some(Focus::Settings); + } + } + None + } +} + +fn contains_pos(rect: Rect, pos: (u16, u16)) -> bool { + pos.0 >= rect.x + && pos.0 < rect.x + rect.width + && pos.1 >= rect.y + && pos.1 < rect.y + rect.height +} + +fn clamp_panel_size(value: u16, min: u16, max: u16) -> u16 { + value.max(min).min(max) +} + +fn export_popup_rect(area: Rect) -> Rect { + let width = EXPORT_POPUP_WIDTH.min(area.width.saturating_sub(4)); + let height = EXPORT_POPUP_HEIGHT.min(area.height.saturating_sub(4)); + Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + (area.height.saturating_sub(height)) / 2, + width, + height, + } +} + +fn help_popup_rect(area: Rect) -> Rect { + let width = if area.width > 54 { + area.width.saturating_sub(6).min(58) + } else { + area.width.saturating_sub(2) + }; + let height = if area.height > 22 { + 20 + } else { + area.height.saturating_sub(2) + }; + + Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + (area.height.saturating_sub(height)) / 2, + width, + height, + } +} + +pub async fn run_tui( + args: &Args, + config: &Config, + paths: crate::config::HoardomPaths, + cache_file: Option<PathBuf>, + force_refresh: bool, + whois_overrides: crate::tlds::WhoisOverrides, + noretry: Vec<ErrorKind>, +) -> io::Result<()> { + // terminal setup + enable_raw_mode()?; + let mut stdout = io::stdout(); + if !args.no_mouse { + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + } else { + execute!(stdout, EnterAlternateScreen)?; + } + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(args, config, paths.config_file.clone(), paths.can_save, cache_file, force_refresh, whois_overrides, noretry); + + if !paths.can_save { + app.status_msg = Some("Warning: favorites and settings wont be saved".to_string()); + } + + let result = run_app(&mut terminal, &mut app).await; + + // put the terminal back to normal + if !args.no_mouse { + execute!( + terminal.backend_mut(), + DisableMouseCapture + )?; + + while event::poll(std::time::Duration::from_millis(0))? { + let _ = event::read(); + } + + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + } else { + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + } + terminal.backend_mut().flush()?; + disable_raw_mode()?; + terminal.show_cursor()?; + + result +} + +async fn run_app( + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, + app: &mut App, +) -> io::Result<()> { + loop { + if app.should_quit { + return Ok(()); + } + + if let Some(popup) = app.export_popup.as_ref() { + if popup.close_at.is_some_and(|deadline| Instant::now() >= deadline) { + app.export_popup = None; + } + } + + // poll streaming results if a search is in progress + if let Some(ref mut rx) = app.stream_rx { + // drain all available messages without blocking + loop { + match rx.try_recv() { + Ok(lookup::StreamMsg::Result { result, sort_index }) => { + // insert in sorted position to maintain list order + let pos = app.results.partition_point(|(idx, _)| *idx < sort_index); + app.results.insert(pos, (sort_index, result)); + // auto-select first result + if app.results.len() == 1 { + app.results_state.select(Some(0)); + } + } + Ok(lookup::StreamMsg::Progress { current, total }) => { + app.search_progress = (current, total); + } + Ok(lookup::StreamMsg::Error(msg)) => { + app.status_msg = Some(format!("Error: {}", msg)); + } + Ok(lookup::StreamMsg::Done) => { + app.searching = false; + app.last_search_duration = app.search_started_at.map(|t| t.elapsed()); + app.search_started_at = None; + app.status_msg = None; + app.stream_rx = None; + app.stream_task = None; + break; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + app.searching = false; + app.last_search_duration = app.search_started_at.map(|t| t.elapsed()); + app.search_started_at = None; + app.status_msg = None; + app.stream_rx = None; + app.stream_task = None; + break; + } + } + } + } + + // poll favorites check results + if let Some(ref mut rx) = app.fav_check_rx { + loop { + match rx.try_recv() { + Ok(lookup::StreamMsg::Result { result, .. }) => { + // Update the matching favorite's status + let domain_lower = result.full.to_lowercase(); + if let Some(fav) = app.favorites.iter_mut().find(|f| f.domain == domain_lower) { + let new_status = result.status_str().to_string(); + if fav.status != new_status && fav.status != "unknown" { + fav.changed = true; + } + fav.status = new_status; + fav.checked = chrono::Utc::now().to_rfc3339(); + } + } + Ok(lookup::StreamMsg::Done) => { + app.checking_favorites = false; + app.fav_check_rx = None; + app.fav_check_task = None; + app.save_config(); + break; + } + Ok(_) => {} // progress/error - dont care for fav checks + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + app.checking_favorites = false; + app.fav_check_rx = None; + app.fav_check_task = None; + app.save_config(); + break; + } + } + } + } + + terminal.draw(|f| draw_ui(f, app))?; + + // poll for events with a timeout so we can update during searches + if event::poll(std::time::Duration::from_millis(50))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if matches!(key.code, KeyCode::F(1)) { + app.show_help = !app.show_help; + continue; + } + + if matches!(key.code, KeyCode::F(2)) { + if app.export_popup.is_some() { + app.export_popup = None; + } else { + app.open_export_popup(); + } + continue; + } + + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c')) + { + quit_app(app); + continue; + } + + if app.export_popup.is_some() { + handle_export_popup_key(app, key.code); + continue; + } + + if app.searching && matches!(key.code, KeyCode::Char('s' | 'S')) { + cancel_search(app); + continue; + } + + if !app.searching + && !app.clear_on_search + && matches!(key.code, KeyCode::Char('C')) + { + app.clear_results(); + continue; + } + + // close dropdown on any key if its open (unless its the dropdown interaction) + if app.dropdown != DropdownState::Closed && app.focus != Focus::Settings { + app.dropdown = DropdownState::Closed; + } + + match key.code { + KeyCode::Esc => { + if app.show_help { + app.show_help = false; + continue; + } + handle_escape_for_panel(app); + continue; + } + // tab between panels + KeyCode::Tab => { + app.dropdown = DropdownState::Closed; + app.set_focus(match app.focus { + Focus::Search => { + if app.show_notes_panel { Focus::Scratchpad } else { Focus::Results } + } + Focus::Scratchpad => Focus::Results, + Focus::Results => Focus::Favorites, + Focus::Favorites => Focus::Settings, + Focus::Settings => Focus::Search, + }); + } + KeyCode::BackTab => { + app.dropdown = DropdownState::Closed; + app.set_focus(match app.focus { + Focus::Search => Focus::Settings, + Focus::Scratchpad => Focus::Search, + Focus::Results => { + if app.show_notes_panel { Focus::Scratchpad } else { Focus::Search } + } + Focus::Favorites => Focus::Results, + Focus::Settings => Focus::Favorites, + }); + } + _ => { + handle_key_for_panel(app, key.code).await; + } + } + } + Event::Mouse(mouse) => { + if app.mouse_enabled { + handle_mouse(app, mouse); + } + } + _ => {} + } + } + } +} + +async fn handle_key_for_panel(app: &mut App, key: KeyCode) { + match app.focus { + Focus::Search => handle_search_key(app, key).await, + Focus::Scratchpad => handle_scratchpad_key(app, key), + Focus::Results => handle_results_key(app, key), + Focus::Favorites => handle_favorites_key(app, key), + Focus::Settings => handle_settings_key(app, key), + } +} + +fn run_export(app: &mut App) { + let Some(popup) = app.export_popup.as_ref() else { + return; + }; + + let mode = popup.mode; + let path_text = popup.path.clone(); + let path = PathBuf::from(&path_text); + + if path.is_file() && !popup.confirm_overwrite { + if let Some(popup) = app.export_popup.as_mut() { + popup.status = Some("File exists. Press Enter again to overwrite.".to_string()); + popup.status_success = false; + popup.confirm_overwrite = true; + popup.close_at = None; + } + return; + } + + let result = match mode { + ExportMode::FavoritesTxt => export_favorites_txt(&path, &app.favorites), + ExportMode::ResultsCsv => { + let visible = app.visible_results(); + if visible.is_empty() { + Err("No results to export".to_string()) + } else { + export_results_csv(&path, &visible) + } + } + }; + + match result { + Ok(()) => { + match mode { + ExportMode::FavoritesTxt => app.last_fav_export_path = path_text.clone(), + ExportMode::ResultsCsv => app.last_res_export_path = path_text.clone(), + } + if let Some(popup) = app.export_popup.as_mut() { + popup.status = Some("Success".to_string()); + popup.status_success = true; + popup.confirm_overwrite = false; + popup.close_at = Some(Instant::now() + Duration::from_secs(2)); + } + app.save_config(); + } + Err(err) => { + if let Some(popup) = app.export_popup.as_mut() { + popup.status = Some(err); + popup.status_success = false; + popup.confirm_overwrite = false; + popup.close_at = None; + } + } + } +} + +fn handle_export_popup_key(app: &mut App, key: KeyCode) { + let Some(popup) = app.export_popup.as_mut() else { + return; + }; + + match key { + KeyCode::Esc => { + app.export_popup = None; + } + KeyCode::Tab | KeyCode::Down => { + popup.selected_row = (popup.selected_row + 1) % 4; + } + KeyCode::BackTab | KeyCode::Up => { + popup.selected_row = if popup.selected_row == 0 { 3 } else { popup.selected_row - 1 }; + } + KeyCode::Left => { + if popup.selected_row == 0 { + let mode = popup.mode.toggled(); + app.set_export_mode(mode); + } else if popup.selected_row == 3 { + popup.selected_row = 2; + } else if popup.selected_row == 1 && popup.cursor_pos > 0 { + popup.cursor_pos -= 1; + } + } + KeyCode::Right => { + if popup.selected_row == 0 { + let mode = popup.mode.toggled(); + app.set_export_mode(mode); + } else if popup.selected_row == 2 { + popup.selected_row = 3; + } else if popup.selected_row == 1 && popup.cursor_pos < popup.path.len() { + popup.cursor_pos += 1; + } + } + KeyCode::Home => { + if popup.selected_row == 1 { + popup.cursor_pos = 0; + } + } + KeyCode::End => { + if popup.selected_row == 1 { + popup.cursor_pos = popup.path.len(); + } + } + KeyCode::Backspace => { + if popup.selected_row == 1 && popup.cursor_pos > 0 { + popup.cursor_pos -= 1; + popup.path.remove(popup.cursor_pos); + popup.status = None; + popup.status_success = false; + popup.confirm_overwrite = false; + popup.close_at = None; + } + } + KeyCode::Delete => { + if popup.selected_row == 1 && popup.cursor_pos < popup.path.len() { + popup.path.remove(popup.cursor_pos); + popup.status = None; + popup.status_success = false; + popup.confirm_overwrite = false; + popup.close_at = None; + } + } + KeyCode::Char(c) => { + if popup.selected_row == 1 { + popup.path.insert(popup.cursor_pos, c); + popup.cursor_pos += 1; + popup.status = None; + popup.status_success = false; + popup.confirm_overwrite = false; + popup.close_at = None; + } + } + KeyCode::Enter => match popup.selected_row { + 0 => { + let mode = popup.mode.toggled(); + app.set_export_mode(mode); + } + 1 => run_export(app), + 2 => app.export_popup = None, + 3 => run_export(app), + _ => {} + }, + _ => {} + } +} + +fn handle_scratchpad_key(app: &mut App, key: KeyCode) { + match key { + KeyCode::Char(c) => { + app.scratchpad.insert(app.scratchpad_cursor, c); + app.scratchpad_cursor += 1; + app.save_config(); + } + KeyCode::Enter => { + app.scratchpad.insert(app.scratchpad_cursor, '\n'); + app.scratchpad_cursor += 1; + app.save_config(); + } + KeyCode::Backspace => { + if app.scratchpad_cursor > 0 { + app.scratchpad_cursor -= 1; + app.scratchpad.remove(app.scratchpad_cursor); + app.save_config(); + } + } + KeyCode::Delete => { + if app.scratchpad_cursor < app.scratchpad.len() { + app.scratchpad.remove(app.scratchpad_cursor); + app.save_config(); + } + } + KeyCode::Left => { + if app.scratchpad_cursor > 0 { + app.scratchpad_cursor -= 1; + } + } + KeyCode::Right => { + if app.scratchpad_cursor < app.scratchpad.len() { + app.scratchpad_cursor += 1; + } + } + KeyCode::Up => { + move_scratchpad_cursor_vertical(app, -1); + } + KeyCode::Down => { + move_scratchpad_cursor_vertical(app, 1); + } + KeyCode::Home => { + app.scratchpad_cursor = 0; + } + KeyCode::End => { + app.scratchpad_cursor = app.scratchpad.len(); + } + _ => {} + } +} + +async fn handle_search_key(app: &mut App, key: KeyCode) { + if app.searching { + return; + } + match key { + KeyCode::Enter => { + if !app.search_input.is_empty() && !app.searching { + start_search(app); + } + } + KeyCode::Char(c) => { + // only allow valid domain chars (alphanumeric, hyphen, dot, space for multi query) + if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == ' ' { + app.search_input.insert(app.cursor_pos, c); + app.cursor_pos += c.len_utf8(); + } + } + KeyCode::Backspace => { + if app.cursor_pos > 0 { + // Find the previous char boundary + let prev = app.search_input[..app.cursor_pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + app.search_input.remove(prev); + app.cursor_pos = prev; + } + } + KeyCode::Delete => { + if app.cursor_pos < app.search_input.len() && app.search_input.is_char_boundary(app.cursor_pos) { + app.search_input.remove(app.cursor_pos); + } + } + KeyCode::Left => { + if app.cursor_pos > 0 { + app.cursor_pos = app.search_input[..app.cursor_pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + } + KeyCode::Right => { + if app.cursor_pos < app.search_input.len() { + app.cursor_pos = app.search_input[app.cursor_pos..] + .char_indices() + .nth(1) + .map(|(i, _)| app.cursor_pos + i) + .unwrap_or(app.search_input.len()); + } + } + KeyCode::Home => { + app.cursor_pos = 0; + } + KeyCode::End => { + app.cursor_pos = app.search_input.len(); + } + _ => {} + } +} + +fn handle_results_key(app: &mut App, key: KeyCode) { + let visible = app.visible_results(); + let len = visible.len(); + if len == 0 { + return; + } + + match key { + KeyCode::Up => { + let i = match app.results_state.selected() { + Some(i) => { + if i > 0 { i - 1 } else { 0 } + } + None => 0, + }; + app.results_state.select(Some(i)); + } + KeyCode::Down => { + let i = match app.results_state.selected() { + Some(i) => { + if i + 1 < len { i + 1 } else { i } + } + None => 0, + }; + app.results_state.select(Some(i)); + } + KeyCode::Enter => { + // add focused domain to favorites + if let Some(idx) = app.results_state.selected() { + let visible = app.visible_results(); + if let Some(result) = visible.get(idx) { + let domain = result.full.clone(); + app.add_favorite(&domain); + } + } + } + _ => {} + } +} + +fn handle_favorites_key(app: &mut App, key: KeyCode) { + let len = app.favorites.len(); + if len == 0 { + return; + } + + match key { + KeyCode::Up => { + let i = match app.favorites_state.selected() { + Some(i) => { + if i > 0 { i - 1 } else { 0 } + } + None => 0, + }; + app.favorites_state.select(Some(i)); + } + KeyCode::Down => { + let i = match app.favorites_state.selected() { + Some(i) => { + if i + 1 < len { i + 1 } else { i } + } + None => 0, + }; + app.favorites_state.select(Some(i)); + } + KeyCode::Enter => { + // acknowledge status change - clear the ! marker + if let Some(idx) = app.favorites_state.selected() { + if let Some(fav) = app.favorites.get_mut(idx) { + if fav.changed { + fav.changed = false; + app.save_config(); + } + } + } + } + KeyCode::Backspace | KeyCode::Delete => { + app.remove_focused_favorite(); + } + KeyCode::Char('c') | KeyCode::Char('C') => { + start_fav_check(app); + } + _ => {} + } +} + +fn handle_settings_key(app: &mut App, key: KeyCode) { + match &app.dropdown { + DropdownState::Open(current) => { + let current = *current; + let option_count = app.list_options().len(); + match key { + KeyCode::Up => { + let new = if option_count == 0 { + 0 + } else if current > 0 { + current - 1 + } else { + option_count - 1 + }; + app.dropdown = DropdownState::Open(new); + } + KeyCode::Down => { + let new = if option_count == 0 { + 0 + } else if current + 1 < option_count { + current + 1 + } else { + 0 + }; + app.dropdown = DropdownState::Open(new); + } + KeyCode::Enter => { + app.set_tld_list_by_index(current); + app.dropdown = DropdownState::Closed; + app.settings_selected = Some(0); + } + KeyCode::Esc => { + app.dropdown = DropdownState::Closed; + } + _ => {} + } + } + DropdownState::Closed => { + match key { + KeyCode::Up => { + app.settings_selected = Some(match app.settings_selected.unwrap_or(0) { + 0 => 4, + n => n - 1, + }); + } + KeyCode::Down => { + app.settings_selected = Some(match app.settings_selected.unwrap_or(0) { + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + _ => 0, + }); + } + KeyCode::Enter => { + match app.settings_selected.unwrap_or(0) { + 0 => { + app.dropdown = DropdownState::Open(app.tld_list_index()); + } + 1 => { + app.show_unavailable = !app.show_unavailable; + app.save_config(); + } + 2 => { + app.show_notes_panel = !app.show_notes_panel; + if !app.show_notes_panel && app.focus == Focus::Scratchpad { + app.set_focus(Focus::Results); + } + app.save_config(); + } + 3 => { + app.clear_on_search = !app.clear_on_search; + app.save_config(); + } + 4 => { + // increment jobs (wrap at 99 -> 1) + app.jobs = if app.jobs >= 99 { 1 } else { app.jobs + 1 }; + app.save_config(); + } + _ => {} + } + } + KeyCode::Char(' ') => { + match app.settings_selected.unwrap_or(0) { + 1 => { + app.show_unavailable = !app.show_unavailable; + app.save_config(); + } + 2 => { + app.show_notes_panel = !app.show_notes_panel; + if !app.show_notes_panel && app.focus == Focus::Scratchpad { + app.set_focus(Focus::Results); + } + app.save_config(); + } + 3 => { + app.clear_on_search = !app.clear_on_search; + app.save_config(); + } + _ => {} + } + } + KeyCode::Char('+') | KeyCode::Char('=') => { + if app.settings_selected == Some(4) { + app.jobs = if app.jobs >= 99 { 99 } else { app.jobs + 1 }; + app.save_config(); + } + } + KeyCode::Char('-') => { + if app.settings_selected == Some(4) { + app.jobs = if app.jobs <= 1 { 1 } else { app.jobs - 1 }; + app.save_config(); + } + } + KeyCode::Left => { + if app.settings_selected == Some(4) { + app.jobs = if app.jobs <= 1 { 1 } else { app.jobs - 1 }; + app.save_config(); + } + } + KeyCode::Right => { + if app.settings_selected == Some(4) { + app.jobs = if app.jobs >= 99 { 99 } else { app.jobs + 1 }; + app.save_config(); + } + } + _ => {} + } + } + } +} + +fn handle_mouse(app: &mut App, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + let col = mouse.column; + let row = mouse.row; + + if let Some(help_popup) = app.panel_rects.help_popup { + if !contains_pos(help_popup, (col, row)) { + app.show_help = false; + } + return; + } + + if let Some(export_popup) = app.panel_rects.export_popup { + if !contains_pos(export_popup, (col, row)) { + app.export_popup = None; + return; + } + + if let Some(mode_rect) = app.panel_rects.export_mode_favorites { + if contains_pos(mode_rect, (col, row)) { + app.set_export_mode(ExportMode::FavoritesTxt); + if let Some(popup) = app.export_popup.as_mut() { + popup.selected_row = 0; + } + return; + } + } + + if let Some(mode_rect) = app.panel_rects.export_mode_results { + if contains_pos(mode_rect, (col, row)) { + app.set_export_mode(ExportMode::ResultsCsv); + if let Some(popup) = app.export_popup.as_mut() { + popup.selected_row = 0; + } + return; + } + } + + if let Some(path_rect) = app.panel_rects.export_path { + if contains_pos(path_rect, (col, row)) { + if let Some(popup) = app.export_popup.as_mut() { + popup.selected_row = 1; + let clicked = col.saturating_sub(path_rect.x + 1) as usize; + popup.cursor_pos = clicked.min(popup.path.len()); + } + return; + } + } + + if let Some(cancel_rect) = app.panel_rects.export_cancel { + if contains_pos(cancel_rect, (col, row)) { + app.export_popup = None; + return; + } + } + + if let Some(save_rect) = app.panel_rects.export_save { + if contains_pos(save_rect, (col, row)) { + if let Some(popup) = app.export_popup.as_mut() { + popup.selected_row = 3; + } + run_export(app); + return; + } + } + + return; + } + + if let Some(dropdown_rect) = app.panel_rects.dropdown { + if contains_pos(dropdown_rect, (col, row)) { + let item_row = row.saturating_sub(dropdown_rect.y + 1) as usize; + let options = app.list_options(); + if item_row < options.len() { + app.set_tld_list_by_index(item_row); + } + app.dropdown = DropdownState::Closed; + return; + } + + app.dropdown = DropdownState::Closed; + app.panel_rects.dropdown = None; + } + + if let Some(topbar) = app.panel_rects.topbar { + if contains_pos(topbar, (col, row)) { + let close_end = topbar.x + 2; + if col <= close_end { + return; + } + if let Some(export_button) = app.panel_rects.export_button { + if contains_pos(export_button, (col, row)) { + app.open_export_popup(); + return; + } + } + if let Some(help_button) = app.panel_rects.help_button { + if contains_pos(help_button, (col, row)) { + app.show_help = !app.show_help; + return; + } + } + return; + } + } + + if let Some(search_rect) = app.panel_rects.search { + if contains_pos(search_rect, (col, row)) { + app.set_focus(Focus::Search); + + if let Some(search_button) = app.panel_rects.search_button { + if contains_pos(search_button, (col, row)) { + if !app.searching && !app.search_input.is_empty() { + start_search(app); + } + return; + } + } + + if let Some(cancel_button) = app.panel_rects.cancel_button { + if contains_pos(cancel_button, (col, row)) { + if app.searching { + cancel_search(app); + } + return; + } + } + + if let Some(clear_button) = app.panel_rects.clear_button { + if contains_pos(clear_button, (col, row)) { + app.clear_results(); + return; + } + } + + return; + } + } + + if let Some(scratchpad_rect) = app.panel_rects.scratchpad { + if contains_pos(scratchpad_rect, (col, row)) { + app.set_focus(Focus::Scratchpad); + app.scratchpad_cursor = app.scratchpad.len(); + return; + } + } + + // check if clicking inside settings panel for interactive elements + if let Some(settings_rect) = app.panel_rects.settings { + if contains_pos(settings_rect, (col, row)) { + app.set_focus(Focus::Settings); + + // row offsets within settings panel (1 = border) + let local_row = row.saturating_sub(settings_rect.y + 1); + + if app.dropdown == DropdownState::Closed { + // row 0 = TLD list line, row 1 = checkbox line + if local_row == 0 { + app.settings_selected = Some(0); + // open TLD dropdown + app.dropdown = DropdownState::Open(app.tld_list_index()); + } else if local_row == 1 { + app.settings_selected = Some(1); + // toggle show unavailable checkbox + app.show_unavailable = !app.show_unavailable; + app.save_config(); + } else if local_row == 2 { + app.settings_selected = Some(2); + app.show_notes_panel = !app.show_notes_panel; + if !app.show_notes_panel && app.focus == Focus::Scratchpad { + app.set_focus(Focus::Results); + } + app.save_config(); + } else if local_row == 3 { + app.settings_selected = Some(3); + app.clear_on_search = !app.clear_on_search; + app.save_config(); + } else if local_row == 4 { + app.settings_selected = Some(4); + // clicking on jobs row increments (use keyboard -/+ for fine control) + app.jobs = if app.jobs >= 99 { 1 } else { app.jobs + 1 }; + app.save_config(); + } + } + return; + } + } + + // check if clicking inside results panel if so select that row + if let Some(results_rect) = app.panel_rects.results { + if contains_pos(results_rect, (col, row)) { + app.set_focus(Focus::Results); + let visible_len = app.visible_results().len(); + let content_start = results_rect.y + 1; + let progress_offset = if app.searching && app.search_progress.1 > 0 { 1 } else { 0 }; + let header_offset = if visible_len > 0 { 1 } else { 0 }; + let list_start = content_start + progress_offset + header_offset; + + if row < list_start { + return; + } + + let clicked_idx = row.saturating_sub(list_start) as usize; + if clicked_idx < visible_len { + app.results_state.select(Some(clicked_idx)); + } + return; + } + } + + // check if clicking the fav check button + if let Some(btn_rect) = app.panel_rects.fav_check_button { + if contains_pos(btn_rect, (col, row)) { + start_fav_check(app); + return; + } + } + + // check if clicking inside favorites panel - select that row + if let Some(fav_rect) = app.panel_rects.favorites { + if contains_pos(fav_rect, (col, row)) { + app.set_focus(Focus::Favorites); + let content_start = fav_rect.y + 1; + let clicked_idx = (row.saturating_sub(content_start)) as usize; + if clicked_idx < app.favorites.len() { + app.favorites_state.select(Some(clicked_idx)); + } + return; + } + } + + // default: just switch focus to clicked panel + if let Some(panel) = app.panel_at(col, row) { + app.set_focus(panel); + } + } + MouseEventKind::Up(MouseButton::Left) => { + let col = mouse.column; + let row = mouse.row; + + if let Some(topbar) = app.panel_rects.topbar { + if contains_pos(topbar, (col, row)) { + let close_end = topbar.x + 2; + if col <= close_end { + quit_app(app); + return; + } + } + } + } + MouseEventKind::ScrollUp => { + // scroll up in focused list + match app.focus { + Focus::Results => { + if let Some(i) = app.results_state.selected() { + if i > 0 { + app.results_state.select(Some(i - 1)); + } + } + } + Focus::Favorites => { + if let Some(i) = app.favorites_state.selected() { + if i > 0 { + app.favorites_state.select(Some(i - 1)); + } + } + } + _ => {} + } + } + MouseEventKind::ScrollDown => { + // scroll down in focused list + match app.focus { + Focus::Results => { + let len = app.visible_results().len(); + if let Some(i) = app.results_state.selected() { + if i + 1 < len { + app.results_state.select(Some(i + 1)); + } + } else if len > 0 { + app.results_state.select(Some(0)); + } + } + Focus::Favorites => { + let len = app.favorites.len(); + if let Some(i) = app.favorites_state.selected() { + if i + 1 < len { + app.favorites_state.select(Some(i + 1)); + } + } else if len > 0 { + app.favorites_state.select(Some(0)); + } + } + _ => {} + } + } + _ => {} + } +} + +fn cancel_search(app: &mut App) { + if let Some(handle) = app.stream_task.take() { + handle.abort(); + } + app.stream_rx = None; + app.searching = false; + app.search_progress = (0, 0); + app.search_started_at = None; + app.last_search_duration = None; + app.status_msg = Some("Search canceled".to_string()); +} + +fn quit_app(app: &mut App) { + if let Some(handle) = app.stream_task.take() { + handle.abort(); + } + if let Some(handle) = app.fav_check_task.take() { + handle.abort(); + } + app.stream_rx = None; + app.fav_check_rx = None; + + // backup on shutdown if enabled + if app.can_save && app.backups_enabled { + let _ = Config::create_backup(&app.config_path, app.backup_count); + } + + app.should_quit = true; +} + +/// kick off checking all favorites availability in the bg +fn start_fav_check(app: &mut App) { + if app.checking_favorites || app.favorites.is_empty() { + return; + } + + // Cancel any previous fav check + if let Some(handle) = app.fav_check_task.take() { + handle.abort(); + } + app.fav_check_rx = None; + + app.checking_favorites = true; + + // Build a batch: each favorite is "name.tld" -> lookup (name, [tld]) + let batches: lookup::LookupBatch = app.favorites.iter().filter_map(|fav| { + let parts: Vec<&str> = fav.domain.splitn(2, '.').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), vec![parts[1].to_string()])) + } else { + None + } + }).collect(); + + if batches.is_empty() { + app.checking_favorites = false; + return; + } + + let stream = lookup::lookup_many_streaming( + batches, + app.delay, + app.retries, + app.verbose, + app.cache_path.clone(), + false, // dont force refresh + app.jobs, + app.patch.clone(), + app.noretry.clone(), + ); + + app.fav_check_task = Some(stream.handle); + app.fav_check_rx = Some(stream.receiver); +} + +fn handle_escape_for_panel(app: &mut App) { + if app.dropdown != DropdownState::Closed { + app.dropdown = DropdownState::Closed; + return; + } + + match app.focus { + Focus::Scratchpad => {} + Focus::Results => { + app.results_state.select(None); + } + Focus::Favorites => { + app.favorites_state.select(None); + } + Focus::Settings => { + app.settings_selected = None; + } + Focus::Search => {} + } +} + +fn start_search(app: &mut App) { + if app.searching { + cancel_search(app); + } + + let search_terms = app.parsed_queries(); + if search_terms.is_empty() { + return; + } + + app.searching = true; + if app.clear_on_search { + app.results.clear(); + app.results_state.select(None); + } + app.search_progress = (0, 0); + app.search_started_at = Some(Instant::now()); + app.last_search_duration = None; + app.status_msg = Some("Searching...".to_string()); + + let default_tlds = app.get_effective_tlds(); + if default_tlds.is_empty() { + app.status_msg = Some("No TLDs to search".to_string()); + app.searching = false; + return; + } + + let search_batches: lookup::LookupBatch = search_terms + .into_iter() + .map(|term| { + if term.contains('.') { + let mut parts = term.splitn(2, '.'); + let name = parts.next().unwrap_or_default().to_string(); + let tld = parts.next().unwrap_or_default().to_string(); + (name, vec![tld]) + } else { + ( + term, + default_tlds.iter().map(|tld| (*tld).to_string()).collect(), + ) + } + }) + .filter(|(name, tlds)| !name.is_empty() && !tlds.is_empty()) + .collect(); + + if search_batches.is_empty() { + app.status_msg = Some("No valid search terms".to_string()); + app.searching = false; + return; + } + + let stream = lookup::lookup_many_streaming( + search_batches, + app.delay, + app.retries, + app.verbose, + app.cache_path.clone(), + app.force_refresh, + app.jobs, + app.patch.clone(), + app.noretry.clone(), + ); + + // only force refresh on first search + app.force_refresh = false; + app.stream_task = Some(stream.handle); + app.stream_rx = Some(stream.receiver); +} + +fn draw_ui(f: &mut Frame, app: &mut App) { + let size = f.area(); + + if terminal_too_small(size) { + app.panel_rects = PanelRects::default(); + draw_terminal_too_small(f, size); + return; + } + + // main layout: top bar + content area + search bar at bottom + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(TOPBAR_HEIGHT), + Constraint::Min(CONTENT_MIN_HEIGHT), + Constraint::Length(SEARCH_PANEL_HEIGHT), + ]) + .split(size); + + let content_area = main_chunks[1]; + let desired_sidebar = content_area.width.saturating_mul(SIDEBAR_TARGET_WIDTH_PERCENT) / 100; + let mut sidebar_width = clamp_panel_size(desired_sidebar, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH) + .min(content_area.width.saturating_sub(RESULTS_MIN_WIDTH)); + if sidebar_width == 0 { + sidebar_width = SIDEBAR_MIN_WIDTH.min(content_area.width); + } + + let sidebar_chunk = Rect { + x: content_area.x + content_area.width.saturating_sub(sidebar_width), + y: content_area.y, + width: sidebar_width, + height: content_area.height, + }; + + let (scratchpad_chunk, results_chunk) = if app.show_notes_panel { + let center_width = content_area.width.saturating_sub(sidebar_width); + let desired_scratchpad = content_area.width.saturating_mul(SCRATCHPAD_TARGET_WIDTH_PERCENT) / 100; + let mut scratchpad_width = clamp_panel_size( + desired_scratchpad, + SCRATCHPAD_MIN_WIDTH, + SCRATCHPAD_MAX_WIDTH, + ) + .min(center_width.saturating_sub(RESULTS_MIN_WIDTH)); + if scratchpad_width == 0 { + scratchpad_width = SCRATCHPAD_MIN_WIDTH.min(center_width); + } + + ( + Some(Rect { + x: content_area.x, + y: content_area.y, + width: scratchpad_width, + height: content_area.height, + }), + Rect { + x: content_area.x + scratchpad_width, + y: content_area.y, + width: center_width.saturating_sub(scratchpad_width), + height: content_area.height, + }, + ) + } else { + ( + None, + Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width.saturating_sub(sidebar_width), + height: content_area.height, + }, + ) + }; + + let settings_height = clamp_panel_size( + SETTINGS_PANEL_HEIGHT, + SETTINGS_PANEL_MIN_HEIGHT, + SETTINGS_PANEL_MAX_HEIGHT, + ) + .min(sidebar_chunk.height.saturating_sub(FAVORITES_MIN_HEIGHT)); + let settings_chunk = Rect { + x: sidebar_chunk.x, + y: sidebar_chunk.y + sidebar_chunk.height.saturating_sub(settings_height), + width: sidebar_chunk.width, + height: settings_height, + }; + let favorites_chunk = Rect { + x: sidebar_chunk.x, + y: sidebar_chunk.y, + width: sidebar_chunk.width, + height: sidebar_chunk.height.saturating_sub(settings_height), + }; + + // store rects for mouse detection + app.panel_rects.topbar = Some(main_chunks[0]); + let help_width = HELP_BUTTON_LABEL.chars().count() as u16; + let export_width = EXPORT_BUTTON_LABEL.chars().count() as u16; + let help_x = main_chunks[0].x + main_chunks[0].width.saturating_sub(help_width); + let export_x = help_x.saturating_sub(export_width + 1); + app.panel_rects.export_button = Some(Rect { + x: export_x, + y: main_chunks[0].y, + width: export_width, + height: 1, + }); + app.panel_rects.help_button = Some(Rect { + x: help_x, + y: main_chunks[0].y, + width: help_width, + height: 1, + }); + app.panel_rects.dropdown = None; + app.panel_rects.help_popup = None; + app.panel_rects.search_button = None; + app.panel_rects.cancel_button = None; + app.panel_rects.clear_button = None; + app.panel_rects.export_popup = None; + app.panel_rects.export_mode_favorites = None; + app.panel_rects.export_mode_results = None; + app.panel_rects.export_path = None; + app.panel_rects.export_cancel = None; + app.panel_rects.export_save = None; + app.panel_rects.scratchpad = scratchpad_chunk; + app.panel_rects.results = Some(results_chunk); + app.panel_rects.favorites = Some(favorites_chunk); + app.panel_rects.settings = Some(settings_chunk); + app.panel_rects.search = Some(main_chunks[2]); + + // draw each panel + draw_topbar(f, main_chunks[0]); + if let Some(scratchpad_rect) = scratchpad_chunk { + draw_scratchpad(f, app, scratchpad_rect); + } + draw_results(f, app, results_chunk); + draw_favorites(f, app, favorites_chunk); + draw_settings(f, app, settings_chunk); + draw_search(f, app, main_chunks[2]); + + // draw dropdown overlay if open + if let DropdownState::Open(selected) = &app.dropdown { + draw_dropdown(f, app, settings_chunk, *selected); + } + + if app.show_help { + draw_help_overlay(f, app, size); + } + + if app.export_popup.is_some() { + draw_export_popup(f, app, size); + } +} + +fn terminal_too_small(area: Rect) -> bool { + area.width < MIN_UI_WIDTH || area.height < MIN_UI_HEIGHT +} + +fn draw_terminal_too_small(f: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" hoardom "); + + let inner = block.inner(area); + f.render_widget(block, area); + let content_width = inner.width as usize; + + let text = vec![ + Line::from(Span::styled( + fit_cell_center("HELP ! HELP ! HELP !", content_width), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + fit_cell_center("I AM BEING CRUSHED!", content_width), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + Line::from(fit_cell_center("", content_width)), + Line::from(Span::styled( + fit_cell_center("Im claustrophobic! :'(", content_width), + Style::default().fg(Color::White), + )), + Line::from(Span::styled( + fit_cell_center(&format!("Need {}x{} of space", MIN_UI_WIDTH, MIN_UI_HEIGHT), content_width), + Style::default().fg(Color::White), + )), + Line::from(Span::styled( + fit_cell_center(&format!("Current: {}x{}", area.width, area.height), content_width), + Style::default().fg(Color::DarkGray), + )), + Line::from(fit_cell_center("", content_width)), + Line::from(Span::styled( + fit_cell_center("REFUSING TO WORK TILL YOU", content_width), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + fit_cell_center("GIVE ME BACK MY SPACE! >:(", content_width), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + ]; + + f.render_widget(Paragraph::new(text), inner); +} + +fn draw_topbar(f: &mut Frame, area: Rect) { + let title = format!("{} - {}", APP_NAME, APP_DESC); + let width = area.width as usize; + let left = format!("{} {}", CLOSE_BUTTON_LABEL, title); + let right = format!("{} {}", EXPORT_BUTTON_LABEL, HELP_BUTTON_LABEL); + let gap = width.saturating_sub(left.chars().count() + right.chars().count()); + let paragraph = Paragraph::new(Line::from(vec![ + Span::styled(CLOSE_BUTTON_LABEL, Style::default().fg(Color::Red).bg(Color::Gray).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", title), Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(" ".repeat(gap), Style::default().bg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(EXPORT_BUTTON_LABEL, Style::default().fg(Color::LightGreen).bg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(" ", Style::default().bg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(HELP_BUTTON_LABEL, Style::default().fg(Color::LightGreen).bg(Color::Red).add_modifier(Modifier::BOLD)), + ])) + .style(Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD)); + f.render_widget(paragraph, area); +} + +// Help Overlay size is here you goofus +fn draw_help_overlay(f: &mut Frame, app: &mut App, area: Rect) { + let popup = help_popup_rect(area); + app.panel_rects.help_popup = Some(popup); + + let text = vec![ + Line::from(Span::styled(" ", Style::default().fg(Color::White))), + Line::from(Span::styled("Global :", Style::default().fg(Color::White))), + Line::from(Span::styled("F1 or Help button Toggle this help", Style::default().fg(Color::White))), + Line::from(Span::styled("F2 or Export button Open export popup", Style::default().fg(Color::White))), + Line::from(Span::styled("Ctrl+C Quit the app", Style::default().fg(Color::White))), + Line::from(Span::styled("s Stop/cancel running search", Style::default().fg(Color::White))), + Line::from(Span::styled("Esc Clear selection or close help", Style::default().fg(Color::White))), + Line::from(Span::styled("Tab or Shift+Tab Move between panels", Style::default().fg(Color::White))), + Line::from(Span::styled("Up and Down arrows Navigate results", Style::default().fg(Color::White))), + Line::from(Span::styled(" ", Style::default().fg(Color::White))), + Line::from(Span::styled("Mouse Click Elements duh", Style::default().fg(Color::White))), + Line::from(Span::styled("Scrolling Scroll through elements (yea)", Style::default().fg(Color::White))), + + Line::from(Span::styled(" ", Style::default().fg(Color::White))), + Line::from(Span::styled("In Results :", Style::default().fg(Color::White))), + Line::from(Span::styled("Enter Add highlighted result to Favorites", Style::default().fg(Color::White))), + Line::from(Span::styled("In Favorites :", Style::default().fg(Color::White))), + Line::from(Span::styled("Backspace or Delete Remove focused favorite", Style::default().fg(Color::White))), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Help "); + + f.render_widget(Clear, popup); + f.render_widget(Paragraph::new(text).block(block), popup); +} + +fn draw_export_popup(f: &mut Frame, app: &mut App, area: Rect) { + let Some(popup_state) = app.export_popup.as_ref() else { + return; + }; + + let popup = export_popup_rect(area); + app.panel_rects.export_popup = Some(popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Export "); + + let inner = block.inner(popup); + f.render_widget(Clear, popup); + f.render_widget(block, popup); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(inner); + + let mode_style = |mode: ExportMode| { + let mut style = Style::default().fg(Color::White); + if popup_state.mode == mode { + style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD); + } else if popup_state.selected_row == 0 { + style = style.add_modifier(Modifier::BOLD); + } + style + }; + + let subtitle = fit_cell_center("Choose what to export and where to save it.", chunks[0].width as usize); + f.render_widget( + Paragraph::new(subtitle).style(Style::default().fg(Color::DarkGray)), + chunks[0], + ); + + let favorites_label = "[Favorites TXT]"; + let results_label = "[Results CSV]"; + let mode_spacing = " "; + let mode_text = format!("{}{}{}", favorites_label, mode_spacing, results_label); + let mode_pad = chunks[1] + .width + .saturating_sub(mode_text.chars().count() as u16) + / 2; + let mode_x = chunks[1].x + mode_pad; + app.panel_rects.export_mode_favorites = Some(Rect { + x: mode_x, + y: chunks[1].y, + width: favorites_label.chars().count() as u16, + height: 1, + }); + app.panel_rects.export_mode_results = Some(Rect { + x: mode_x + favorites_label.chars().count() as u16 + mode_spacing.chars().count() as u16, + y: chunks[1].y, + width: results_label.chars().count() as u16, + height: 1, + }); + let mode_line = Line::from(vec![ + Span::raw(" ".repeat(mode_pad as usize)), + Span::styled(favorites_label, mode_style(ExportMode::FavoritesTxt)), + Span::raw(mode_spacing), + Span::styled(results_label, mode_style(ExportMode::ResultsCsv)), + ]); + f.render_widget(Paragraph::new(mode_line), chunks[1]); + + let path_block = Block::default() + .borders(Borders::ALL) + .border_style(if popup_state.selected_row == 1 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }) + .title(" Save to "); + let path_inner = path_block.inner(chunks[2]); + app.panel_rects.export_path = Some(chunks[2]); + f.render_widget(path_block, chunks[2]); + f.render_widget( + Paragraph::new(popup_state.path.as_str()).style(Style::default().fg(Color::White)), + path_inner, + ); + + let status_style = if popup_state.status_success { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else if popup_state.confirm_overwrite { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else if popup_state.status.is_some() { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + let status_text = popup_state.status.as_deref().unwrap_or(" "); + f.render_widget( + Paragraph::new(fit_cell_center(status_text, chunks[3].width as usize)).style(status_style), + chunks[3], + ); + + + let cancel_label = "[Cancel]"; + let button_gap = " "; + let save_label = "[Save]"; + let button_text = format!("{}{}{}", cancel_label, button_gap, save_label); + let button_pad = chunks[4] + .width + .saturating_sub(button_text.chars().count() as u16); + let buttons_x = chunks[4].x + button_pad; + app.panel_rects.export_cancel = Some(Rect { + x: buttons_x, + y: chunks[4].y, + width: cancel_label.chars().count() as u16, + height: 1, + }); + app.panel_rects.export_save = Some(Rect { + x: buttons_x + cancel_label.chars().count() as u16 + button_gap.chars().count() as u16, + y: chunks[4].y, + width: save_label.chars().count() as u16, + height: 1, + }); + let button_line = Line::from(vec![ + Span::raw(" ".repeat(button_pad as usize)), + Span::styled( + cancel_label, + if popup_state.selected_row == 2 { + Style::default().fg(Color::Green).bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + }, + ), + Span::raw(button_gap), + Span::styled( + save_label, + if popup_state.selected_row == 3 { + Style::default().fg(Color::Green).bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + }, + ), + ]); + f.render_widget(Paragraph::new(button_line), chunks[4]); + + if popup_state.selected_row == 1 { + let max_x = path_inner.width.saturating_sub(1); + let x = path_inner.x + (popup_state.cursor_pos as u16).min(max_x); + let y = path_inner.y; + f.set_cursor_position((x, y)); + } +} + +fn draw_results(f: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Results; + let border_style = if focused { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + + // show progress in title when searching + let title = if app.searching { + let (cur, tot) = app.search_progress; + if tot > 0 { + format!(" Results [{}/{}] ", cur, tot) + } else { + " Results (loading...) ".to_string() + } + } else if app.results.is_empty() { + " Results ".to_string() + } else { + let avail = app.results.iter().filter(|(_, r)| r.is_available()).count(); + let duration_str = match app.last_search_duration { + Some(d) => format!(" | Took: {:.1}s", d.as_secs_f64()), + None => String::new(), + }; + format!(" Results ({} available / {} total{}) ", avail, app.results.len(), duration_str) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + + // If searching and have progress, show a gauge bar at top of results area + if app.searching && app.search_progress.1 > 0 { + let inner = block.inner(area); + f.render_widget(block, area); + + // split: 1 line for progress bar, rest for results + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(inner); + + // draw progress gauge + let (cur, tot) = app.search_progress; + let pct = (cur as f64 / tot as f64 * 100.0) as u16; + let filled = (chunks[0].width as u32 * cur as u32 / tot as u32) as u16; + let bar: String = "\u{2588}".repeat(filled as usize) + + &"\u{2591}".repeat((chunks[0].width.saturating_sub(filled)) as usize); + let bar_text = format!(" {}% ", pct); + let gauge_line = Line::from(vec![ + Span::styled(bar, Style::default().fg(Color::Red)), + Span::styled(bar_text, Style::default().fg(Color::DarkGray)), + ]); + f.render_widget(Paragraph::new(gauge_line), chunks[0]); + + // draw results list in remaining space + draw_results_list(f, app, chunks[1]); + } else { + let inner = block.inner(area); + f.render_widget(block, area); + draw_results_list(f, app, inner); + } +} + +fn draw_results_list(f: &mut Frame, app: &mut App, area: Rect) { + let show_note_column = app.show_unavailable; + let selected_idx = app.results_state.selected(); + let selected_bg = Color::Black; + + // collect visible results + let visible_data: Vec<(String, String, String, DomainStatus)> = if app.show_unavailable { + app.results.iter().map(|(_, r)| (r.full.clone(), r.status_str().to_string(), r.note_str(), r.status.clone())).collect() + } else { + app.results.iter().filter(|(_, r)| r.is_available()).map(|(_, r)| (r.full.clone(), r.status_str().to_string(), r.note_str(), r.status.clone())).collect() + }; + + if visible_data.is_empty() && !app.searching { + let msg = if app.results.is_empty() { + "Type a domain suffix or full domain name then press Enter" + } else { + "No available domains found" + }; + let p = Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)); + f.render_widget(p, area); + return; + } + + // calculate adaptive column widths from available space + let total_w = area.width.saturating_sub(1) as usize; + let marker_w = 3usize; + let sep_w = if show_note_column { 9usize } else { 6usize }; + let mut status_w = 10usize.min(total_w.saturating_sub(12)).max(6); + let mut note_w = if show_note_column { + (total_w / 3).clamp(8, 28) + } else { + 0 + }; + let mut domain_w = total_w.saturating_sub(status_w + note_w + marker_w + sep_w); + + if domain_w < 10 { + let needed = 10 - domain_w; + if show_note_column { + let shrink_note = needed.min(note_w.saturating_sub(8)); + note_w = note_w.saturating_sub(shrink_note); + } + domain_w = total_w.saturating_sub(status_w + note_w + marker_w + sep_w); + } + if domain_w < 10 { + let needed = 10 - domain_w; + let shrink_status = needed.min(status_w.saturating_sub(6)); + status_w = status_w.saturating_sub(shrink_status); + domain_w = total_w.saturating_sub(status_w + note_w + marker_w + sep_w); + } + domain_w = domain_w.max(6); + + let (list_area, header_area) = if !visible_data.is_empty() { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(area); + (chunks[1], Some(chunks[0])) + } else { + (area, None) + }; + + if let Some(header_area) = header_area { + let mut header_spans = vec![ + Span::styled(format!(" {}", fit_cell("Domain", domain_w)), Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)), + Span::styled(" │ ", Style::default().fg(Color::DarkGray)), + Span::styled(fit_cell("Status", status_w), Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)), + ]; + + if show_note_column { + header_spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray))); + header_spans.push(Span::styled(fit_cell("Details", note_w), Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD))); + } + + header_spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray))); + header_spans.push(Span::styled(" ✓ ", Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD))); + + f.render_widget(Paragraph::new(Line::from(header_spans)), header_area); + } + + let items: Vec<ListItem> = visible_data + .iter() + .enumerate() + .map(|(idx, (full, status_str, note, status))| { + let is_selected = selected_idx == Some(idx); + let selection_bg = if is_selected { Some(selected_bg) } else { None }; + + let status_style = match status { + DomainStatus::Available => Style::default().fg(Color::Green), + DomainStatus::Registered { .. } => Style::default().fg(Color::Red), + DomainStatus::Error { kind, .. } => match kind { + ErrorKind::InvalidTld => Style::default().fg(Color::Yellow), + _ => Style::default().fg(Color::Blue), + }, + }; + + let domain_style = match status { + DomainStatus::Available => Style::default().fg(Color::Green), + DomainStatus::Registered { .. } => Style::default().fg(Color::Red), + DomainStatus::Error { kind, .. } => match kind { + ErrorKind::InvalidTld => Style::default().fg(Color::Yellow), + _ => Style::default().fg(Color::Blue), + }, + }; + + let apply_bg = |style: Style| { + if let Some(bg) = selection_bg { + style.bg(bg).add_modifier(Modifier::BOLD) + } else { + style + } + }; + + let mut spans = vec![ + Span::styled(format!(" {}", fit_cell(full, domain_w)), apply_bg(domain_style)), + Span::styled(" \u{2502} ", apply_bg(Style::default().fg(Color::Gray))), + Span::styled(fit_cell(status_str, status_w), apply_bg(status_style)), + ]; + + if show_note_column { + spans.push(Span::styled(" \u{2502} ", apply_bg(Style::default().fg(Color::Gray)))); + spans.push(Span::styled(fit_cell(note, note_w), apply_bg(Style::default().fg(Color::White)))); + } + + spans.push(Span::styled(" \u{2502} ", apply_bg(Style::default().fg(Color::Gray)))); + spans.push(match status { + DomainStatus::Available => Span::styled(" ✓ ", apply_bg(Style::default().fg(Color::Green))), + DomainStatus::Registered { .. } => Span::styled(" ✗ ", apply_bg(Style::default().fg(Color::Red))), + DomainStatus::Error { kind, .. } => match kind { + ErrorKind::InvalidTld => Span::styled(" ? ", apply_bg(Style::default().fg(Color::Yellow))), + _ => Span::styled(" ! ", apply_bg(Style::default().fg(Color::Blue))), + }, + }); + + let line = Line::from(spans); + ListItem::new(line) + }) + .collect(); + + let list = List::new(items); + + f.render_stateful_widget(list, list_area, &mut app.results_state); +} + +fn fit_cell(value: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + + let len = value.chars().count(); + if len <= width { + return format!("{:<width$}", value, width = width); + } + + if width == 1 { + return "…".to_string(); + } + + let truncated: String = value.chars().take(width - 1).collect(); + format!("{:<width$}", format!("{}…", truncated), width = width) +} + +fn fit_cell_center(value: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + + let rendered = if value.chars().count() <= width { + value.to_string() + } else if width == 1 { + "…".to_string() + } else { + let truncated: String = value.chars().take(width - 1).collect(); + format!("{}…", truncated) + }; + + let len = rendered.chars().count(); + let left = width.saturating_sub(len) / 2; + let right = width.saturating_sub(len + left); + format!("{}{}{}", " ".repeat(left), rendered, " ".repeat(right)) +} + +fn wrap_text_lines(text: &str, width: u16) -> Vec<String> { + if width == 0 { + return Vec::new(); + } + + let width = width as usize; + let mut wrapped = Vec::new(); + + for line in text.split('\n') { + if line.is_empty() { + wrapped.push(String::new()); + continue; + } + + let chars: Vec<char> = line.chars().collect(); + for chunk in chars.chunks(width) { + wrapped.push(chunk.iter().collect()); + } + } + + if text.ends_with('\n') { + wrapped.push(String::new()); + } + + wrapped +} + +fn scratchpad_cursor_positions(text: &str, width: u16) -> Vec<(usize, u16, u16)> { + if width == 0 { + return vec![(0, 0, 0)]; + } + + let mut positions = Vec::with_capacity(text.chars().count() + 1); + let mut line = 0u16; + let mut col = 0u16; + let mut byte_idx = 0usize; + positions.push((byte_idx, line, col)); + + for ch in text.chars() { + byte_idx += ch.len_utf8(); + if ch == '\n' { + line += 1; + col = 0; + } else { + col += 1; + if col >= width { + line += 1; + col = 0; + } + } + positions.push((byte_idx, line, col)); + } + + positions +} + +fn cursor_line_col(text: &str, cursor: usize, width: u16) -> (u16, u16) { + let clamped_cursor = cursor.min(text.len()); + scratchpad_cursor_positions(text, width) + .into_iter() + .take_while(|(idx, _, _)| *idx <= clamped_cursor) + .last() + .map(|(_, line, col)| (line, col)) + .unwrap_or((0, 0)) +} + +fn cursor_index_for_line_col(text: &str, target_line: u16, target_col: u16, width: u16) -> usize { + let positions = scratchpad_cursor_positions(text, width); + let mut best_on_line = None; + + for (idx, line, col) in positions { + if line == target_line { + best_on_line = Some(idx); + if col >= target_col { + return idx; + } + } else if line > target_line { + return best_on_line.unwrap_or(idx); + } + } + + best_on_line.unwrap_or(text.len()) +} + +fn scratchpad_inner_width(app: &App) -> u16 { + app.panel_rects + .scratchpad + .map(|rect| rect.width.saturating_sub(2).max(1)) + .unwrap_or(1) +} + +fn move_scratchpad_cursor_vertical(app: &mut App, line_delta: i16) { + let width = scratchpad_inner_width(app); + let (line, col) = cursor_line_col(&app.scratchpad, app.scratchpad_cursor, width); + let target_line = if line_delta.is_negative() { + line.saturating_sub(line_delta.unsigned_abs()) + } else { + line.saturating_add(line_delta as u16) + }; + app.scratchpad_cursor = cursor_index_for_line_col(&app.scratchpad, target_line, col, width); +} + +fn draw_scratchpad(f: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Scratchpad; + let border_style = if focused { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Scratchpad "); + + let inner = block.inner(area); + let wrapped_lines = wrap_text_lines(&app.scratchpad, inner.width); + let text: Vec<Line> = if wrapped_lines.is_empty() { + vec![Line::raw(String::new())] + } else { + wrapped_lines.into_iter().map(Line::raw).collect() + }; + f.render_widget(block, area); + f.render_widget( + Paragraph::new(text) + .style(Style::default().fg(Color::White)), + inner, + ); + + if focused && app.export_popup.is_none() { + let (line, col) = cursor_line_col(&app.scratchpad, app.scratchpad_cursor, inner.width); + let max_x = inner.width.saturating_sub(1); + let max_y = inner.height.saturating_sub(1); + f.set_cursor_position((inner.x + col.min(max_x), inner.y + line.min(max_y))); + } +} + +fn draw_favorites(f: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Favorites; + let border_style = if focused { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + + let title = if app.checking_favorites { + " Favorites (checking...) " + } else { + " Favorites " + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + + let inner = block.inner(area); + + // Reserve 1 row at the bottom for the check button + let list_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + let button_area = Rect { + x: inner.x, + y: inner.y + list_area.height, + width: inner.width, + height: 1.min(inner.height), + }; + app.panel_rects.fav_check_button = Some(button_area); + + let items: Vec<ListItem> = app + .favorites + .iter() + .map(|fav| { + let status_color = match fav.status.as_str() { + "available" => Color::Green, + "registered" => Color::Red, + "error" => Color::DarkGray, + _ => Color::White, // unknown + }; + let mut spans = vec![Span::styled( + fav.domain.as_str(), + Style::default().fg(status_color), + )]; + if fav.changed { + spans.push(Span::styled(" !", Style::default().fg(Color::Yellow))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(items) + .highlight_style( + Style::default() + .add_modifier(Modifier::REVERSED), + ); + + f.render_widget(block, area); + f.render_stateful_widget(list, list_area, &mut app.favorites_state); + + // Draw the check button at the bottom + let btn_label = if app.checking_favorites { "checking..." } else { "[c]heck all" }; + let btn_style = if app.checking_favorites { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Green) + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled(btn_label, btn_style))) + .alignment(ratatui::layout::Alignment::Center), + button_area, + ); +} + +fn draw_settings(f: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Settings; + let border_style = if focused { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Settings "); + + let unavail_check = if app.show_unavailable { "[x]" } else { "[ ]" }; + let notes_check = if app.show_notes_panel { "[x]" } else { "[ ]" }; + let clear_check = if app.clear_on_search { "[x]" } else { "[ ]" }; + let jobs_str = format!("{:>2}", app.jobs); + + let selected = if focused { app.settings_selected } else { None }; + let checkbox_style = |row: usize, checked: bool| { + let style = if checked { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + + if selected == Some(row) { + style.add_modifier(Modifier::REVERSED | Modifier::BOLD) + } else { + style + } + }; + + let label_style = |row: usize| { + if selected == Some(row) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default().fg(Color::White) + } + }; + + let tld_row_style = if selected == Some(0) { + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let jobs_row_style = if selected == Some(4) { + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let text = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled("TLD List: [", tld_row_style.fg(Color::White)), + Span::styled(app.tld_list_name.as_str(), tld_row_style.fg(Color::Cyan)), + Span::styled("] ", tld_row_style.fg(Color::White)), + Span::styled("V", tld_row_style.fg(Color::Green)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled(unavail_check, checkbox_style(1, app.show_unavailable)), + Span::styled(" Show Unavailable", label_style(1)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled(notes_check, checkbox_style(2, app.show_notes_panel)), + Span::styled(" Show Notes Panel", label_style(2)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled(clear_check, checkbox_style(3, app.clear_on_search)), + Span::styled(" Clear on Search", label_style(3)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Jobs: [", jobs_row_style.fg(Color::White)), + Span::styled(jobs_str, jobs_row_style.fg(Color::Cyan)), + Span::styled("] ", jobs_row_style.fg(Color::White)), + Span::styled("-/+", jobs_row_style.fg(Color::Green)), + ]), + ]; + + let paragraph = Paragraph::new(text).block(block); + f.render_widget(paragraph, area); +} + +fn draw_search(f: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Search; + let border_style = if focused { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + + let title = match &app.status_msg { + Some(msg) => format!(" Search - {} ", msg), + None => " Search (Enter to lookup) ".to_string(), + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + + let inner = block.inner(area); + f.render_widget(block, area); + + let search_button_width = SEARCH_BUTTON_LABEL.chars().count() as u16; + let clear_button_width = CLEAR_BUTTON_LABEL.chars().count() as u16; + let stop_button_width = STOP_BUTTON_LABEL.chars().count() as u16; + let chunks = if app.clear_on_search { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(1), + Constraint::Length(search_button_width), + Constraint::Length(1), + Constraint::Length(stop_button_width), + ]) + .split(inner) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(1), + Constraint::Length(search_button_width), + Constraint::Length(1), + Constraint::Length(stop_button_width), + Constraint::Length(1), + Constraint::Length(clear_button_width), + ]) + .split(inner) + }; + + app.panel_rects.search_button = Some(chunks[1]); + if app.clear_on_search { + app.panel_rects.clear_button = None; + app.panel_rects.cancel_button = Some(chunks[3]); + } else { + app.panel_rects.cancel_button = Some(chunks[3]); + app.panel_rects.clear_button = Some(chunks[5]); + } + + let input_chunk = chunks[0]; + let visible_input = fit_cell(&app.search_input, input_chunk.width as usize); + let input = Paragraph::new(visible_input).style(Style::default().fg(Color::White)); + f.render_widget(input, input_chunk); + + let search_enabled = !app.searching && !app.search_input.is_empty(); + let cancel_enabled = app.searching; + + let search_style = if search_enabled { + Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray).bg(Color::Black) + }; + let stop_style = if cancel_enabled { + Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray).bg(Color::Black) + }; + let clear_style = Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD); + + f.render_widget(Paragraph::new(SEARCH_BUTTON_LABEL).style(search_style), chunks[1]); + if app.clear_on_search { + f.render_widget(Paragraph::new(STOP_BUTTON_LABEL).style(stop_style), chunks[3]); + } else { + f.render_widget(Paragraph::new(STOP_BUTTON_LABEL).style(stop_style), chunks[3]); + f.render_widget(Paragraph::new(CLEAR_BUTTON_LABEL).style(clear_style), chunks[5]); + } + + // show cursor in search bar when focused + if focused && app.export_popup.is_none() { + let max_cursor = input_chunk.width.saturating_sub(1) as usize; + let x = input_chunk.x + app.cursor_pos.min(max_cursor) as u16; + let y = input_chunk.y; + f.set_cursor_position((x, y)); + } +} + +fn draw_dropdown(f: &mut Frame, app: &mut App, settings_area: Rect, selected: usize) { + let options = app.list_options(); + + // position dropdown below the TLD list line in settings + let dropdown_full = Rect { + x: settings_area.x + 1, + y: settings_area.y + 1, + width: settings_area.width.saturating_sub(2).min(DROPDOWN_MAX_WIDTH), + height: (options.len() as u16 + 2).min(DROPDOWN_MAX_HEIGHT), + }; + + app.panel_rects.dropdown = Some(dropdown_full); + + // clear the area behind the dropdown + f.render_widget(Clear, dropdown_full); + + let items: Vec<ListItem> = options + .iter() + .map(|opt| { + ListItem::new(Line::from(Span::styled( + format!(" {} ", opt), + Style::default().fg(Color::White), + ))) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" TLD List "); + + f.render_widget(Clear, dropdown_full); + let list = List::new(items) + .block(block) + .highlight_style(Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD)); + let mut state = ListState::default(); + state.select(Some(selected)); + f.render_stateful_widget(list, dropdown_full, &mut state); +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..9a496c2 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DomainStatus { + Available, + Registered { expiry: Option<String> }, + Error { kind: ErrorKind, message: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ErrorKind { + InvalidTld, + Unknown, + Timeout, + RateLimit, + Forbidden, +} + +impl ErrorKind { + /// parse from config string (case insensitive, underscores and hyphens both work) + pub fn from_config_str(s: &str) -> Option<Self> { + match s.to_lowercase().replace('-', "_").as_str() { + "invalid_tld" | "invalidtld" => Some(ErrorKind::InvalidTld), + "unknown" => Some(ErrorKind::Unknown), + "timeout" => Some(ErrorKind::Timeout), + "rate_limit" | "ratelimit" => Some(ErrorKind::RateLimit), + "forbidden" => Some(ErrorKind::Forbidden), + _ => None, + } + } + + /// back to config string + pub fn to_config_str(&self) -> &'static str { + match self { + ErrorKind::InvalidTld => "invalid_tld", + ErrorKind::Unknown => "unknown", + ErrorKind::Timeout => "timeout", + ErrorKind::RateLimit => "rate_limit", + ErrorKind::Forbidden => "forbidden", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainResult { + pub name: String, + pub tld: String, + pub full: String, + pub status: DomainStatus, +} + +impl DomainResult { + pub fn new(name: &str, tld: &str, status: DomainStatus) -> Self { + Self { + name: name.to_string(), + tld: tld.to_string(), + full: format!("{}.{}", name, tld), + status, + } + } + + pub fn is_available(&self) -> bool { + matches!(self.status, DomainStatus::Available) + } + + pub fn is_error(&self) -> bool { + matches!(self.status, DomainStatus::Error { .. }) + } + + pub fn status_str(&self) -> &str { + match &self.status { + DomainStatus::Available => "available", + DomainStatus::Registered { .. } => "registered", + DomainStatus::Error { .. } => "error", + } + } + + pub fn note_str(&self) -> String { + match &self.status { + DomainStatus::Available => "-".to_string(), + DomainStatus::Registered { expiry } => match expiry { + Some(date) => format!("until {}", date), + None => "no expiry info".to_string(), + }, + DomainStatus::Error { kind, message } => match kind { + ErrorKind::InvalidTld => "invalid tld".to_string(), + _ => message.clone(), + }, + } + } +} + + |
