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),
}
pub fn tokenize_line(line: &str) -> Result<Vec<String>, String> {
shlex::split(line)
.ok_or_else(|| "Unbalanced quote in command line (close the \" or ' )".to_string())
}
#[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 => {
if let Ok(rt) = other.parse() {
record_type = rt;
}
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> {
let Some(domain) = args.first() else {
return Err(
"Usage: follow <domain> [iterations] [interval_minutes] [type] [@server] [--changes-only]"
.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 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 if let Ok(rt) = arg.parse() {
parsed.record_type = rt;
}
}
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 got = parse_bulk_args(&["dig", "domains.txt", "NOTATYPE"]).expect("valid");
assert_eq!(got.record_type, RecordType::A);
}
#[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 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());
}
}