use crossterm::event::{KeyCode, KeyEvent};
use seer_core::bulk::{BulkOperation, BulkResult};
use crate::tui::action::{Action, BulkParams, EditTarget};
use crate::tui::panes::PaneOutcome;
pub const OPS: &[&str] = &["lookup", "status", "dig", "avail", "info"];
#[derive(Default)]
pub struct BulkState {
pub op_idx: usize,
pub domains: String,
pub rows: Vec<BulkResult>,
pub running: bool,
pub note: Option<String>,
pub gen: u64,
pub total: usize,
}
pub fn parse_domains_input(s: &str) -> Vec<String> {
s.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#'))
.flat_map(|line| line.split([',', ' ', '\t']))
.map(str::trim)
.filter(|t| !t.is_empty() && !t.starts_with('#') && t.contains('.'))
.map(str::to_string)
.take(50)
.collect()
}
impl BulkState {
pub fn op(&self) -> &str {
OPS[self.op_idx]
}
pub fn push(&mut self, r: BulkResult) {
self.rows.push(r);
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<PaneOutcome> {
match key.code {
KeyCode::Char('o') => {
self.op_idx = (self.op_idx + 1) % OPS.len();
Some(PaneOutcome::None)
}
KeyCode::Char('d') => Some(PaneOutcome::EditField(EditTarget::BulkDomains)),
KeyCode::Char('r') | KeyCode::Enter => {
let domains = parse_domains_input(&self.domains);
if domains.is_empty() {
return Some(PaneOutcome::Toast {
tone: "info",
msg: "enter domains first (d)",
});
}
self.rows.clear();
self.running = true;
self.note = None;
self.gen += 1;
self.total = domains.len();
Some(PaneOutcome::Action(Action::StartBulk(BulkParams {
op: self.op().to_string(),
domains,
gen: self.gen,
})))
}
KeyCode::Char('f') => Some(PaneOutcome::EditField(EditTarget::BulkPath)),
KeyCode::Char('e') => {
if self.rows.is_empty() {
Some(PaneOutcome::None)
} else {
let path = format!("seer-bulk-{}.csv", self.op());
let contents = self.to_csv();
Some(PaneOutcome::Action(Action::WriteCsv { path, contents }))
}
}
_ => None,
}
}
pub fn to_csv(&self) -> String {
use crate::utils::{escape_csv_field, get_domain_from_operation};
let mut out = String::from("domain,success,error,duration_ms\n");
for r in &self.rows {
let domain = escape_csv_field(&get_domain_from_operation(&r.operation));
let err = escape_csv_field(r.error.as_deref().unwrap_or(""));
out.push_str(&format!(
"{},{},{},{}\n",
domain, r.success, err, r.duration_ms
));
}
out
}
}
pub fn op_domain(op: &BulkOperation) -> &str {
match op {
BulkOperation::Whois { domain }
| BulkOperation::Rdap { domain }
| BulkOperation::Lookup { domain }
| BulkOperation::Status { domain }
| BulkOperation::Avail { domain }
| BulkOperation::Info { domain }
| BulkOperation::Ssl { domain }
| BulkOperation::Dns { domain, .. }
| BulkOperation::Propagation { domain, .. } => domain.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn make_lookup_result(domain: &str) -> BulkResult {
BulkResult {
operation: BulkOperation::Lookup {
domain: domain.to_string(),
},
success: true,
data: None,
error: None,
duration_ms: 5,
}
}
#[test]
fn default_state_is_zeroed() {
let s = BulkState::default();
assert_eq!(s.op_idx, 0);
assert!(s.domains.is_empty());
assert!(!s.running);
assert!(s.rows.is_empty());
assert!(s.note.is_none());
}
#[test]
fn o_cycles_op() {
let mut s = BulkState::default();
assert_eq!(s.op(), "lookup");
let out = s.handle_key(key(KeyCode::Char('o')));
assert!(matches!(out, Some(PaneOutcome::None)));
assert_eq!(s.op(), "status");
for _ in 0..OPS.len() - 1 {
s.handle_key(key(KeyCode::Char('o')));
}
assert_eq!(s.op(), "lookup");
}
#[test]
fn parse_domains_input_splits_and_filters() {
let got = parse_domains_input("google.com, github.com\nrust-lang.org bad\n# comment.skip");
assert_eq!(got, vec!["google.com", "github.com", "rust-lang.org"]);
}
#[test]
fn parse_domains_input_caps_at_50() {
let many = (0..80)
.map(|i| format!("d{i}.com"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(parse_domains_input(&many).len(), 50);
}
#[test]
fn d_opens_domains_field() {
let mut s = BulkState::default();
let out = s.handle_key(key(KeyCode::Char('d')));
assert!(matches!(
out,
Some(PaneOutcome::EditField(EditTarget::BulkDomains))
));
}
#[test]
fn r_with_empty_domains_toasts_and_does_not_run() {
let mut s = BulkState::default();
let out = s.handle_key(key(KeyCode::Char('r')));
assert!(!s.running, "empty run must not start");
assert!(matches!(out, Some(PaneOutcome::Toast { .. })));
}
#[test]
fn r_with_domains_starts_run_with_parsed_list() {
let mut s = BulkState::default();
s.domains = "a.com b.com".into();
let out = s.handle_key(key(KeyCode::Char('r')));
assert!(s.running);
assert_eq!(s.gen, 1);
match out {
Some(PaneOutcome::Action(Action::StartBulk(p))) => {
assert_eq!(p.op, "lookup");
assert_eq!(p.domains, vec!["a.com", "b.com"]);
assert_eq!(p.gen, 1);
}
other => panic!("expected StartBulk, got {other:?}"),
}
}
#[test]
fn f_returns_edit_field_bulk_path() {
let mut s = BulkState::default();
let out = s.handle_key(key(KeyCode::Char('f')));
assert!(matches!(
out,
Some(PaneOutcome::EditField(EditTarget::BulkPath))
));
}
#[test]
fn e_with_no_rows_returns_pane_outcome_none() {
let mut s = BulkState::default();
let out = s.handle_key(key(KeyCode::Char('e')));
assert!(matches!(out, Some(PaneOutcome::None)));
}
#[test]
fn e_with_rows_returns_write_csv() {
let mut s = BulkState::default();
s.rows.push(make_lookup_result("x.com"));
let out = s.handle_key(key(KeyCode::Char('e')));
assert!(matches!(
out,
Some(PaneOutcome::Action(Action::WriteCsv { .. }))
));
}
#[test]
fn esc_returns_none() {
let mut s = BulkState::default();
let out = s.handle_key(key(KeyCode::Esc));
assert!(out.is_none(), "Esc must not be swallowed");
}
#[test]
fn to_csv_produces_header_and_row() {
let mut s = BulkState::default();
s.rows.push(make_lookup_result("x.com"));
let csv = s.to_csv();
assert!(csv.starts_with("domain,success,error,duration_ms\n"));
assert!(csv.contains("x.com,true,,5"));
}
#[test]
fn to_csv_escapes_commas_in_error() {
let mut s = BulkState::default();
s.rows.push(BulkResult {
operation: BulkOperation::Lookup {
domain: "bad.com".to_string(),
},
success: false,
data: None,
error: Some("timeout, retry failed".to_string()),
duration_ms: 10,
});
let csv = s.to_csv();
assert!(
csv.contains("\"timeout, retry failed\""),
"error with comma must be quoted, got: {csv}"
);
}
#[test]
fn to_csv_protects_against_formula_injection() {
let mut s = BulkState::default();
s.rows.push(BulkResult {
operation: BulkOperation::Lookup {
domain: "=cmd.com".to_string(),
},
success: false,
data: None,
error: Some("=HYPERLINK(\"evil\")".to_string()),
duration_ms: 10,
});
let csv = s.to_csv();
assert!(csv.contains("'=cmd.com"), "domain must be guarded: {csv}");
assert!(
csv.contains("\"'=HYPERLINK(\"\"evil\"\")\""),
"error must be guarded + quoted: {csv}"
);
}
#[test]
fn op_domain_works_for_all_variants() {
use seer_core::RecordType;
assert_eq!(
op_domain(&BulkOperation::Whois {
domain: "a.com".into()
}),
"a.com"
);
assert_eq!(
op_domain(&BulkOperation::Dns {
domain: "b.com".into(),
record_type: RecordType::A
}),
"b.com"
);
assert_eq!(
op_domain(&BulkOperation::Propagation {
domain: "c.com".into(),
record_type: RecordType::MX
}),
"c.com"
);
}
}