use seer_core::bulk::BulkOperation;
use seer_core::RecordType;
pub const MAX_BULK_DOMAINS: usize = 1000;
pub const BULK_OPS: &[&str] = &[
"lookup",
"whois",
"rdap",
"dig",
"prop",
"status",
"avail",
"info",
"ssl",
"posture",
"confusables",
"caa",
];
pub const BULK_OPS_SUMMARY: &str =
"lookup, whois, rdap, dig/dns, prop, status, avail, info, ssl, posture, confusables, caa";
pub const BULK_OP_HELP: &str = "Operation type: lookup, whois, rdap, dig/dns, prop, status, avail, info, ssl, posture, confusables, caa";
pub fn bulk_operation_for(
op: &str,
domain: String,
record_type: RecordType,
) -> Option<BulkOperation> {
Some(match op {
"lookup" => BulkOperation::Lookup { domain },
"whois" => BulkOperation::Whois { domain },
"rdap" => BulkOperation::Rdap { domain },
"dig" | "dns" => BulkOperation::Dns {
domain,
record_type,
},
"propagation" | "prop" => BulkOperation::Propagation {
domain,
record_type,
},
"status" => BulkOperation::Status { domain },
"avail" => BulkOperation::Avail { domain },
"info" => BulkOperation::Info { domain },
"ssl" => BulkOperation::Ssl { domain },
"posture" => BulkOperation::Posture { domain },
"confusables" => BulkOperation::Confusables { domain },
"caa" => BulkOperation::Caa { domain },
_ => return None,
})
}
pub fn build_bulk_operations(
op: &str,
domains: &[String],
record_type: RecordType,
) -> Result<Vec<BulkOperation>, String> {
if bulk_operation_for(op, String::new(), record_type).is_none() {
return Err(format!(
"Unknown operation: {}. Use: {}",
op, BULK_OPS_SUMMARY
));
}
Ok(domains
.iter()
.filter_map(|d| bulk_operation_for(op, d.clone(), record_type))
.collect())
}
pub fn parse_bulk_domains(content: &str) -> Result<Vec<String>, String> {
let domains = seer_core::bulk::parse_domains_from_file(content);
if domains.is_empty() {
return Err(
"No valid domains found in file. Expected format: one domain per line, \
# for comments, or CSV (first column)"
.to_string(),
);
}
if domains.len() > MAX_BULK_DOMAINS {
return Err(format!(
"Bulk file contains {} domains, maximum is {}",
domains.len(),
MAX_BULK_DOMAINS
));
}
Ok(domains)
}
pub fn default_bulk_output_path(input_file: &str) -> String {
let input_path = std::path::Path::new(input_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()
}
pub fn bar_progress_callback(bar: &indicatif::ProgressBar) -> seer_core::bulk::ProgressCallback {
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());
})
}
pub async fn record_lookup_history(domain: &str, result: seer_core::LookupResult) {
let domain = domain.to_string();
tokio::task::spawn_blocking(move || {
let mut history = seer_core::LookupHistory::load();
history.record(&domain, result);
let _ = history.save();
})
.await
.ok();
}
pub struct DriftOutcome {
pub report: seer_core::DriftReport,
pub had_previous: bool,
}
pub async fn drift_check(
lookup: &seer_core::SmartLookup,
domain: &str,
record: bool,
) -> seer_core::Result<DriftOutcome> {
let result = lookup.lookup(domain).await?;
let domain_key = domain.to_string();
let previous = tokio::task::spawn_blocking(move || {
seer_core::LookupHistory::load()
.get(&domain_key)
.last()
.map(|e| e.result.clone())
})
.await
.ok()
.flatten();
let report = match &previous {
Some(prev) => seer_core::DriftReport::from_lookups(domain, prev, &result),
None => seer_core::DriftReport {
domain: domain.to_string(),
changes: Vec::new(),
},
};
if record {
record_lookup_history(domain, result).await;
}
Ok(DriftOutcome {
report,
had_previous: previous.is_some(),
})
}
pub fn no_baseline_note(domain: &str, record: bool) -> String {
format!(
"no previous snapshot for {} — {}",
domain,
if record {
"recorded a baseline"
} else {
"run with --record to establish a baseline"
}
)
}
pub struct SubdomainBaselineOutcome {
pub result: seer_core::SubdomainResult,
pub report: seer_core::SubdomainBaselineDiff,
}
pub async fn subdomain_baseline_check(
domain: &str,
record: bool,
) -> seer_core::Result<SubdomainBaselineOutcome> {
let enumerator = seer_core::SubdomainEnumerator::new();
let result = enumerator.enumerate(domain).await?;
let domain_key = result.domain.clone();
let names = result.subdomains.clone();
let source = result.source.clone();
let report = tokio::task::spawn_blocking(move || -> seer_core::Result<_> {
let mut baselines = seer_core::SubdomainBaselines::load();
let report = baselines.diff(&domain_key, &names);
if record {
baselines.record(&domain_key, &names, &source);
baselines.save()?;
}
Ok(report)
})
.await
.map_err(|e| seer_core::SeerError::ConfigError(format!("baseline task failed: {e}")))??;
Ok(SubdomainBaselineOutcome { result, report })
}
pub fn no_subdomain_baseline_note(domain: &str, record: bool) -> String {
format!(
"no subdomain baseline for {} — {}",
domain,
if record {
"recorded one from this run"
} else {
"run with --record to establish a baseline"
}
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_canonical_op_maps_to_an_operation() {
for op in BULK_OPS {
assert!(
bulk_operation_for(op, "example.com".to_string(), RecordType::A).is_some(),
"canonical op {op} must be accepted"
);
}
}
#[test]
fn aliases_map_like_their_canonical_names() {
let dig = bulk_operation_for("dig", "a.com".into(), RecordType::MX);
let dns = bulk_operation_for("dns", "a.com".into(), RecordType::MX);
assert!(matches!(dig, Some(BulkOperation::Dns { .. })));
assert!(matches!(dns, Some(BulkOperation::Dns { .. })));
let prop = bulk_operation_for("prop", "a.com".into(), RecordType::A);
let propagation = bulk_operation_for("propagation", "a.com".into(), RecordType::A);
assert!(matches!(prop, Some(BulkOperation::Propagation { .. })));
assert!(matches!(
propagation,
Some(BulkOperation::Propagation { .. })
));
}
#[test]
fn new_ops_map_to_their_variants() {
assert!(matches!(
bulk_operation_for("posture", "a.com".into(), RecordType::A),
Some(BulkOperation::Posture { .. })
));
assert!(matches!(
bulk_operation_for("confusables", "a.com".into(), RecordType::A),
Some(BulkOperation::Confusables { .. })
));
assert!(matches!(
bulk_operation_for("caa", "a.com".into(), RecordType::A),
Some(BulkOperation::Caa { .. })
));
}
#[test]
fn unknown_op_is_rejected_with_op_list() {
assert!(bulk_operation_for("bogus", "a.com".into(), RecordType::A).is_none());
let err = build_bulk_operations("bogus", &["a.com".to_string()], RecordType::A)
.expect_err("unknown op must error");
assert!(err.contains("Unknown operation: bogus"), "got: {err}");
for op in ["posture", "confusables", "caa", "ssl"] {
assert!(err.contains(op), "error should list {op}: {err}");
}
}
#[test]
fn build_bulk_operations_builds_one_per_domain() {
let domains = vec!["a.com".to_string(), "b.com".to_string()];
let ops = build_bulk_operations("caa", &domains, RecordType::A).expect("valid op");
assert_eq!(ops.len(), 2);
assert_eq!(ops[0].domain(), "a.com");
assert_eq!(ops[1].domain(), "b.com");
}
#[test]
fn parse_bulk_domains_rejects_empty_input() {
let err = parse_bulk_domains("# only a comment\n").expect_err("empty must error");
assert!(err.contains("No valid domains"), "got: {err}");
}
#[test]
fn parse_bulk_domains_rejects_oversized_lists() {
let content: String = (0..=MAX_BULK_DOMAINS)
.map(|i| format!("d{i}.com\n"))
.collect();
let err = parse_bulk_domains(&content).expect_err("oversized must error");
assert!(err.contains("maximum is"), "got: {err}");
}
#[test]
fn parse_bulk_domains_accepts_valid_lists() {
let domains = parse_bulk_domains("a.com\n# skip\nb.com\n").expect("valid list");
assert_eq!(domains, vec!["a.com", "b.com"]);
}
#[test]
fn default_bulk_output_path_derives_sibling_csv() {
assert_eq!(
default_bulk_output_path("domains.txt"),
"domains_results.csv"
);
let nested = std::path::Path::new("lists").join("domains.csv");
let expected = std::path::Path::new("lists")
.join("domains_results.csv")
.to_string_lossy()
.to_string();
assert_eq!(
default_bulk_output_path(&nested.to_string_lossy()),
expected
);
}
#[test]
fn no_baseline_note_reflects_record_flag() {
assert!(no_baseline_note("a.com", true).contains("recorded a baseline"));
assert!(no_baseline_note("a.com", false).contains("--record"));
}
#[test]
fn no_subdomain_baseline_note_reflects_record_flag() {
assert!(no_subdomain_baseline_note("a.com", true).contains("recorded one"));
assert!(no_subdomain_baseline_note("a.com", false).contains("--record"));
}
}