mod catalog;
mod commands;
mod completer;
pub use commands::{CommandContext, CommandResult};
pub use completer::SeerCompleter;
use std::io::Write;
use colored::Colorize;
use rustyline::error::ReadlineError;
use rustyline::history::DefaultHistory;
use rustyline::{CompletionType, Editor};
use seer_core::colors::CatppuccinExt;
use crate::display::Spinner;
use crate::query::{Clients, Query};
const HISTORY_FILE: &str = ".seer_history";
pub struct Repl {
editor: Editor<SeerCompleter, DefaultHistory>,
context: CommandContext,
clients: Clients,
dns_follower: seer_core::DnsFollower,
last_result: Option<crate::payload::Payload>,
}
fn editor_config() -> rustyline::Config {
rustyline::Config::builder()
.history_ignore_space(true)
.completion_type(CompletionType::List)
.edit_mode(rustyline::EditMode::Emacs)
.build()
}
fn history_entry(line: &str) -> &str {
line.trim_end()
}
impl Repl {
pub fn new() -> anyhow::Result<Self> {
let completer = SeerCompleter::new();
let mut editor = Editor::with_config(editor_config())?;
editor.set_helper(Some(completer));
let history_path = std::env::home_dir()
.map(|p| p.join(HISTORY_FILE))
.unwrap_or_else(|| HISTORY_FILE.into());
let _ = editor.load_history(&history_path);
let context = CommandContext::new();
let cfg = &context.config;
Ok(Self {
editor,
clients: Clients::from_config(cfg),
dns_follower: seer_core::DnsFollower::from_config(cfg),
last_result: None,
context,
})
}
pub fn set_output_format(&mut self, format: seer_core::output::OutputFormat) {
self.context.output_format = format;
}
pub async fn run(&mut self) -> anyhow::Result<()> {
self.print_banner();
let mut last_ctrl_c: Option<std::time::Instant> = None;
loop {
let prompt = self.get_prompt();
match self.editor.readline(&prompt) {
Ok(line) => {
last_ctrl_c = None;
let command_line = line.trim();
if command_line.is_empty() {
continue;
}
self.editor.add_history_entry(history_entry(&line))?;
match self.execute_line(command_line).await {
CommandResult::Continue => {}
CommandResult::Exit => break,
CommandResult::Error(e) => {
match crate::utils::machine_error(self.context.output_format, &e) {
Some(structured) => eprintln!("{}", structured),
None => eprintln!("{} {}", "Error:".ctp_red().bold(), e),
}
}
}
println!();
}
Err(ReadlineError::Interrupted) => {
if last_ctrl_c.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2))
{
println!("exit");
break;
}
last_ctrl_c = Some(std::time::Instant::now());
println!("{}", "Press Ctrl+C again to exit (or type 'exit')".dimmed());
continue;
}
Err(ReadlineError::Eof) => {
println!("exit");
break;
}
Err(err) => {
eprintln!("{} {:?}", "Error:".ctp_red().bold(), err);
break;
}
}
}
let history_path = std::env::home_dir()
.map(|p| p.join(HISTORY_FILE))
.unwrap_or_else(|| HISTORY_FILE.into());
let _ = self.editor.save_history(&history_path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&history_path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
fn print_banner(&self) {
println!();
println!("{}", " ✦ ·:*¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨*:· ✦".bright_cyan());
println!("{}", " ╔═╗ ╔═╗ ╔═╗ ╦═╗".bright_purple());
println!("{}", " ╚═╗ ╠═ ╠═ ╠╦╝".bright_purple());
println!("{}", " ╚═╝ ╚═╝ ╚═╝ ╩╚═".bright_purple());
println!("{}", " ✦ '·:*¨¨¨¨¨¨¨¨¨¨¨¨¨¨*:·' ✦".bright_cyan());
println!();
println!(
" {} - Domain Name Helper",
format!("Seer v{}", env!("CARGO_PKG_VERSION"))
.bright_purple()
.bold()
);
println!(" Type {} for available commands\n", "help".bright_green());
}
fn get_prompt(&self) -> String {
let format_indicator = match self.context.output_format {
seer_core::output::OutputFormat::Human => "",
seer_core::output::OutputFormat::Json => " [json]",
seer_core::output::OutputFormat::Yaml => " [yaml]",
seer_core::output::OutputFormat::Markdown => " [md]",
};
format!(
"{}{} ",
"seer".bright_cyan().bold(),
format!("{}›", format_indicator).white()
)
}
async fn execute_line(&mut self, line: &str) -> CommandResult {
let tokens = match commands::tokenize_line(line) {
Ok(t) => t,
Err(e) => return CommandResult::Error(e),
};
let parts: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
if parts.is_empty() {
return CommandResult::Continue;
}
let command = parts[0].to_lowercase();
let result = self.dispatch(&command, &parts).await;
if matches!(result, CommandResult::Error(_)) && !matches!(command.as_str(), "copy" | "set")
{
self.last_result = None;
}
result
}
async fn dispatch(&mut self, command: &str, parts: &[&str]) -> CommandResult {
let args = &parts[1..];
match catalog::canonical(command) {
"help" => {
self.print_help();
CommandResult::Continue
}
"exit" => CommandResult::Exit,
"bulk" => self.execute_bulk(args).await,
"follow" => self.execute_follow(args).await,
"watch" => self.execute_watch(args).await,
"history" => self.execute_history(args).await,
"set" => self.execute_set(args),
"copy" => self.execute_copy(args),
"clear" => {
print!("\x1B[2J\x1B[1;1H");
let _ = std::io::stdout().flush();
CommandResult::Continue
}
_ => match commands::parse_query(command, parts) {
Ok(query) => self.execute_query(query).await,
Err(e) => CommandResult::Error(e),
},
}
}
fn print_help(&self) {
println!();
for section in catalog::SECTIONS {
println!("{}", section.title.bright_purple().bold());
for command in section.commands {
let invocation = format!("{} {}", command.name, command.usage);
println!(
" {:<34} {}",
invocation.trim_end().bright_cyan(),
command.about
);
}
if let Some(note) = section.note {
println!(" {}", note().dimmed());
}
println!();
}
}
fn print_bulk_help(&self) {
println!();
println!("{}", "BULK OPERATIONS".bright_purple().bold());
println!();
println!("{}", "Usage:".bright_cyan());
println!(" bulk <operation> <file> [type] [-o output.csv]");
println!();
println!("{}", "Operations:".bright_cyan());
for (op, about) in crate::ops::BULK_OPS {
println!(" {:<12} {}", op.bright_green(), about);
}
println!();
println!("{}", "Input File Formats:".bright_cyan());
println!("{}", crate::ops::BULK_INPUT_FORMATS);
println!("{}", "Output:".bright_cyan());
println!(" Results are written to CSV file (default: <input>_results.csv)");
println!(" Use -o to specify custom output path");
println!(" Each operation's CSV columns: see `seer bulk --help`");
println!();
println!("{}", "Examples:".bright_cyan());
println!(" bulk status domains.txt");
println!(" bulk lookup domains.csv -o results.csv");
println!(" bulk dig domains.txt MX");
println!();
}
async fn execute_query(&mut self, query: Query) -> CommandResult {
match crate::query::run(query, &self.clients, &self.context.config, true).await {
Ok(outcome) => {
let format = self.context.output_format;
outcome
.present(|payload| println!("{}", crate::payload::serialize(payload, format)));
self.last_result = Some(outcome.payload);
CommandResult::Continue
}
Err(e) => CommandResult::Error(e.to_string()),
}
}
async fn execute_bulk(&mut self, args: &[&str]) -> CommandResult {
if args.is_empty()
|| args
.iter()
.any(|a| *a == "-h" || *a == "--help" || *a == "help")
{
self.print_bulk_help();
return CommandResult::Continue;
}
let parsed = match commands::parse_bulk_args(args) {
Ok(p) => p,
Err(e) => return CommandResult::Error(e),
};
let file_path = crate::utils::expand_tilde(&parsed.file);
let output_path = parsed
.output
.as_deref()
.map(crate::utils::expand_tilde)
.unwrap_or_else(|| crate::ops::default_bulk_output_path(&file_path));
let content = match crate::utils::read_bulk_input(&file_path) {
Ok(c) => c,
Err(e) => return CommandResult::Error(e),
};
let domains = match crate::ops::parse_bulk_domains(&content) {
Ok(d) => d,
Err(e) => return CommandResult::Error(e),
};
let operations = match crate::ops::build_bulk_operations(
&parsed.operation,
&domains,
parsed.record_type,
) {
Ok(operations) => operations,
Err(e) => return CommandResult::Error(e),
};
println!(
"{}",
crate::ops::bulk_banner(domains.len(), &parsed.operation)
);
let bar = crate::ops::bulk_bar(operations.len());
let executor = seer_core::BulkExecutor::from_config(&self.context.config);
let callback = crate::ops::bar_progress_callback(&bar);
let results = executor.execute(operations, Some(callback)).await;
crate::ops::finish_bulk_bar(&bar);
if let Err(e) = crate::ops::write_bulk_csv(&results, &parsed.operation, &output_path) {
return CommandResult::Error(e);
}
println!("\n");
println!("Results written to: {}", output_path.ctp_green());
println!("{}", crate::ops::bulk_summary(&results));
if results.iter().any(|r| !r.success) {
println!("\n{}", "Failures:".bright_red().bold());
for result in results.iter().filter(|r| !r.success) {
let domain = result.operation.domain();
println!(
" {} - {}",
domain,
result.error.as_deref().unwrap_or("Unknown error")
);
}
}
CommandResult::Continue
}
async fn execute_follow(&self, args: &[&str]) -> CommandResult {
let commands::FollowArgs {
domain,
iterations,
interval_minutes,
record_type,
nameserver,
changes_only,
} = match commands::parse_follow_args(args) {
Ok(p) => p,
Err(e) => return CommandResult::Error(e),
};
let nameserver = nameserver.or_else(|| self.context.config.nameserver.clone());
let config = match seer_core::FollowConfig::new(iterations, interval_minutes) {
Ok(cfg) => cfg.with_changes_only(changes_only),
Err(e) => return CommandResult::Error(e.to_string()),
};
println!(
"Following {} {} records ({} iterations, {} interval)",
domain.ctp_green(),
record_type.to_string().ctp_yellow(),
iterations.to_string().ctp_yellow(),
crate::utils::format_interval(interval_minutes)
);
println!(
"Press {} or {} to stop early\n",
"Esc".ctp_yellow(),
"Ctrl+C".ctp_yellow()
);
let result = crate::ops::run_live_follow(
&self.dns_follower,
&domain,
record_type,
nameserver.as_deref(),
config,
self.context.output_format,
false,
)
.await;
match result {
Ok(result) => {
let formatter = seer_core::output::get_formatter(self.context.output_format);
if result.interrupted {
println!("\n{}", "Follow interrupted by user".ctp_yellow());
}
println!("\n{}", formatter.format_follow(&result));
CommandResult::Continue
}
Err(e) => CommandResult::Error(e.to_string()),
}
}
async fn execute_watch(&mut self, args: &[&str]) -> CommandResult {
if let Some(action) = args.first() {
return match crate::ops::watch_edit(action, args.get(1).copied(), "watch").await {
Ok(message) => {
println!("{}", message);
CommandResult::Continue
}
Err(e) => CommandResult::Error(e),
};
}
let watchlist = match crate::ops::load_watchlist().await {
Ok(w) => w,
Err(e) => return CommandResult::Error(e),
};
if watchlist.domains.is_empty() {
println!("{}", crate::ops::watchlist_listing(&watchlist, "watch"));
return CommandResult::Continue;
}
let spinner = Spinner::new(&format!("Checking {} domains", watchlist.domains.len()));
let report =
seer_core::check_watchlist_with_config(&watchlist.domains, &self.context.config).await;
spinner.finish();
let formatter = seer_core::output::get_formatter(self.context.output_format);
println!("{}", formatter.format_watch(&report));
self.last_result = Some(crate::payload::Payload::Watch(Box::new(report)));
CommandResult::Continue
}
async fn execute_history(&self, args: &[&str]) -> CommandResult {
let result = if args.contains(&"--clear") {
crate::ops::clear_history()
.await
.map(|()| "Lookup history cleared".to_string())
} else {
crate::ops::load_history().await.map(|history| {
crate::ops::history_listing(&history, args.first().copied(), "lookup")
})
};
match result {
Ok(text) => {
println!("{}", text);
CommandResult::Continue
}
Err(e) => CommandResult::Error(e),
}
}
fn render_copy(&self, args: &[&str]) -> Result<(String, String), String> {
let arg = args.first().map(|s| s.to_lowercase());
let format = match arg.as_deref() {
None => seer_core::output::OutputFormat::Markdown,
Some("markdown") | Some("md") => seer_core::output::OutputFormat::Markdown,
Some("json") => seer_core::output::OutputFormat::Json,
Some("yaml") | Some("yml") => seer_core::output::OutputFormat::Yaml,
Some(other) => {
return Err(format!(
"Unknown format '{other}'. Usage: copy [markdown|json|yaml]"
))
}
};
let Some(payload) = &self.last_result else {
return Err("Nothing to copy yet — run a lookup first".to_string());
};
let text = crate::payload::serialize(payload, format);
let msg = format!(
"Copied {} result as {}",
payload.kind(),
format!("{format:?}").to_lowercase()
);
Ok((text, msg))
}
fn execute_copy(&self, args: &[&str]) -> CommandResult {
match self.render_copy(args) {
Ok((text, msg)) => {
if let Err(e) = crate::clipboard::copy(&text) {
return CommandResult::Error(format!("Clipboard write failed: {e}"));
}
println!("{}", msg.green());
CommandResult::Continue
}
Err(msg) => {
if msg.starts_with("Nothing to copy") {
println!("{}", msg.yellow());
CommandResult::Continue
} else {
CommandResult::Error(msg)
}
}
}
}
fn execute_set(&mut self, args: &[&str]) -> CommandResult {
if args.len() < 2 {
return CommandResult::Error(catalog::usage("set"));
}
match args[0] {
"output" => match args[1].parse() {
Ok(format) => {
self.context.output_format = format;
println!("Output format set to: {}", args[1]);
CommandResult::Continue
}
Err(_) => CommandResult::Error(
"Invalid format. Use: human, json, yaml, markdown".to_string(),
),
},
_ => CommandResult::Error(format!("Unknown setting: {}", args[0])),
}
}
}
#[cfg(test)]
mod copy_tests {
use super::*;
use seer_core::dns::{RecordData, RecordType};
fn repl_with_result() -> Repl {
let mut repl = Repl::new().expect("repl construction is offline");
repl.last_result = Some(crate::payload::Payload::Dns(vec![seer_core::DnsRecord {
name: "example.com".into(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: "1.2.3.4".into(),
},
}]));
repl
}
#[test]
fn copy_with_no_result_is_friendly() {
let repl = Repl::new().expect("repl construction is offline");
let err = repl.render_copy(&[]).unwrap_err();
assert!(err.contains("Nothing to copy"));
}
#[test]
fn copy_defaults_to_markdown() {
let repl = repl_with_result();
let (text, msg) = repl.render_copy(&[]).expect("copyable");
assert!(text.contains("1.2.3.4"));
assert!(msg.contains("dns") && msg.contains("markdown"));
}
#[test]
fn copy_accepts_explicit_formats() {
let repl = repl_with_result();
let (json, _) = repl.render_copy(&["json"]).expect("json");
assert!(json.trim_start().starts_with('['));
let (yaml, _) = repl.render_copy(&["yaml"]).expect("yaml");
assert!(!yaml.is_empty());
let (md, _) = repl.render_copy(&["markdown"]).expect("markdown");
assert!(md.contains("1.2.3.4"));
let (json_upper, _) = repl.render_copy(&["JSON"]).expect("JSON");
assert!(json_upper.trim_start().starts_with('['));
let (yml, _) = repl.render_copy(&["yml"]).expect("yml");
assert!(!yml.is_empty());
}
#[test]
fn copy_rejects_unknown_format() {
let repl = repl_with_result();
let err = repl.render_copy(&["bogus"]).unwrap_err();
assert!(err.contains("Usage: copy"));
}
#[test]
fn copy_after_doctor_copies_the_doctor_report_not_the_previous_result() {
use seer_core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
let mut repl = repl_with_result();
assert_eq!(repl.last_result.as_ref().expect("seeded").kind(), "dns");
repl.last_result = Some(crate::payload::Payload::Doctor(Box::new(
DoctorReport::from_checks(vec![DoctorCheck {
name: "dns".into(),
status: CheckStatus::Pass,
detail: "resolved example.com".into(),
latency_ms: Some(12),
}]),
)));
let (text, msg) = repl.render_copy(&[]).expect("doctor payload is copyable");
assert!(
msg.contains("doctor"),
"confirmation should name doctor: {msg}"
);
assert!(
text.contains("resolved example.com"),
"copied text should be the doctor report: {text}"
);
assert!(
!text.contains("1.2.3.4"),
"must not copy the stale DNS result: {text}"
);
}
#[test]
fn doctor_payload_serializes_in_every_copy_format() {
use seer_core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use seer_core::output::OutputFormat;
let payload = crate::payload::Payload::Doctor(Box::new(DoctorReport::from_checks(vec![
DoctorCheck {
name: "whois".into(),
status: CheckStatus::Warn,
detail: "port 43 slow".into(),
latency_ms: Some(4200),
},
])));
for format in [
OutputFormat::Markdown,
OutputFormat::Json,
OutputFormat::Yaml,
] {
let out = crate::payload::serialize(&payload, format);
assert!(
out.contains("whois"),
"{format:?} output missing check name: {out}"
);
}
}
#[test]
fn delegation_payload_is_copyable() {
let mut repl = repl_with_result();
repl.last_result = Some(crate::payload::Payload::Delegation(Box::new(
seer_core::dns::DelegationReport {
domain: "example.com".into(),
parent_zone: "com".into(),
parent_server_queried: vec!["a.gtld-servers.net".into()],
delegated_ns: vec!["a.iana-servers.net".into()],
zone_ns: vec!["a.iana-servers.net".into()],
in_sync: true,
missing_from_zone: vec![],
missing_from_parent: vec![],
lame: vec![],
warnings: vec![],
},
)));
let (text, msg) = repl.render_copy(&["json"]).expect("copyable");
assert!(msg.contains("delegation"), "got: {msg}");
assert!(text.contains("a.iana-servers.net"), "got: {text}");
assert!(!text.contains("1.2.3.4"), "must not copy the stale result");
}
#[tokio::test]
async fn failed_command_clears_the_copy_buffer() {
let mut repl = repl_with_result();
let result = repl.execute_line("delegation").await;
assert!(matches!(result, CommandResult::Error(_)), "got {result:?}");
assert!(repl.last_result.is_none(), "stale payload must be dropped");
let err = repl.render_copy(&[]).unwrap_err();
assert!(err.contains("Nothing to copy"), "got: {err}");
}
#[tokio::test]
async fn copy_and_set_errors_keep_the_copy_buffer() {
let mut repl = repl_with_result();
let result = repl.execute_line("copy bogus").await;
assert!(matches!(result, CommandResult::Error(_)), "got {result:?}");
assert!(repl.last_result.is_some());
let result = repl.execute_line("set output bogus").await;
assert!(matches!(result, CommandResult::Error(_)), "got {result:?}");
assert!(repl.last_result.is_some());
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn doctor_command_populates_last_result() {
let mut repl = Repl::new().expect("repl construction is offline");
let _ = repl.execute_line("doctor").await;
assert!(
matches!(repl.last_result, Some(crate::payload::Payload::Doctor(_))),
"doctor should store a copyable payload"
);
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn tld_command_populates_last_result() {
let mut repl = Repl::new().expect("repl construction is offline");
let _ = repl.execute_line("tld com").await;
assert!(
matches!(repl.last_result, Some(crate::payload::Payload::Tld(_))),
"tld should store a copyable payload"
);
}
}
#[cfg(test)]
mod delegation_repl_tests {
use super::*;
#[tokio::test]
async fn delegation_requires_a_domain() {
let mut repl = Repl::new().expect("repl construction is offline");
let result = repl.execute_line("delegation").await;
let CommandResult::Error(msg) = result else {
panic!("expected usage error, got {result:?}");
};
assert!(msg.contains("Usage: delegation"), "got: {msg}");
}
#[tokio::test]
async fn delegation_rejects_invalid_domain_before_network() {
let mut repl = Repl::new().expect("repl construction is offline");
let result = repl.execute_line("delegation bad..domain").await;
let CommandResult::Error(msg) = result else {
panic!("expected invalid-domain error, got {result:?}");
};
assert!(
msg.to_lowercase().contains("invalid") && msg.contains("bad..domain"),
"error should name the invalid input: {msg}"
);
}
}
#[cfg(test)]
mod session_tests {
use super::*;
use rustyline::history::History;
#[test]
fn leading_space_line_is_kept_out_of_history() {
let mut history = DefaultHistory::with_config(&editor_config());
history
.add(history_entry(" whois secret.com"))
.expect("history add");
assert!(history.is_empty(), "leading-space line must not be saved");
history
.add(history_entry("whois public.com "))
.expect("history add");
assert_eq!(history.len(), 1);
}
#[test]
fn explicit_format_carries_into_the_repl() {
let mut repl = Repl::new().expect("repl construction is offline");
repl.set_output_format(seer_core::output::OutputFormat::Json);
assert_eq!(
repl.context.output_format,
seer_core::output::OutputFormat::Json
);
assert!(repl.get_prompt().contains("[json]"));
}
#[tokio::test]
async fn repl_rejects_unknown_flags_before_network() {
let mut repl = Repl::new().expect("repl construction is offline");
for line in [
"drift example.com --recrod",
"subdomains example.com --reslove",
"takeover example.com --hots a.example.com",
"follow example.com 5 MXX",
] {
let result = repl.execute_line(line).await;
let CommandResult::Error(msg) = result else {
panic!("{line:?} should be rejected, got {result:?}");
};
assert!(
msg.contains("--recrod")
|| msg.contains("--reslove")
|| msg.contains("--hots")
|| msg.contains("MXX"),
"{line:?}: error should name the bad token: {msg}"
);
}
}
}
#[cfg(test)]
mod record_type_tests {
use super::*;
fn assert_rejects(result: &CommandResult) {
let CommandResult::Error(msg) = result else {
panic!("expected error for invalid record type, got {result:?}");
};
assert!(msg.contains("BOGUS"), "error should name the input: {msg}");
assert!(
msg.contains("MX") && msg.contains("SSHFP"),
"error should list valid types: {msg}"
);
}
#[tokio::test]
async fn dig_rejects_invalid_record_type() {
let mut repl = Repl::new().expect("repl construction is offline");
let result = repl.execute_line("dig example.com BOGUS").await;
assert_rejects(&result);
}
#[tokio::test]
async fn propagation_rejects_invalid_record_type() {
let mut repl = Repl::new().expect("repl construction is offline");
let result = repl.execute_line("propagation example.com BOGUS").await;
assert_rejects(&result);
}
#[tokio::test]
async fn compare_rejects_invalid_record_type() {
let mut repl = Repl::new().expect("repl construction is offline");
let result = repl
.execute_line("compare example.com BOGUS @8.8.8.8 @1.1.1.1")
.await;
assert_rejects(&result);
}
}