use seer_core::output::OutputFormat;
use seer_core::SeerConfig;
#[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)
}
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,
}
const SUBDOMAINS_USAGE: &str = "Usage: subdomains <domain> [--resolve | --diff] [--record]";
pub fn parse_subdomains_args(args: &[&str]) -> Result<SubdomainsArgs, String> {
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, SUBDOMAINS_USAGE)?;
if domain.is_some() {
return Err(format!("Unexpected argument: {other}\n{SUBDOMAINS_USAGE}"));
}
domain = Some(other.to_string());
}
}
}
let Some(domain) = domain else {
return Err(SUBDOMAINS_USAGE.to_string());
};
if resolve && (diff || record) {
return Err(format!(
"--resolve cannot be combined with --diff or --record\n{SUBDOMAINS_USAGE}"
));
}
Ok(SubdomainsArgs {
domain,
resolve,
diff,
record,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TakeoverArgs {
pub domain: String,
pub hosts: Vec<String>,
}
const TAKEOVER_USAGE: &str = "Usage: takeover <domain> [--host <host>]...";
pub fn parse_takeover_args(args: &[&str]) -> Result<TakeoverArgs, String> {
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{TAKEOVER_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{TAKEOVER_USAGE}"));
}
hosts.push(host.to_string());
} else {
reject_unknown_flag(arg, TAKEOVER_USAGE)?;
if domain.is_some() {
return Err(format!("Unexpected argument: {arg}\n{TAKEOVER_USAGE}"));
}
domain = Some(arg.to_string());
}
i += 1;
}
let Some(domain) = domain else {
return Err(TAKEOVER_USAGE.to_string());
};
Ok(TakeoverArgs { domain, hosts })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriftArgs {
pub domain: String,
pub record: bool,
}
const DRIFT_USAGE: &str = "Usage: drift <domain> [--record]";
pub fn parse_drift_args(args: &[&str]) -> Result<DriftArgs, String> {
let mut domain: Option<String> = None;
let mut record = false;
for arg in args {
if *arg == "--record" {
record = true;
continue;
}
reject_unknown_flag(arg, DRIFT_USAGE)?;
if domain.is_some() {
return Err(format!("Unexpected argument: {arg}\n{DRIFT_USAGE}"));
}
domain = Some(arg.to_string());
}
let Some(domain) = domain else {
return Err(DRIFT_USAGE.to_string());
};
Ok(DriftArgs { domain, record })
}
#[derive(Debug, Clone, PartialEq)]
pub struct BulkArgs {
pub operation: String,
pub file: String,
pub record_type: seer_core::RecordType,
pub output: Option<String>,
}
pub fn parse_bulk_args(args: &[&str]) -> Result<BulkArgs, String> {
if args.len() < 2 {
return Err(
"Usage: bulk <operation> <file> [type] [-o output.csv]\nType 'bulk -h' for detailed help."
.to_string(),
);
}
let operation = args[0].to_string();
let file = args[1].to_string();
let mut record_type = seer_core::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: seer_core::RecordType,
pub nameserver: Option<String>,
pub changes_only: bool,
}
pub fn parse_follow_args(args: &[&str]) -> Result<FollowArgs, String> {
const FOLLOW_USAGE: &str =
"Usage: follow <domain> [iterations] [interval_minutes] [type] [@server] [--changes-only]";
let Some(domain) = args.first() else {
return Err(FOLLOW_USAGE.to_string());
};
let mut parsed = FollowArgs {
domain: domain.to_string(),
iterations: 10,
interval_minutes: 1.0,
record_type: seer_core::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{FOLLOW_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::*;
use seer_core::RecordType;
#[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 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());
}
}