use seer_core::output::OutputFormat;
use seer_core::{RecordType, SeerConfig};
use super::catalog;
use crate::query::Query;
#[derive(Debug, Clone)]
pub struct CommandContext {
pub output_format: OutputFormat,
pub config: SeerConfig,
}
impl CommandContext {
pub fn new() -> Self {
let config = SeerConfig::load();
let output_format = config.output_format.parse().unwrap_or_default();
Self {
output_format,
config,
}
}
}
impl Default for CommandContext {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub enum CommandResult {
Continue,
Exit,
Error(String),
}
const UNBALANCED_QUOTE: &str = "Unbalanced quote in command line (close the \" or ' )";
pub fn tokenize_line(line: &str) -> Result<Vec<String>, String> {
if cfg!(windows) {
split_quoted_literal(line)
} else {
shlex::split(line).ok_or_else(|| UNBALANCED_QUOTE.to_string())
}
}
pub fn split_quoted_literal(line: &str) -> Result<Vec<String>, String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_token = false;
let mut quote: Option<char> = None;
for c in line.chars() {
match quote {
Some(q) if c == q => quote = None,
Some(_) => current.push(c),
None if c == '"' || c == '\'' => {
quote = Some(c);
in_token = true;
}
None if c.is_whitespace() => {
if in_token {
tokens.push(std::mem::take(&mut current));
in_token = false;
}
}
None => {
current.push(c);
in_token = true;
}
}
}
if quote.is_some() {
return Err(UNBALANCED_QUOTE.to_string());
}
if in_token {
tokens.push(current);
}
Ok(tokens)
}
pub fn parse_query(command: &str, parts: &[&str]) -> Result<Query, String> {
let args = &parts[1..];
let arg = || {
args.first()
.map(|a| a.to_string())
.ok_or_else(|| catalog::usage(command))
};
Ok(match catalog::canonical(command) {
"lookup" => Query::Lookup(arg()?),
"info" => Query::Info(arg()?),
"whois" => Query::Whois(arg()?),
"rdap" => Query::Rdap(arg()?),
"dig" => {
let domain = arg()?;
let (mut servers, record_type) = servers_and_type(&args[1..])?;
Query::Dig {
domain,
record_type,
server: servers.pop(),
}
}
"prop" => Query::Prop {
domain: arg()?,
record_type: match args.get(1) {
Some(name) => crate::try_parse_record_type(name)?,
None => RecordType::A,
},
},
"status" => Query::Status(arg()?),
"reverse" => Query::Reverse(arg()?),
"avail" => Query::Avail(arg()?),
"dnssec" => Query::Dnssec(arg()?),
"ssl" => Query::Ssl(arg()?),
"tld" => Query::Tld(arg()?),
"compare" => {
if args.len() < 3 {
return Err(catalog::usage(command));
}
let (servers, record_type) = servers_and_type(&args[1..])?;
let [server_a, server_b, ..] = servers.as_slice() else {
return Err("Need two nameservers (e.g., @8.8.8.8 @1.1.1.1)".to_string());
};
Query::Compare {
domain: args[0].to_string(),
record_type,
server_a: server_a.clone(),
server_b: server_b.clone(),
}
}
"subdomains" => {
let SubdomainsArgs {
domain,
resolve,
diff,
record,
} = parse_subdomains_args(args)?;
Query::Subdomains {
domain,
resolve,
diff,
record,
}
}
"diff" => match args {
[a, b, ..] => Query::Diff(a.to_string(), b.to_string()),
_ => return Err(catalog::usage(command)),
},
"drift" => {
let DriftArgs { domain, record } = parse_drift_args(args)?;
Query::Drift { domain, record }
}
"caa" => Query::Caa(arg()?),
"posture" => Query::Posture(arg()?),
"headers" => Query::Headers(arg()?),
"takeover" => {
let TakeoverArgs { domain, hosts } = parse_takeover_args(args)?;
Query::Takeover { domain, hosts }
}
"confusables" => Query::Confusables(arg()?),
"doctor" => Query::Doctor,
"delegation" => Query::Delegation(arg()?),
_ if command.contains('.') => Query::Lookup(parts[0].to_string()),
_ => {
return Err(format!(
"Unknown command: {}. Type 'help' for available commands.",
command
))
}
})
}
fn servers_and_type(args: &[&str]) -> Result<(Vec<String>, RecordType), String> {
let mut servers = Vec::new();
let mut record_type = RecordType::A;
for arg in args {
match arg.strip_prefix('@') {
Some(server) => servers.push(server.to_string()),
None => record_type = crate::try_parse_record_type(arg)?,
}
}
Ok((servers, record_type))
}
fn reject_unknown_flag(token: &str, usage: &str) -> Result<(), String> {
if token.starts_with('-') {
return Err(format!("Unknown option: {token}\n{usage}"));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubdomainsArgs {
pub domain: String,
pub resolve: bool,
pub diff: bool,
pub record: bool,
}
pub fn parse_subdomains_args(args: &[&str]) -> Result<SubdomainsArgs, String> {
let usage = catalog::usage("subdomains");
let mut domain: Option<String> = None;
let (mut resolve, mut diff, mut record) = (false, false, false);
for arg in args {
match *arg {
"--resolve" => resolve = true,
"--diff" => diff = true,
"--record" => record = true,
other => {
reject_unknown_flag(other, &usage)?;
if domain.is_some() {
return Err(format!("Unexpected argument: {other}\n{usage}"));
}
domain = Some(other.to_string());
}
}
}
let Some(domain) = domain else {
return Err(usage);
};
if resolve && (diff || record) {
return Err(format!(
"--resolve cannot be combined with --diff or --record\n{usage}"
));
}
Ok(SubdomainsArgs {
domain,
resolve,
diff,
record,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TakeoverArgs {
pub domain: String,
pub hosts: Vec<String>,
}
pub fn parse_takeover_args(args: &[&str]) -> Result<TakeoverArgs, String> {
let usage = catalog::usage("takeover");
let mut domain: Option<String> = None;
let mut hosts = Vec::new();
let mut i = 0;
while i < args.len() {
let arg = args[i];
if arg == "--host" {
let Some(host) = args.get(i + 1) else {
return Err(format!("Missing value after --host\n{usage}"));
};
hosts.push(host.to_string());
i += 2;
continue;
}
if let Some(host) = arg.strip_prefix("--host=") {
if host.is_empty() {
return Err(format!("Missing value after --host\n{usage}"));
}
hosts.push(host.to_string());
} else {
reject_unknown_flag(arg, &usage)?;
if domain.is_some() {
return Err(format!("Unexpected argument: {arg}\n{usage}"));
}
domain = Some(arg.to_string());
}
i += 1;
}
let Some(domain) = domain else {
return Err(usage);
};
Ok(TakeoverArgs { domain, hosts })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriftArgs {
pub domain: String,
pub record: bool,
}
pub fn parse_drift_args(args: &[&str]) -> Result<DriftArgs, String> {
let usage = catalog::usage("drift");
let mut domain: Option<String> = None;
let mut record = false;
for arg in args {
if *arg == "--record" {
record = true;
continue;
}
reject_unknown_flag(arg, &usage)?;
if domain.is_some() {
return Err(format!("Unexpected argument: {arg}\n{usage}"));
}
domain = Some(arg.to_string());
}
let Some(domain) = domain else {
return Err(usage);
};
Ok(DriftArgs { domain, record })
}
#[derive(Debug, Clone, PartialEq)]
pub struct BulkArgs {
pub operation: String,
pub file: String,
pub record_type: RecordType,
pub output: Option<String>,
}
pub fn parse_bulk_args(args: &[&str]) -> Result<BulkArgs, String> {
if args.len() < 2 {
return Err(format!(
"{}\nType 'bulk -h' for detailed help.",
catalog::usage("bulk")
));
}
let operation = args[0].to_string();
let file = args[1].to_string();
let mut record_type = RecordType::A;
let mut output: Option<String> = None;
let mut i = 2;
while i < args.len() {
match args[i] {
"-o" | "--output" => {
let Some(value) = args.get(i + 1) else {
return Err("Missing value after -o/--output".to_string());
};
output = Some(value.to_string());
i += 2;
}
other => {
record_type = crate::try_parse_record_type(other)?;
i += 1;
}
}
}
Ok(BulkArgs {
operation,
file,
record_type,
output,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct FollowArgs {
pub domain: String,
pub iterations: usize,
pub interval_minutes: f64,
pub record_type: RecordType,
pub nameserver: Option<String>,
pub changes_only: bool,
}
pub fn parse_follow_args(args: &[&str]) -> Result<FollowArgs, String> {
let usage = catalog::usage("follow");
let Some(domain) = args.first() else {
return Err(usage);
};
let mut parsed = FollowArgs {
domain: domain.to_string(),
iterations: 10,
interval_minutes: 1.0,
record_type: RecordType::A,
nameserver: None,
changes_only: false,
};
let mut iterations_set = false;
for arg in &args[1..] {
if let Some(ns) = arg.strip_prefix('@') {
parsed.nameserver = Some(ns.to_string());
} else if *arg == "--changes-only" {
parsed.changes_only = true;
} else if arg.starts_with("--") {
return Err(format!("Unknown option: {arg}\n{usage}"));
} else if let Ok(n) = arg.parse::<usize>() {
if !iterations_set {
parsed.iterations = n;
iterations_set = true;
} else {
parsed.interval_minutes = n as f64;
}
} else if let Ok(mins) = arg.parse::<f64>() {
parsed.interval_minutes = mins;
} else {
parsed.record_type = crate::try_parse_record_type(arg)?;
}
}
Ok(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bulk_requires_operation_and_file() {
assert!(parse_bulk_args(&[]).is_err());
assert!(parse_bulk_args(&["status"]).is_err());
}
#[test]
fn bulk_minimal_defaults() {
let got = parse_bulk_args(&["status", "domains.txt"]).expect("valid");
assert_eq!(
got,
BulkArgs {
operation: "status".into(),
file: "domains.txt".into(),
record_type: RecordType::A,
output: None,
}
);
}
#[test]
fn bulk_parses_record_type_positional() {
let got = parse_bulk_args(&["dig", "domains.txt", "MX"]).expect("valid");
assert_eq!(got.record_type, RecordType::MX);
let err = parse_bulk_args(&["dig", "domains.txt", "NOTATYPE"]).expect_err("must error");
assert!(err.contains("NOTATYPE"));
assert!(err.contains("valid types"));
}
#[test]
fn bulk_parses_output_flag_variants() {
for flag in ["-o", "--output"] {
let got = parse_bulk_args(&["lookup", "d.txt", flag, "out.csv"]).expect("valid");
assert_eq!(got.output.as_deref(), Some("out.csv"));
}
}
#[test]
fn bulk_output_flag_and_type_combine_in_any_order() {
let got = parse_bulk_args(&["dig", "d.txt", "-o", "out.csv", "TXT"]).expect("valid");
assert_eq!(got.record_type, RecordType::TXT);
assert_eq!(got.output.as_deref(), Some("out.csv"));
let got = parse_bulk_args(&["dig", "d.txt", "TXT", "-o", "out.csv"]).expect("valid");
assert_eq!(got.record_type, RecordType::TXT);
assert_eq!(got.output.as_deref(), Some("out.csv"));
}
#[test]
fn bulk_dangling_output_flag_errors() {
let err = parse_bulk_args(&["lookup", "d.txt", "-o"]).expect_err("must error");
assert!(err.contains("-o/--output"), "got: {err}");
}
#[test]
fn follow_requires_domain() {
assert!(parse_follow_args(&[]).is_err());
}
#[test]
fn follow_defaults() {
let got = parse_follow_args(&["example.com"]).expect("valid");
assert_eq!(
got,
FollowArgs {
domain: "example.com".into(),
iterations: 10,
interval_minutes: 1.0,
record_type: RecordType::A,
nameserver: None,
changes_only: false,
}
);
}
#[test]
fn follow_first_int_is_iterations_second_is_interval() {
let got = parse_follow_args(&["example.com", "5", "2"]).expect("valid");
assert_eq!(got.iterations, 5);
assert_eq!(got.interval_minutes, 2.0);
}
#[test]
fn follow_explicit_default_iterations_still_consumes_slot() {
let got = parse_follow_args(&["example.com", "10", "5"]).expect("valid");
assert_eq!(got.iterations, 10);
assert_eq!(got.interval_minutes, 5.0);
}
#[test]
fn follow_float_is_always_interval() {
let got = parse_follow_args(&["example.com", "0.5"]).expect("valid");
assert_eq!(got.iterations, 10);
assert_eq!(got.interval_minutes, 0.5);
}
#[test]
fn follow_parses_type_server_and_changes_only() {
let got =
parse_follow_args(&["example.com", "MX", "@8.8.8.8", "--changes-only"]).expect("valid");
assert_eq!(got.record_type, RecordType::MX);
assert_eq!(got.nameserver.as_deref(), Some("8.8.8.8"));
assert!(got.changes_only);
}
#[test]
fn follow_full_argument_soup() {
let got =
parse_follow_args(&["example.com", "20", "0.5", "AAAA", "@1.1.1.1"]).expect("valid");
assert_eq!(got.iterations, 20);
assert_eq!(got.interval_minutes, 0.5);
assert_eq!(got.record_type, RecordType::AAAA);
assert_eq!(got.nameserver.as_deref(), Some("1.1.1.1"));
assert!(!got.changes_only);
}
#[test]
fn follow_rejects_mistyped_record_type() {
let err = parse_follow_args(&["example.com", "5", "MXX"]).expect_err("must error");
assert!(err.contains("MXX"), "error should name the input: {err}");
assert!(err.contains("valid types"), "got: {err}");
}
#[test]
fn follow_rejects_unknown_long_flag() {
let err =
parse_follow_args(&["example.com", "--chnages-only"]).expect_err("typo'd flag errors");
assert!(err.contains("--chnages-only"), "got: {err}");
assert!(err.contains("Usage: follow"), "got: {err}");
}
#[test]
fn every_query_command_parses_or_reports_its_usage() {
let builtins = [
"help", "exit", "bulk", "follow", "watch", "history", "set", "copy", "clear",
];
for command in catalog::commands().filter(|c| !builtins.contains(&c.name)) {
if let Err(e) = parse_query(command.name, &[command.name]) {
assert!(
e.starts_with(&catalog::usage(command.name)),
"{}: {e}",
command.name
);
}
}
assert!(matches!(
parse_query("doctor", &["doctor"]),
Ok(Query::Doctor)
));
}
#[test]
fn parse_query_handles_aliases_servers_and_bare_domains() {
assert!(matches!(
parse_query("dns", &["DNS", "example.com", "MX", "@1.1.1.1"]),
Ok(Query::Dig { record_type: RecordType::MX, server: Some(ref s), .. }) if s == "1.1.1.1"
));
assert!(matches!(
parse_query("compare", &["compare", "example.com", "@8.8.8.8", "@1.1.1.1"]),
Ok(Query::Compare { ref server_a, ref server_b, .. })
if server_a == "8.8.8.8" && server_b == "1.1.1.1"
));
let err = parse_query("compare", &["compare", "example.com", "MX", "@8.8.8.8"])
.err()
.expect("one server");
assert!(err.starts_with("Need two nameservers"), "got: {err}");
assert!(matches!(
parse_query("example.com", &["Example.COM"]),
Ok(Query::Lookup(ref d)) if d == "Example.COM"
));
let err = parse_query("bogus", &["bogus"]).err().expect("unknown");
assert!(err.starts_with("Unknown command: bogus"), "got: {err}");
}
#[test]
fn subdomains_parses_flags_in_any_position() {
let got = parse_subdomains_args(&["--diff", "example.com", "--record"]).expect("valid");
assert_eq!(
got,
SubdomainsArgs {
domain: "example.com".into(),
resolve: false,
diff: true,
record: true,
}
);
let got = parse_subdomains_args(&["example.com", "--resolve"]).expect("valid");
assert!(got.resolve && !got.diff && !got.record);
}
#[test]
fn subdomains_rejects_unknown_flags_conflicts_and_missing_domain() {
assert!(parse_subdomains_args(&[]).is_err());
assert!(parse_subdomains_args(&["--diff"]).is_err());
let err = parse_subdomains_args(&["example.com", "--reslove"]).expect_err("typo");
assert!(err.contains("--reslove"), "got: {err}");
let err = parse_subdomains_args(&["example.com", "--resolve", "--diff"])
.expect_err("conflict, as in the CLI");
assert!(err.contains("--resolve"), "got: {err}");
assert!(parse_subdomains_args(&["a.com", "b.com"]).is_err());
}
#[test]
fn takeover_collects_repeatable_host_flags() {
let got = parse_takeover_args(&[
"example.com",
"--host",
"a.example.com",
"--host=b.example.com",
])
.expect("valid");
assert_eq!(got.domain, "example.com");
assert_eq!(got.hosts, vec!["a.example.com", "b.example.com"]);
assert!(parse_takeover_args(&["example.com"])
.expect("valid")
.hosts
.is_empty());
}
#[test]
fn takeover_rejects_dangling_host_and_unknown_flags() {
let err = parse_takeover_args(&["example.com", "--host"]).expect_err("dangling");
assert!(err.contains("--host"), "got: {err}");
let err = parse_takeover_args(&["example.com", "--hots", "a.x"]).expect_err("typo");
assert!(err.contains("--hots"), "got: {err}");
assert!(parse_takeover_args(&[]).is_err());
}
#[test]
fn drift_accepts_record_and_rejects_typos() {
let got = parse_drift_args(&["example.com", "--record"]).expect("valid");
assert_eq!(
got,
DriftArgs {
domain: "example.com".into(),
record: true,
}
);
let err = parse_drift_args(&["example.com", "--recrod"]).expect_err("typo");
assert!(err.contains("--recrod"), "got: {err}");
assert!(parse_drift_args(&[]).is_err());
}
#[test]
fn literal_splitter_keeps_windows_backslashes() {
let got = split_quoted_literal(r"bulk lookup C:\Users\me\d.txt -o C:\Reports\out.csv")
.expect("valid");
assert_eq!(
got,
vec![
"bulk",
"lookup",
r"C:\Users\me\d.txt",
"-o",
r"C:\Reports\out.csv"
]
);
}
#[test]
fn literal_splitter_honors_quotes_around_paths_with_spaces() {
let got = split_quoted_literal(r#"bulk lookup "C:\My Lists\d.txt" -o 'D:\out dir\r.csv'"#)
.expect("valid");
assert_eq!(
got,
vec![
"bulk",
"lookup",
r"C:\My Lists\d.txt",
"-o",
r"D:\out dir\r.csv"
]
);
let got = split_quoted_literal(r#"bulk lookup "C:\dir\""#).expect("valid");
assert_eq!(got, vec!["bulk", "lookup", r"C:\dir\"]);
}
#[test]
fn literal_splitter_joins_adjacent_runs_and_keeps_empty_quotes() {
assert_eq!(
split_quoted_literal(r#"a"b c"d '' e"#).expect("valid"),
vec!["ab cd", "", "e"]
);
assert_eq!(
split_quoted_literal(" \t ").expect("valid"),
Vec::<String>::new()
);
}
#[test]
fn literal_splitter_rejects_unbalanced_quote() {
let err = split_quoted_literal(r#"bulk lookup "C:\unterminated"#).unwrap_err();
assert!(err.to_lowercase().contains("quote"), "got: {err}");
}
#[test]
fn tokenize_splits_plain_words_like_whitespace_split() {
let got = tokenize_line("bulk lookup domains.txt").expect("valid");
assert_eq!(got, vec!["bulk", "lookup", "domains.txt"]);
}
#[test]
fn tokenize_keeps_quoted_path_with_spaces_intact() {
let got = tokenize_line("bulk lookup \"Domain Lists/prod.txt\"").expect("valid");
assert_eq!(got, vec!["bulk", "lookup", "Domain Lists/prod.txt"]);
}
#[test]
fn tokenize_supports_single_quotes() {
let got = tokenize_line("bulk lookup 'my domains.txt' -o 'out dir/r.csv'").expect("valid");
assert_eq!(
got,
vec!["bulk", "lookup", "my domains.txt", "-o", "out dir/r.csv"]
);
}
#[test]
fn tokenize_rejects_unbalanced_quote_with_usage_hint() {
let err = tokenize_line("bulk lookup \"unterminated").unwrap_err();
assert!(
err.to_lowercase().contains("quote"),
"error should mention quoting: {err}"
);
}
#[test]
fn tokenize_empty_line_yields_no_tokens() {
assert_eq!(tokenize_line(" ").expect("valid"), Vec::<String>::new());
}
}