mod display;
mod repl;
mod utils;
use std::io::Write;
use std::sync::Arc;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use indicatif::{ProgressBar, ProgressStyle};
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
enum ProgressMode {
Bar,
Verbose,
Failures,
None,
}
fn resolve_progress_mode(
flag: Option<ProgressMode>,
stderr_is_tty: bool,
format: &str,
) -> ProgressMode {
if let Some(mode) = flag {
return mode;
}
if format.eq_ignore_ascii_case("json") {
return ProgressMode::None;
}
if !stderr_is_tty {
return ProgressMode::None;
}
ProgressMode::Bar
}
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal;
use seer_core::colors::CatppuccinExt;
const BULK_EXAMPLES: &str = r#"
Input File Formats:
Plain text (one domain per line, # for comments):
# My domains to check
example.com
google.com
github.com
CSV (uses first column, skips header if present):
domain,owner,notes
example.com,Alice,Main site
google.com,Bob,Search
github.com,Carol,Code hosting
Example Usage:
seer bulk status domains.txt # Output: domains_results.csv
seer bulk lookup domains.csv # Output: domains_results.csv
seer bulk dig domains.txt MX # Output: domains_results.csv
seer bulk avail domains.txt # Output: domains_results.csv
seer bulk info domains.txt # Output: domains_results.csv
seer bulk status domains.txt -o out.csv # Output: out.csv
Example Output (status operation):
domain,success,http_status,http_status_text,title,ssl_issuer,ssl_valid_until,ssl_days_remaining,domain_expires,domain_days_remaining,registrar,dns_resolves,dns_a_records,dns_aaaa_records,dns_cname,dns_nameservers,duration_ms,error
example.com,true,200,OK,Example Domain,DigiCert Inc,2025-03-01,89,2025-08-13,204,RESERVED-Internet Assigned Numbers Authority,true,93.184.216.34,2606:2800:220:1:248:1893:25c8:1946,,a.iana-servers.net;b.iana-servers.net,1245,
google.com,true,200,OK,Google,Google Trust Services,2025-02-15,75,2028-09-14,1332,MarkMonitor Inc.,true,142.250.185.46,2607:f8b0:4004:800::200e,,ns1.google.com;ns2.google.com,892,
Example Output (lookup/whois/rdap operation):
domain,success,registrar,created,expires,updated,duration_ms,availability_verdict,error
example.com,true,RESERVED-Internet Assigned Numbers Authority,1995-08-14,2025-08-13,2024-08-14,523,,
google.com,true,MarkMonitor Inc.,1997-09-15,2028-09-14,2019-09-09,412,,
Example Output (dig operation):
domain,success,record_type,records,duration_ms,error
example.com,true,A,93.184.216.34,45,
google.com,true,MX,10 smtp.google.com; 20 smtp2.google.com,38,
Example Output (avail operation):
domain,success,available,confidence,method,details,duration_ms,error
nonexistent123.com,true,true,high,whois,WHOIS indicates domain is not registered,1523,
google.com,true,false,high,rdap,Domain is registered (status: client delete prohibited),412,
Example Output (info operation):
domain,success,source,registrar,registrant,organization,created,expires,updated,nameservers,status,dnssec,...,whois_server,rdap_url,availability_verdict,duration_ms,error
example.com,true,Both,RESERVED-Internet Assigned Numbers Authority,,Internet Assigned Numbers Authority,1995-08-14,2025-08-13,2024-08-14,a.iana-servers.net;b.iana-servers.net,client delete prohibited,signed,...,whois.iana.org,https://rdap.iana.org/domain/example.com,,1523,
"#;
#[derive(Parser)]
#[command(name = "seer")]
#[command(about = "Domain name helper - WHOIS, RDAP, DIG, and propagation checking")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(short, long, default_value = "human")]
format: String,
#[arg(short, long)]
quiet: bool,
#[arg(long, value_delimiter = ',')]
fields: Option<Vec<String>>,
}
#[derive(Subcommand)]
enum Commands {
Lookup {
domain: String,
},
Info {
domain: String,
},
Whois {
domain: String,
},
Rdap {
query: String,
},
Dig {
domain: String,
#[arg(default_value = "A")]
record_type: String,
#[arg(short, long)]
server: Option<String>,
},
Prop {
domain: String,
#[arg(default_value = "A")]
record_type: String,
},
#[command(after_long_help = BULK_EXAMPLES)]
Bulk {
#[arg(value_name = "OPERATION")]
operation: String,
#[arg(value_name = "FILE")]
file: String,
#[arg(value_name = "TYPE", default_value = "A")]
record_type: String,
#[arg(short, long, value_name = "OUTPUT")]
output: Option<String>,
#[arg(long, value_enum)]
progress: Option<ProgressMode>,
},
Status {
domain: String,
},
Follow {
domain: String,
#[arg(default_value = "10")]
iterations: usize,
#[arg(default_value = "1")]
interval_minutes: f64,
#[arg(default_value = "A")]
record_type: String,
#[arg(short, long)]
server: Option<String>,
#[arg(long)]
changes_only: bool,
},
Reverse {
ip: String,
},
Avail {
domain: String,
},
Dnssec {
domain: String,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
Config {
#[arg(long)]
init: bool,
},
Ssl {
domain: String,
},
Tld {
tld: String,
},
Compare {
domain: String,
#[arg(default_value = "A")]
record_type: String,
server_a: String,
server_b: String,
},
Subdomains {
domain: String,
},
Diff {
domain_a: String,
domain_b: String,
},
Watch {
action: Option<String>,
domain: Option<String>,
},
History {
domain: Option<String>,
#[arg(long)]
clear: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _log_guard = seer_core::logging::init_logging_with_writer(
"seer",
"error",
display::ProgressWriterFactory::new(),
);
let cli = Cli::parse();
let output_format: seer_core::output::OutputFormat = if cli.format == "human" {
let config = seer_core::SeerConfig::load();
config.output_format.parse().unwrap_or_default()
} else {
cli.format.parse().unwrap_or_default()
};
match cli.command {
Some(cmd) => execute_command(cmd, output_format, cli.quiet, cli.fields).await,
None => {
let mut repl = repl::Repl::new()?;
repl.run().await
}
}
}
fn extract_fields(value: &serde_json::Value, fields: &[String]) {
for field in fields {
let parts: Vec<&str> = field.split('.').collect();
let mut current = value;
for part in &parts {
current = match current {
serde_json::Value::Object(map) => {
map.get(*part).unwrap_or(&serde_json::Value::Null)
}
_ => &serde_json::Value::Null,
};
}
match current {
serde_json::Value::String(s) => println!("{}", s),
serde_json::Value::Null => println!(),
other => println!("{}", other),
}
}
}
fn handle_quiet_output<T: serde::Serialize>(value: &T, fields: &Option<Vec<String>>) -> bool {
if let Some(ref fields) = fields {
let json_value = serde_json::to_value(value).unwrap_or_default();
extract_fields(&json_value, fields);
true
} else {
let json = serde_json::to_string(value).unwrap_or_default();
println!("{}", json);
true
}
}
async fn execute_command(
command: Commands,
output_format: seer_core::output::OutputFormat,
quiet: bool,
fields: Option<Vec<String>>,
) -> anyhow::Result<()> {
let formatter = seer_core::output::get_formatter(output_format);
match command {
Commands::Lookup { domain } => {
let spinner = Arc::new(display::Spinner::new(&format!(
"Smart lookup for {} (trying RDAP first)",
domain
)));
let spinner_clone = spinner.clone();
let progress: seer_core::LookupProgressCallback = Arc::new(move |message| {
spinner_clone.set_message(message);
});
let lookup = seer_core::SmartLookup::new();
match lookup.lookup_with_progress(&domain, Some(progress)).await {
Ok(result) => {
spinner.finish();
let domain_for_history = domain.clone();
let result_for_history = result.clone();
tokio::task::spawn_blocking(move || {
let mut history = seer_core::LookupHistory::load();
history.record(&domain_for_history, result_for_history);
let _ = history.save();
})
.await
.ok();
if quiet {
handle_quiet_output(&result, &fields);
} else {
println!("{}", formatter.format_lookup(&result));
}
}
Err(e) => {
spinner.finish();
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Info { domain } => {
let spinner = Arc::new(display::Spinner::new(&format!(
"Getting comprehensive info for {}",
domain
)));
let lookup = seer_core::SmartLookup::new();
match lookup.lookup(&domain).await {
Ok(result) => {
spinner.finish();
let info = seer_core::DomainInfo::from_lookup_result(&result);
if quiet {
handle_quiet_output(&info, &fields);
} else {
println!("{}", formatter.format_domain_info(&info));
}
}
Err(e) => {
spinner.finish();
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Whois { domain } => {
let client = seer_core::WhoisClient::new();
match client.lookup(&domain).await {
Ok(response) => {
if quiet {
handle_quiet_output(&response, &fields);
} else {
println!("{}", formatter.format_whois(&response));
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Rdap { query } => {
let client = seer_core::RdapClient::new();
let result = if query.starts_with("AS") || query.starts_with("as") {
let asn: u32 = query[2..].parse()?;
client.lookup_asn(asn).await
} else if query.parse::<std::net::IpAddr>().is_ok() {
client.lookup_ip(&query).await
} else {
client.lookup_domain(&query).await
};
match result {
Ok(response) => {
if quiet {
handle_quiet_output(&response, &fields);
} else {
println!("{}", formatter.format_rdap(&response));
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Dig {
domain,
record_type,
server,
} => {
let resolver = seer_core::DnsResolver::new();
let rt: seer_core::RecordType = record_type.parse()?;
let ns = server.as_ref().map(|s| s.trim_start_matches('@'));
match resolver.resolve(&domain, rt, ns).await {
Ok(records) => {
if quiet {
handle_quiet_output(&records, &fields);
} else {
println!("{}", formatter.format_dns(&records));
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Prop {
domain,
record_type,
} => {
let checker = seer_core::dns::PropagationChecker::new();
let rt: seer_core::RecordType = record_type.parse()?;
match checker.check(&domain, rt).await {
Ok(result) => {
if quiet {
handle_quiet_output(&result, &fields);
} else {
println!("{}", formatter.format_propagation(&result));
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Bulk {
operation,
file,
record_type,
output,
progress,
} => {
let stderr_is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
let format_str = match output_format {
seer_core::output::OutputFormat::Json => "json",
seer_core::output::OutputFormat::Human => "human",
seer_core::output::OutputFormat::Yaml => "yaml",
seer_core::output::OutputFormat::Markdown => "markdown",
};
let progress_mode = resolve_progress_mode(progress, stderr_is_tty, format_str);
const MAX_BULK_DOMAINS_CLI: usize = 1000;
let content = utils::read_bulk_input(&file).map_err(|e| anyhow::anyhow!(e))?;
let domains = seer_core::bulk::parse_domains_from_file(&content);
if domains.is_empty() {
eprintln!(
"{} No valid domains found in file. Expected format: one domain per line, # for comments, or CSV (first column)",
"Error:".ctp_red()
);
std::process::exit(1);
}
if domains.len() > MAX_BULK_DOMAINS_CLI {
return Err(anyhow::anyhow!(
"Bulk file contains {} domains, maximum is {}",
domains.len(),
MAX_BULK_DOMAINS_CLI
));
}
let output_path = output.unwrap_or_else(|| {
let input_path = std::path::Path::new(&file);
let stem = input_path.file_stem().unwrap_or_default().to_string_lossy();
let parent = input_path.parent().unwrap_or(std::path::Path::new("."));
parent
.join(format!("{}_results.csv", stem))
.to_string_lossy()
.to_string()
});
let rt: seer_core::RecordType = record_type.parse().unwrap_or(seer_core::RecordType::A);
let executor = seer_core::BulkExecutor::new();
println!(
"Processing {} domains with {} operation...",
domains.len().to_string().ctp_green(),
operation.ctp_yellow()
);
let operations: Vec<seer_core::bulk::BulkOperation> = match operation.as_str() {
"lookup" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Lookup { domain: d.clone() })
.collect(),
"whois" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Whois { domain: d.clone() })
.collect(),
"rdap" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Rdap { domain: d.clone() })
.collect(),
"dig" | "dns" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Dns {
domain: d.clone(),
record_type: rt,
})
.collect(),
"propagation" | "prop" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Propagation {
domain: d.clone(),
record_type: rt,
})
.collect(),
"status" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Status { domain: d.clone() })
.collect(),
"avail" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Avail { domain: d.clone() })
.collect(),
"info" => domains
.iter()
.map(|d: &String| seer_core::bulk::BulkOperation::Info { domain: d.clone() })
.collect(),
_ => {
eprintln!(
"{} Unknown operation: {}. Use: lookup, whois, rdap, dig/dns, prop, status, avail, info",
"Error:".ctp_red(),
operation
);
std::process::exit(1);
}
};
let total = operations.len();
let pb: Option<Arc<ProgressBar>> = match progress_mode {
ProgressMode::None => None,
ProgressMode::Bar | ProgressMode::Verbose | ProgressMode::Failures => {
let bar = ProgressBar::new(total as u64);
bar.set_style(
ProgressStyle::default_bar()
.template("{bar:40.cyan/blue} {pos}/{len} ({percent}%) eta {eta} {msg}")
.expect("valid progress bar template")
.progress_chars("=>-"),
);
display::set_bulk_progress_bar(bar.clone());
Some(Arc::new(bar))
}
};
let callback: Option<seer_core::bulk::ProgressCallback> = pb.as_ref().map(|bar| {
let bar = bar.clone();
Box::new(move |completed: usize, _total: usize, domain: &str| {
bar.set_position(completed as u64);
bar.set_message(domain.to_string());
}) as seer_core::bulk::ProgressCallback
});
let results = executor.execute(operations, callback).await;
if let Some(bar) = pb.as_ref() {
for r in &results {
let domain = match &r.operation {
seer_core::bulk::BulkOperation::Whois { domain }
| seer_core::bulk::BulkOperation::Rdap { domain }
| seer_core::bulk::BulkOperation::Dns { domain, .. }
| seer_core::bulk::BulkOperation::Propagation { domain, .. }
| seer_core::bulk::BulkOperation::Lookup { domain }
| seer_core::bulk::BulkOperation::Status { domain }
| seer_core::bulk::BulkOperation::Avail { domain }
| seer_core::bulk::BulkOperation::Info { domain } => domain.as_str(),
};
match (progress_mode, r.success) {
(ProgressMode::Verbose, true) => {
bar.println(format!(
"{} {} ({}ms)",
"\u{2713}".ctp_green(),
domain,
r.duration_ms
));
}
(ProgressMode::Verbose, false) | (ProgressMode::Failures, false) => {
let err = r.error.as_deref().unwrap_or("unknown error");
bar.println(format!("{} {} ({})", "\u{2717}".ctp_red(), domain, err));
}
_ => {}
}
}
bar.finish_and_clear();
display::clear_bulk_progress_bar();
}
let csv_content = utils::bulk_results_to_csv(&results, &operation);
std::fs::write(&output_path, csv_content)?;
let success_count = results.iter().filter(|r| r.success).count();
let fail_count = results.len() - success_count;
println!("Results written to: {}", output_path.ctp_green());
println!(
" {} successful, {} failed",
success_count.to_string().ctp_green(),
if fail_count > 0 {
fail_count.to_string().ctp_red()
} else {
fail_count.to_string().ctp_green()
}
);
}
Commands::Status { domain } => {
let client = seer_core::StatusClient::new();
match client.check(&domain).await {
Ok(response) => {
if quiet {
handle_quiet_output(&response, &fields);
} else {
println!("{}", formatter.format_status(&response));
}
let has_issues = response
.http_status
.is_none_or(|s| !(200..300).contains(&s))
|| response
.certificate
.as_ref()
.is_some_and(|c| !c.is_valid || c.days_until_expiry < 30)
|| response
.domain_expiration
.as_ref()
.is_some_and(|d| d.days_until_expiry < 30);
if has_issues {
std::process::exit(1);
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Reverse { ip } => {
let resolver = seer_core::DnsResolver::new();
match resolver
.resolve(&ip, seer_core::RecordType::PTR, None)
.await
{
Ok(records) => {
if quiet {
handle_quiet_output(&records, &fields);
} else {
println!("{}", formatter.format_dns(&records));
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Avail { domain } => {
let checker = seer_core::AvailabilityChecker::new();
match checker.check(&domain).await {
Ok(result) => {
if quiet {
handle_quiet_output(&result, &fields);
} else {
println!("{}", formatter.format_availability(&result));
}
if !result.available {
std::process::exit(1);
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Dnssec { domain } => {
let checker = seer_core::DnssecChecker::new();
match checker.check(&domain).await {
Ok(report) => {
if quiet {
handle_quiet_output(&report, &fields);
} else {
println!("{}", formatter.format_dnssec(&report));
}
if report.status != "secure" {
std::process::exit(1);
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "seer", &mut std::io::stdout());
}
Commands::Config { init } => {
if init {
let config_path = seer_core::SeerConfig::config_path();
match config_path {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() {
eprintln!("Config file already exists at: {}", path.display());
std::process::exit(1);
}
let content = seer_core::SeerConfig::default_toml();
std::fs::write(&path, content)?;
println!(
"Created config file at: {}",
path.display().to_string().ctp_green()
);
}
None => {
eprintln!("{} Could not determine home directory", "Error:".ctp_red());
std::process::exit(1);
}
}
} else {
let config = seer_core::SeerConfig::load();
println!(
"{}",
serde_json::to_string_pretty(&config).unwrap_or_default()
);
}
}
Commands::Follow {
domain,
iterations,
interval_minutes,
record_type,
server,
changes_only,
} => {
let rt: seer_core::RecordType = record_type.parse()?;
let ns = server.as_ref().map(|s| s.trim_start_matches('@'));
let config = match seer_core::FollowConfig::new(iterations, interval_minutes) {
Ok(cfg) => cfg.with_changes_only(changes_only),
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
};
let follower = seer_core::DnsFollower::new();
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let cancel_tx_ctrlc = cancel_tx.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
let _ = cancel_tx_ctrlc.send(true);
});
let raw_mode_enabled = terminal::enable_raw_mode().is_ok();
let cancel_tx_esc = cancel_tx.clone();
let key_listener = tokio::spawn(async move {
loop {
if event::poll(std::time::Duration::from_millis(100)).unwrap_or(false) {
if let Ok(Event::Key(KeyEvent {
code, modifiers, ..
})) = event::read()
{
match code {
KeyCode::Esc => {
let _ = cancel_tx_esc.send(true);
break;
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
let _ = cancel_tx_esc.send(true);
break;
}
_ => {}
}
}
}
if cancel_tx_esc.is_closed() {
break;
}
}
});
let follow_format = output_format;
let callback: seer_core::dns::FollowProgressCallback = Arc::new(move |iteration| {
let formatter = seer_core::output::get_formatter(follow_format);
let output = formatter.format_follow_iteration(iteration);
let output = output.replace('\n', "\r\n");
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(output.as_bytes());
let _ = stdout.write_all(b"\r\n");
let _ = stdout.flush();
});
print!(
"Following {} {} records ({} iterations, {} interval)\r\n",
domain.ctp_green(),
record_type.ctp_yellow(),
iterations.to_string().ctp_yellow(),
utils::format_interval(interval_minutes)
);
print!(
"Press {} or {} to stop early\r\n\r\n",
"Esc".ctp_yellow(),
"Ctrl+C".ctp_yellow()
);
let _ = std::io::stdout().flush();
let result = follower
.follow(&domain, rt, ns, config, Some(callback), Some(cancel_rx))
.await;
key_listener.abort();
if raw_mode_enabled {
let _ = terminal::disable_raw_mode();
}
match result {
Ok(result) => {
if result.interrupted {
println!("\n{}", "Follow interrupted by user".ctp_yellow());
}
println!("\n{}", formatter.format_follow(&result));
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Ssl { domain } => {
let checker = seer_core::SslChecker::new();
match checker.check(&domain).await {
Ok(report) => {
if quiet && handle_quiet_output(&report, &fields) {
} else {
println!("{}", formatter.format_ssl(&report));
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Tld { tld } => {
let info = seer_core::lookup_tld(&tld).await;
if quiet && handle_quiet_output(&info, &fields) {
} else {
println!("{}", formatter.format_tld(&info));
}
}
Commands::Compare {
domain,
record_type,
server_a,
server_b,
} => {
let comparator = seer_core::dns::DnsComparator::new();
let rt: seer_core::RecordType = record_type.parse()?;
let ns_a = server_a.trim_start_matches('@');
let ns_b = server_b.trim_start_matches('@');
match comparator.compare(&domain, rt, ns_a, ns_b).await {
Ok(comparison) => {
if quiet && handle_quiet_output(&comparison, &fields) {
} else {
println!("{}", formatter.format_dns_comparison(&comparison));
}
if !comparison.matches {
std::process::exit(1);
}
}
Err(e) => {
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Subdomains { domain } => {
let spinner = Arc::new(display::Spinner::new(&format!(
"Enumerating subdomains for {}",
domain
)));
let enumerator = seer_core::SubdomainEnumerator::new();
match enumerator.enumerate(&domain).await {
Ok(result) => {
spinner.finish();
if quiet && handle_quiet_output(&result, &fields) {
} else {
println!("{}", formatter.format_subdomains(&result));
}
}
Err(e) => {
spinner.finish();
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Diff { domain_a, domain_b } => {
let spinner = Arc::new(display::Spinner::new(&format!(
"Comparing {} vs {}",
domain_a, domain_b
)));
let differ = seer_core::DomainDiffer::new();
match differ.diff(&domain_a, &domain_b).await {
Ok(diff) => {
spinner.finish();
if quiet && handle_quiet_output(&diff, &fields) {
} else {
println!("{}", formatter.format_diff(&diff));
}
}
Err(e) => {
spinner.finish();
eprintln!("{} {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Commands::Watch { action, domain } => {
let mut watchlist = seer_core::Watchlist::load();
match action.as_deref() {
Some("add") => {
let domain = domain
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Usage: seer watch add <domain>"))?;
match watchlist.add(domain) {
Ok(true) => {
watchlist.save()?;
println!("Added {} to watchlist", domain.ctp_green());
}
Ok(false) => {
println!("{} is already in the watchlist", domain);
}
Err(e) => {
eprintln!("{} Invalid domain: {}", "Error:".ctp_red(), e);
std::process::exit(1);
}
}
}
Some("remove") => {
let domain = domain
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Usage: seer watch remove <domain>"))?;
if watchlist.remove(domain) {
watchlist.save()?;
println!("Removed {} from watchlist", domain.ctp_green());
} else {
println!("{} was not in the watchlist", domain);
}
}
Some("list") => {
if watchlist.domains.is_empty() {
println!(
"Watchlist is empty. Use 'seer watch add <domain>' to add domains."
);
} else {
println!("Watchlist ({} domains):", watchlist.domains.len());
for d in &watchlist.domains {
println!(" - {}", d);
}
}
}
None => {
if watchlist.domains.is_empty() {
println!(
"Watchlist is empty. Use 'seer watch add <domain>' to add domains."
);
} else {
let spinner = Arc::new(display::Spinner::new(&format!(
"Checking {} domains",
watchlist.domains.len()
)));
let report = seer_core::check_watchlist(&watchlist.domains).await;
spinner.finish();
if quiet && handle_quiet_output(&report, &fields) {
} else {
println!("{}", formatter.format_watch(&report));
}
}
}
Some(other) => {
eprintln!(
"{} Unknown watch action: {}. Use: add, remove, list",
"Error:".ctp_red(),
other
);
std::process::exit(1);
}
}
}
Commands::History { domain, clear } => {
let mut history = tokio::task::spawn_blocking(seer_core::LookupHistory::load)
.await
.unwrap_or_default();
if clear {
history.clear();
let save_result = tokio::task::spawn_blocking(move || history.save()).await;
match save_result {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e.into()),
Err(e) => return Err(e.into()),
}
println!("Lookup history cleared");
} else if let Some(domain) = domain {
let entries = history.get(&domain);
if entries.is_empty() {
println!("No history for {}", domain);
} else {
println!(
"History for {} ({} entries):",
domain.ctp_green(),
entries.len()
);
for entry in entries {
let source = if entry.result.is_rdap() {
"RDAP"
} else if entry.result.is_whois() {
"WHOIS"
} else {
"availability"
};
println!(
" [{}] via {} - registrar: {}",
entry.timestamp.format("%Y-%m-%d %H:%M"),
source,
entry.result.registrar().unwrap_or_else(|| "—".to_string())
);
}
}
} else {
let total: usize = history.entries.values().map(Vec::len).sum();
if total == 0 {
println!("No lookup history. Run 'seer lookup <domain>' to build history.");
} else {
println!(
"Lookup history ({} entries across {} domains):",
total,
history.entries.len()
);
for (domain, entries) in &history.entries {
println!(" {} ({} entries)", domain, entries.len());
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod progress_mode_tests {
use super::{resolve_progress_mode, ProgressMode};
#[test]
fn explicit_mode_is_honored_on_tty() {
assert_eq!(
resolve_progress_mode(Some(ProgressMode::Verbose), true, "human"),
ProgressMode::Verbose
);
assert_eq!(
resolve_progress_mode(Some(ProgressMode::None), true, "human"),
ProgressMode::None
);
}
#[test]
fn explicit_mode_is_honored_on_non_tty() {
assert_eq!(
resolve_progress_mode(Some(ProgressMode::Bar), false, "human"),
ProgressMode::Bar
);
}
#[test]
fn explicit_mode_overrides_json_format() {
assert_eq!(
resolve_progress_mode(Some(ProgressMode::Bar), true, "json"),
ProgressMode::Bar
);
}
#[test]
fn default_is_bar_on_tty_with_human_format() {
assert_eq!(
resolve_progress_mode(None, true, "human"),
ProgressMode::Bar
);
}
#[test]
fn default_is_none_on_non_tty() {
assert_eq!(
resolve_progress_mode(None, false, "human"),
ProgressMode::None
);
}
#[test]
fn default_is_none_with_json_format() {
assert_eq!(
resolve_progress_mode(None, true, "json"),
ProgressMode::None
);
}
}