use std::collections::HashMap;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use seer_core::output::OutputFormat;
use seer_core::RecordType;
use crate::tui::action::{
Action, EditTarget, FetchReq, Focus, InputMode, LensData, LensKey, LensState, Msg,
};
use crate::tui::command::{self, CmdOutcome};
use crate::tui::event::{self, KeyAction};
use crate::tui::lenses;
use crate::tui::panes::{PaneOutcome, Panes};
use crate::tui::theme::Theme;
const TOAST_TICKS: u32 = 22;
const PAGE_ROWS: u16 = 10;
pub const SPIN: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[derive(Debug, Clone)]
pub struct Toast {
pub tone: String,
pub msg: String,
ticks_left: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Confirm {
ClearHistory,
Unwatch(String),
}
pub struct App {
pub lens: usize,
pub focus: Focus,
pub tab: usize,
pub sel: usize,
pub format: OutputFormat,
pub domain: Option<String>,
pub input_mode: InputMode,
pub help: bool,
pub should_quit: bool,
pub spin: usize,
pub toast: Option<Toast>,
pub panes: Panes,
pub raw_scroll: u16,
theme: Theme,
states: HashMap<LensKey, LensState>,
startup: Vec<Action>,
fetch_gen: HashMap<LensKey, u64>,
superseded: Vec<LensKey>,
lens_filter: HashMap<LensKey, String>,
last_req: HashMap<LensKey, FetchReq>,
confirm: Option<(Confirm, u32)>,
redraw: bool,
}
impl App {
pub fn new(domain: Option<String>) -> Self {
let mut app = Self {
lens: 0,
focus: Focus::Nav,
tab: 0,
sel: 0,
format: OutputFormat::Human,
domain: None,
input_mode: InputMode::Normal,
help: false,
should_quit: false,
spin: 0,
toast: None,
panes: Panes::default(),
raw_scroll: 0,
theme: Theme::frappe(),
states: HashMap::new(),
startup: Vec::new(),
fetch_gen: HashMap::new(),
superseded: Vec::new(),
lens_filter: HashMap::new(),
last_req: HashMap::new(),
confirm: None,
redraw: true,
};
if let Some(d) = domain {
let actions = match app.target_or_toast(&d) {
Some(target) => app.set_domain_and_fetch(target),
None => vec![],
};
app.startup.extend(actions);
}
app
}
pub fn take_startup_actions(&mut self) -> Vec<Action> {
self.superseded.clear();
std::mem::take(&mut self.startup)
}
pub fn take_redraw(&mut self) -> bool {
std::mem::take(&mut self.redraw)
}
fn animating(&self) -> bool {
matches!(self.state_of(self.lens), LensState::Loading)
|| match self.current_lens().key {
LensKey::Follow => self.panes.follow.running,
LensKey::Bulk => self.panes.bulk.running,
_ => false,
}
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn set_theme_by_name(&mut self, name: &str) -> bool {
match Theme::from_name(name) {
Some(t) => {
self.theme = t;
true
}
None => false,
}
}
pub fn current_lens(&self) -> &'static lenses::Lens {
&lenses::lenses()[self.lens]
}
pub fn state_of(&self, lens: usize) -> &LensState {
self.states
.get(&lenses::lenses()[lens].key)
.unwrap_or(&LensState::Idle)
}
pub fn set_toast(&mut self, tone: &str, msg: &str) {
self.toast = Some(Toast {
tone: tone.to_string(),
msg: msg.to_string(),
ticks_left: TOAST_TICKS,
});
}
fn goto_lens(&mut self, i: usize) {
self.lens = i;
self.tab = 0;
self.sel = 0;
self.raw_scroll = 0;
self.focus = Focus::Nav;
}
fn bump_fetch_gen(&mut self, key: LensKey) -> u64 {
let g = self.fetch_gen.entry(key).or_insert(0);
*g += 1;
if !self.superseded.contains(&key) {
self.superseded.push(key);
}
*g
}
fn fetch_action(&mut self, req: FetchReq) -> Action {
let key = req.lens_key();
let gen = self.bump_fetch_gen(key);
self.states.insert(key, LensState::Loading);
self.last_req.insert(key, req.clone());
Action::Fetch { req, gen }
}
pub fn pending_target(&self, lens: usize) -> Option<String> {
self.last_req
.get(&lenses::lenses()[lens].key)
.map(FetchReq::target)
}
pub fn active_filter(&self) -> String {
if let InputMode::Field {
target: EditTarget::LensFilter,
buf,
} = &self.input_mode
{
return buf.as_str().to_string();
}
self.lens_filter
.get(&self.current_lens().key)
.cloned()
.unwrap_or_default()
}
pub fn row_count(&self) -> usize {
let LensState::Loaded(data) = self.state_of(self.lens) else {
return 0;
};
let filter = self.active_filter();
let filtered = crate::tui::filter::apply(data, &filter);
let data = filtered.as_ref().unwrap_or(data);
match data {
LensData::Dig(r) => crate::tui::filter::dig_rows(r, &filter).count(),
LensData::Trace(t) => t.hops.len(),
LensData::Prop(p) => p.results.len(),
LensData::Reverse(r) => r.len(),
LensData::Watch(w) => w.results.len(),
LensData::History(e) => crate::tui::filter::history_rows(e, &filter).count(),
LensData::Subdomains(s) => s.subdomains.len(),
LensData::Takeover(t) => t.findings.len(),
_ => 0,
}
}
pub fn raw_view(&self) -> bool {
self.format != OutputFormat::Human
&& !self.current_lens().key.renders_from_panes()
&& matches!(self.state_of(self.lens), LensState::Loaded(_))
}
fn raw_line_count(&self) -> usize {
match self.state_of(self.lens) {
LensState::Loaded(data) => crate::payload::serialize(data, self.format).lines().count(),
_ => 0,
}
}
fn scroll_raw(&mut self, delta: i32) {
let last = self.raw_line_count().saturating_sub(1);
let last = u16::try_from(last).unwrap_or(u16::MAX);
let next = i32::from(self.raw_scroll) + delta;
self.raw_scroll = u16::try_from(next.max(0)).unwrap_or(u16::MAX).min(last);
}
fn target_or_toast(&mut self, raw: &str) -> Option<String> {
let raw = raw.trim();
if let Ok(ip) = raw.parse::<std::net::IpAddr>() {
return Some(ip.to_string());
}
match seer_core::validation::normalize_query_name(raw) {
Ok(target) => Some(target),
Err(e) => {
let msg = seer_core::output::sanitize_line(&e.to_string());
self.set_toast("fail", &msg);
None
}
}
}
fn set_domain_and_fetch(&mut self, target: String) -> Vec<Action> {
let mut actions = Vec::new();
if self.domain.as_deref() != Some(target.as_str()) {
self.states.clear();
self.last_req.clear();
self.panes.diff.a.clear();
self.panes.compare.domain = None;
let keys: Vec<LensKey> = self.fetch_gen.keys().copied().collect();
for key in keys {
self.bump_fetch_gen(key);
}
self.lens_filter.clear();
self.panes.dns.resolved_ip = None;
if self.panes.follow.running {
actions.push(Action::StopFollow);
}
self.panes.follow.reset_for_new_domain();
}
self.domain = Some(target);
self.sel = 0;
actions.extend(self.fetch_current(true));
actions
}
fn fetch_current(&mut self, explicit: bool) -> Option<Action> {
let lens = self.current_lens();
let key = lens.key;
if key == LensKey::History {
self.states.remove(&key);
}
let want = match self.domain.clone() {
Some(domain) => self.default_req(key, &domain),
None => match key {
LensKey::History => Some(FetchReq::History),
LensKey::Watch => Some(FetchReq::Watch),
_ => None,
},
};
if matches!(
self.states.get(&key),
Some(LensState::Loaded(_) | LensState::Loading)
) {
let stale = self.last_req.get(&key).is_some_and(|prev| {
prev.tab() != self.tab || want.as_ref().is_some_and(|w| w != prev)
});
if !stale {
return None;
}
self.states.remove(&key);
self.last_req.remove(&key);
self.bump_fetch_gen(key);
}
if lens.heavy && !explicit {
return None;
}
want.map(|req| self.fetch_action(req))
}
fn default_req(&self, key: LensKey, domain: &str) -> Option<FetchReq> {
let d = domain.to_string();
Some(match key {
LensKey::Overview => FetchReq::Overview(d),
LensKey::Whois => FetchReq::Whois(d),
LensKey::Rdap => match self.tab {
0 => FetchReq::RdapDomain(d),
1 => FetchReq::RdapIp(self.panes.dns.resolved_ip.clone()?),
_ => return None, },
LensKey::Dns => match self.tab {
1 => FetchReq::Dnssec(d),
2 => FetchReq::Compare {
domain: self.panes.compare.domain.clone().unwrap_or(d),
record_type: RecordType::A,
a: self.panes.compare.a.clone(),
b: self.panes.compare.b.clone(),
},
3 => FetchReq::Trace {
domain: d,
record_type: self.panes.dns.record_type,
},
_ => FetchReq::Dns {
domain: d,
record_type: self.panes.dns.record_type,
nameserver: self.panes.dns.nameserver(),
},
},
LensKey::Ssl => FetchReq::Ssl(d),
LensKey::Status => FetchReq::Status(d),
LensKey::Propagation => FetchReq::Prop(d),
LensKey::Reverse => FetchReq::Reverse(d),
LensKey::Avail => FetchReq::Avail(d),
LensKey::Tld => FetchReq::Tld(self.panes.tld.current()),
LensKey::Diff => return None, LensKey::Watch => FetchReq::Watch,
LensKey::History => FetchReq::History,
LensKey::Subdomains => FetchReq::Subdomains(d),
LensKey::Headers => FetchReq::Headers(d),
LensKey::Takeover => FetchReq::Takeover(d),
LensKey::Follow | LensKey::Bulk => return None, })
}
fn refetch_for_tab(&mut self) -> Vec<Action> {
let lens = self.current_lens();
if lens.tabs.is_empty() {
return vec![];
}
let key = lens.key;
self.states.remove(&key);
self.raw_scroll = 0;
self.lens_filter.remove(&key);
self.bump_fetch_gen(key);
self.fetch_with_current(false)
}
fn extract_resolved_ip_if_needed(&mut self, data: &LensData) {
match data {
LensData::Dig(result) => {
let ip = result.records().find_map(|r| {
if let seer_core::dns::RecordData::A { address } = &r.data {
Some(address.clone())
} else {
None
}
});
if ip.is_some() {
self.panes.dns.resolved_ip = ip;
}
}
LensData::Status(s) => {
let ip = s
.dns_resolution
.as_ref()
.and_then(|d| d.a_records.first().cloned());
if ip.is_some() {
self.panes.dns.resolved_ip = ip;
}
}
_ => {}
}
}
pub fn update(&mut self, msg: Msg) -> Vec<Action> {
let tick = matches!(msg, Msg::Tick);
let animating = tick && self.animating();
let had_toast = self.toast.is_some();
let actions = self.step(msg);
if !tick || animating || had_toast != self.toast.is_some() {
self.redraw = true;
}
let mut out: Vec<Action> = self.superseded.drain(..).map(Action::CancelFetch).collect();
out.extend(actions);
out
}
fn step(&mut self, msg: Msg) -> Vec<Action> {
match msg {
Msg::Tick => {
self.spin = (self.spin + 1) % SPIN.len();
if let Some(t) = &mut self.toast {
t.ticks_left = t.ticks_left.saturating_sub(1);
if t.ticks_left == 0 {
self.toast = None;
}
}
if let Some((_, ticks)) = &mut self.confirm {
*ticks = ticks.saturating_sub(1);
if *ticks == 0 {
self.confirm = None;
}
}
vec![]
}
Msg::Data { lens, gen, result } => {
let current_gen = self.fetch_gen.get(&lens).copied().unwrap_or(0);
if current_gen == gen {
let new_state = match result {
Ok(data) => {
self.extract_resolved_ip_if_needed(&data);
LensState::Loaded(data)
}
Err(e) => LensState::Error(e),
};
self.states.insert(lens, new_state);
if lens == self.current_lens().key {
self.sel = self.sel.min(self.row_count().saturating_sub(1));
self.raw_scroll = 0;
}
}
vec![]
}
Msg::CopyResult { ok, label } => {
if ok {
self.set_toast("ok", &format!("copied {label}"));
} else {
self.set_toast("fail", &label);
}
vec![]
}
Msg::Toast { tone, msg } => {
self.set_toast(tone, &msg);
vec![]
}
Msg::FollowStep { gen, it } => {
if gen == self.panes.follow.gen {
self.panes.follow.push(*it);
}
vec![]
}
Msg::FollowDone { gen } => {
if gen == self.panes.follow.gen {
self.panes.follow.running = false;
}
vec![]
}
Msg::BulkStep { gen, result } => {
if gen == self.panes.bulk.gen {
self.panes.bulk.push(*result);
}
vec![]
}
Msg::BulkDone { gen } => {
if gen == self.panes.bulk.gen {
self.panes.bulk.running = false;
}
vec![]
}
Msg::Input(Event::Key(key)) if key.kind == KeyEventKind::Press => self.on_key(key),
Msg::Input(Event::Paste(s)) => {
let s: String = s
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
match &mut self.input_mode {
InputMode::Field { buf, .. } => buf.insert_str(&s),
InputMode::Command(buf) => buf.insert_str(&s),
InputMode::Normal => {}
}
vec![]
}
Msg::Input(_) => vec![],
}
}
fn on_key(&mut self, key: KeyEvent) -> Vec<Action> {
match std::mem::take(&mut self.input_mode) {
InputMode::Command(buf) => return self.on_command_key(key, buf),
InputMode::Field { target, buf } => return self.on_field_key(key, target, buf),
InputMode::Normal => {}
}
let confirm = self.confirm.take().map(|(c, _)| c);
if event::is_chord(&key) {
let ctrl_c =
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl_c {
return self.on_normal_action(KeyAction::QuitHint);
}
return vec![];
}
if self.help {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q')
) {
self.help = false;
}
return vec![];
}
if self.raw_view() {
let pane = self.focus == Focus::Pane;
let delta = match key.code {
KeyCode::PageDown => Some(i32::from(PAGE_ROWS)),
KeyCode::PageUp => Some(-i32::from(PAGE_ROWS)),
KeyCode::Char('j') | KeyCode::Down if pane => Some(1),
KeyCode::Char('k') | KeyCode::Up if pane => Some(-1),
_ => None,
};
if let Some(delta) = delta {
self.scroll_raw(delta);
return vec![];
}
}
if let Some(actions) = self.handle_pane_key(key, confirm) {
return actions;
}
let lens = self.current_lens();
if key.code == KeyCode::Char('/')
&& self.focus == Focus::Pane
&& lens.filterable(self.tab)
&& matches!(self.state_of(self.lens), LensState::Loaded(_))
{
let cur = self.lens_filter.get(&lens.key).cloned().unwrap_or_default();
self.input_mode = InputMode::Field {
target: EditTarget::LensFilter,
buf: cur.into(),
};
return vec![];
}
let Some(ka) = event::map(key) else {
return vec![];
};
self.on_normal_action(ka)
}
fn on_command_key(
&mut self,
key: KeyEvent,
mut buf: crate::tui::line_editor::LineEditor,
) -> Vec<Action> {
use crate::tui::line_editor::EditOutcome;
match buf.handle_key(key) {
EditOutcome::Cancel => vec![],
EditOutcome::Submit => self.exec_command(buf.as_str()),
EditOutcome::Continue => {
self.input_mode = InputMode::Command(buf);
vec![]
}
}
}
fn on_field_key(
&mut self,
key: KeyEvent,
target: EditTarget,
mut buf: crate::tui::line_editor::LineEditor,
) -> Vec<Action> {
use crate::tui::line_editor::EditOutcome;
match buf.handle_key(key) {
EditOutcome::Cancel => vec![],
EditOutcome::Submit => self.apply_field(target, buf.as_str().trim().to_string()),
EditOutcome::Continue => {
self.input_mode = InputMode::Field { target, buf };
vec![]
}
}
}
fn apply_field(&mut self, target: EditTarget, value: String) -> Vec<Action> {
match target {
EditTarget::Target => {
if value.is_empty() {
return vec![];
}
let Some(target) = self.target_or_toast(&value) else {
return vec![];
};
self.goto_lens(0);
self.set_domain_and_fetch(target)
}
EditTarget::LensFilter => {
let key = self.current_lens().key;
if value.is_empty() {
self.lens_filter.remove(&key);
} else {
self.lens_filter.insert(key, value);
}
self.sel = 0;
vec![]
}
EditTarget::DiffB => {
self.panes.diff.b = value.to_lowercase();
match self.panes.diff.effective_a(self.domain.as_deref()) {
Some(a) if !self.panes.diff.b.is_empty() => {
let b = self.panes.diff.b.clone();
let action = self.fetch_action(FetchReq::Diff { a, b });
vec![action]
}
_ => vec![],
}
}
EditTarget::WatchAdd => {
if value.is_empty() {
return vec![];
}
vec![self.watch_mutate(Some(value), None)]
}
EditTarget::TldFilter => {
self.panes.tld.set_filter(value);
let cur = self.panes.tld.current();
if cur.is_empty() {
self.states.remove(&LensKey::Tld);
vec![]
} else {
vec![self.fetch_action(FetchReq::Tld(cur))]
}
}
EditTarget::FollowInterval
| EditTarget::FollowCount
| EditTarget::BulkPath
| EditTarget::BulkDomains => self.panes.apply_field(target, value, self.domain.clone()),
}
}
fn watch_mutate(&mut self, add: Option<String>, remove: Option<String>) -> Action {
let gen = self.bump_fetch_gen(LensKey::Watch);
self.states.insert(LensKey::Watch, LensState::Loading);
Action::WatchMutate { add, remove, gen }
}
fn handle_pane_key(&mut self, key: KeyEvent, confirm: Option<Confirm>) -> Option<Vec<Action>> {
if self.focus != Focus::Pane {
return None;
}
let lens_key = self.current_lens().key;
match lens_key {
LensKey::Watch => return self.handle_watch_key(key, confirm),
LensKey::History => return self.handle_history_key(key, confirm),
_ => {}
}
let domain = self.domain.clone();
let outcome = self
.panes
.handle_key(lens_key, self.tab, key, domain.as_deref())?;
Some(self.apply_pane_outcome(outcome))
}
fn arm(&mut self, action: Confirm, msg: &str) {
self.set_toast("warn", msg);
self.confirm = Some((action, TOAST_TICKS));
}
fn handle_watch_key(&mut self, key: KeyEvent, confirm: Option<Confirm>) -> Option<Vec<Action>> {
match key.code {
KeyCode::Char('a') => {
self.input_mode = InputMode::Field {
target: EditTarget::WatchAdd,
buf: crate::tui::line_editor::LineEditor::new(),
};
Some(vec![])
}
KeyCode::Char('d') => {
let domain = self.selected_watch_domain()?;
let armed = Confirm::Unwatch(domain.clone());
if confirm.as_ref() == Some(&armed) {
return Some(vec![self.watch_mutate(None, Some(domain))]);
}
let msg = format!("press d again to stop watching {domain}");
self.arm(armed, &seer_core::output::sanitize_line(&msg));
Some(vec![])
}
KeyCode::Enter => {
let domain = self.selected_watch_domain()?;
Some(self.open_overview(&domain))
}
_ => None,
}
}
fn handle_history_key(
&mut self,
key: KeyEvent,
confirm: Option<Confirm>,
) -> Option<Vec<Action>> {
match key.code {
KeyCode::Enter => {
let domain = self.selected_history_domain()?;
Some(self.open_overview(&domain))
}
KeyCode::Char('c') => {
if confirm == Some(Confirm::ClearHistory) {
let gen = self.bump_fetch_gen(LensKey::History);
self.states.insert(LensKey::History, LensState::Loading);
return Some(vec![Action::HistoryClear { gen }]);
}
let total = match self.state_of(self.lens) {
LensState::Loaded(LensData::History(entries)) => entries.len(),
_ => 0,
};
let msg = format!("press c again to delete ALL {total} history entries");
self.arm(Confirm::ClearHistory, &msg);
Some(vec![])
}
_ => None,
}
}
fn open_overview(&mut self, domain: &str) -> Vec<Action> {
let Some(target) = self.target_or_toast(domain) else {
return vec![];
};
self.goto_lens(0);
self.set_domain_and_fetch(target)
}
fn selected_watch_domain(&self) -> Option<String> {
if let LensState::Loaded(LensData::Watch(w)) = self.state_of(self.lens) {
w.results.get(self.sel).map(|r| r.domain.clone())
} else {
None
}
}
fn selected_history_domain(&self) -> Option<String> {
let LensState::Loaded(data) = self.state_of(self.lens) else {
return None;
};
if let LensData::History(entries) = data {
crate::tui::filter::history_rows(entries, &self.active_filter())
.nth(self.sel)
.map(|e| e.domain.clone())
} else {
None
}
}
fn apply_pane_outcome(&mut self, outcome: PaneOutcome) -> Vec<Action> {
match outcome {
PaneOutcome::None => vec![],
PaneOutcome::Fetch(req) => vec![self.fetch_action(req)],
PaneOutcome::Action(a) => vec![a],
PaneOutcome::EditField(target) => {
let cur = self.panes.field_value(target);
self.input_mode = InputMode::Field {
target,
buf: cur.into(),
};
vec![]
}
PaneOutcome::Toast { tone, msg } => {
self.set_toast(tone, &msg);
vec![]
}
}
}
fn exec_command(&mut self, line: &str) -> Vec<Action> {
match command::parse(line) {
CmdOutcome::Noop => vec![],
CmdOutcome::Quit => {
self.should_quit = true;
vec![Action::Quit]
}
CmdOutcome::Help => {
self.help = true;
vec![]
}
CmdOutcome::Copy => self.copy_action(),
CmdOutcome::SetFormat(f) => {
self.format = f.parse().unwrap_or(OutputFormat::Human);
self.raw_scroll = 0;
self.set_toast("ok", &format!("output → {f}"));
vec![]
}
CmdOutcome::BadFormat => {
self.set_toast("fail", "formats: human · json · yaml · markdown");
vec![]
}
CmdOutcome::SetTheme(name) => {
if self.set_theme_by_name(&name) {
let msg = format!("theme → {}", self.theme.name);
self.set_toast("ok", &msg);
} else {
self.bad_theme_toast();
}
vec![]
}
CmdOutcome::BadTheme => {
self.bad_theme_toast();
vec![]
}
CmdOutcome::Lens { lens, target } => {
if lens == "rdap" {
if let Some(t) = target {
return self.rdap_command(&t);
}
}
if lens == "tld" {
if let Some(t) = target {
return self.tld_command(&t);
}
}
let Some(i) = lenses::find_by_cmd_or_key(&lens) else {
return vec![];
};
let target = match target {
Some(t) => match self.target_or_toast(&t) {
Some(target) => Some(target),
None => return vec![],
},
None => None,
};
self.goto_lens(i);
match target {
Some(target) => self.set_domain_and_fetch(target),
None => self.fetch_with_current(true),
}
}
CmdOutcome::Lookup(d) => {
let Some(target) = self.target_or_toast(&d) else {
return vec![];
};
self.goto_lens(0);
self.set_domain_and_fetch(target)
}
CmdOutcome::Diff { a, b } => {
self.goto_lens(LensKey::Diff.index());
self.panes.diff.a = a.clone();
self.panes.diff.b = b.clone();
let action = self.fetch_action(FetchReq::Diff { a, b });
vec![action]
}
CmdOutcome::Compare { domain, a, b } => {
self.open_dns_tab(2);
self.panes.compare.domain = Some(domain.clone());
self.panes.compare.set_servers(a.clone(), b.clone());
let action = self.fetch_action(FetchReq::Compare {
domain,
record_type: RecordType::A,
a,
b,
});
vec![action]
}
CmdOutcome::Dig {
domain,
record_type,
server,
trace,
} => {
let Some(target) = self.target_or_toast(&domain) else {
return vec![];
};
self.open_dns_tab(if trace { 3 } else { 0 });
self.panes.dns.record_type = record_type;
if let Some(server) = server {
self.panes.dns.select_server(server);
}
self.set_domain_and_fetch(target)
}
CmdOutcome::WatchMutate { add, remove } => {
self.goto_lens(LensKey::Watch.index());
vec![self.watch_mutate(add, remove)]
}
CmdOutcome::Invalid(msg) => {
self.set_toast("fail", &msg);
vec![]
}
CmdOutcome::Unknown(c) => {
self.set_toast("fail", &format!("unknown command: {c}"));
vec![]
}
}
}
fn open_dns_tab(&mut self, tab: usize) {
let i = LensKey::Dns.index();
let shown = if self.lens == i { self.tab } else { 0 };
if shown != tab {
self.lens_filter.remove(&LensKey::Dns);
}
self.goto_lens(i);
self.tab = tab;
}
fn rdap_command(&mut self, target: &str) -> Vec<Action> {
self.goto_lens(LensKey::Rdap.index());
if target.parse::<std::net::IpAddr>().is_ok() {
self.tab = 1;
return vec![self.fetch_action(FetchReq::RdapIp(target.to_string()))];
}
if let Some(asn) = parse_asn(target) {
self.tab = 2;
return vec![self.fetch_action(FetchReq::RdapAsn(asn))];
}
vec![self.fetch_action(FetchReq::RdapDomain(target.to_string()))]
}
fn tld_command(&mut self, tld: &str) -> Vec<Action> {
if !self.panes.tld.select(tld) {
self.set_toast("fail", &format!("unknown tld: {tld}"));
return vec![];
}
self.goto_lens(LensKey::Tld.index());
vec![self.fetch_action(FetchReq::Tld(self.panes.tld.current()))]
}
fn bad_theme_toast(&mut self) {
let msg = format!("themes: {}", Theme::NAMES.join(" · "));
self.set_toast("fail", &msg);
}
fn fetch_with_current(&mut self, explicit: bool) -> Vec<Action> {
self.fetch_current(explicit).into_iter().collect()
}
fn nav_to(&mut self, i: usize) -> Vec<Action> {
self.goto_lens(i);
self.fetch_with_current(false)
}
fn on_normal_action(&mut self, ka: KeyAction) -> Vec<Action> {
let n_lenses = lenses::lenses().len();
match ka {
KeyAction::Down => {
if self.focus == Focus::Nav {
return self.nav_to((self.lens + 1) % n_lenses);
}
let max = self.row_count().saturating_sub(1);
self.sel = (self.sel + 1).min(max);
vec![]
}
KeyAction::Up => {
if self.focus == Focus::Nav {
return self.nav_to((self.lens + n_lenses - 1) % n_lenses);
}
self.sel = self.sel.saturating_sub(1);
vec![]
}
KeyAction::Top => {
if self.focus == Focus::Nav {
return self.nav_to(0);
}
self.sel = 0;
vec![]
}
KeyAction::Bottom => {
if self.focus == Focus::Nav {
return self.nav_to(n_lenses - 1);
}
self.sel = self.row_count().saturating_sub(1);
vec![]
}
KeyAction::JumpLens(i) => {
if i < n_lenses {
return self.nav_to(i);
}
vec![]
}
KeyAction::ToggleFocus => {
self.focus = self.focus.toggled();
vec![]
}
KeyAction::NextTab => {
self.tab = lenses::cycle_tab(self.current_lens(), self.tab, true);
self.sel = 0;
self.refetch_for_tab()
}
KeyAction::PrevTab => {
self.tab = lenses::cycle_tab(self.current_lens(), self.tab, false);
self.sel = 0;
self.refetch_for_tab()
}
KeyAction::EnterPane => {
let lens = self.current_lens();
if lens.heavy
&& self.domain.is_some()
&& matches!(
self.state_of(self.lens),
LensState::Idle | LensState::Error(_)
)
{
self.states.remove(&lens.key);
return self.fetch_with_current(true);
}
if self.row_count() > 0 || lens.interactive || self.raw_view() {
self.focus = Focus::Pane;
}
vec![]
}
KeyAction::Back => {
self.focus = Focus::Nav;
vec![]
}
KeyAction::ToggleRaw => {
self.raw_scroll = 0;
self.format = if self.format == OutputFormat::Human {
self.set_toast("info", "raw output → json (:set output yaml|markdown)");
OutputFormat::Json
} else {
self.set_toast("ok", "human view");
OutputFormat::Human
};
vec![]
}
KeyAction::Copy => self.copy_action(),
KeyAction::EditDomain => {
let cur = self.domain.clone().unwrap_or_default();
self.input_mode = InputMode::Field {
target: EditTarget::Target,
buf: cur.into(),
};
vec![]
}
KeyAction::Command => {
self.input_mode = InputMode::Command(crate::tui::line_editor::LineEditor::new());
vec![]
}
KeyAction::Help => {
self.help = true;
vec![]
}
KeyAction::QuitHint => {
self.set_toast("info", "type :q to quit");
vec![]
}
}
}
fn copy_action(&mut self) -> Vec<Action> {
let label = self.current_lens().label;
if self.current_lens().key == LensKey::History {
self.set_toast(
"info",
"history can't be copied — ↵ replays an entry, then y copies it",
);
return vec![];
}
match self.state_of(self.lens) {
LensState::Loaded(data) => {
let fmt = if self.format == OutputFormat::Human {
OutputFormat::Markdown
} else {
self.format
};
let text = crate::payload::serialize(data, fmt);
vec![Action::Copy {
text,
label: format!("{label} as {fmt:?}").to_lowercase(),
}]
}
_ => {
self.set_toast("fail", "nothing to copy yet");
vec![]
}
}
}
}
fn parse_asn(target: &str) -> Option<u32> {
let digits = match target.get(..2) {
Some(prefix) if prefix.eq_ignore_ascii_case("as") => &target[2..],
_ => target,
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
fn key(app: &mut App, code: KeyCode) -> Vec<Action> {
app.update(Msg::Input(Event::Key(KeyEvent::new(
code,
KeyModifiers::NONE,
))))
}
fn empty_dig() -> LensData {
LensData::Dig(Box::new(crate::payload::fixtures::dig(
RecordType::A,
vec![],
)))
}
#[test]
fn new_starts_on_overview_nav_focus() {
let app = App::new(None);
assert_eq!(app.lens, 0);
assert_eq!(app.focus, Focus::Nav);
assert!(!app.should_quit);
}
#[test]
fn startup_with_domain_emits_overview_fetch() {
let mut app = App::new(Some("example.com".into()));
let actions = app.take_startup_actions();
assert!(matches!(
actions.as_slice(),
[Action::Fetch {
req: FetchReq::Overview(_),
..
}]
));
}
#[test]
fn number_jump_to_whois_fetches_whois() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = key(&mut app, KeyCode::Char('2'));
assert_eq!(app.lens, 1);
assert!(actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Whois(_),
..
}
)));
}
#[test]
fn editing_target_field_enter_fetches() {
let mut app = App::new(None);
key(&mut app, KeyCode::Char('/'));
assert!(matches!(
app.input_mode,
InputMode::Field {
target: EditTarget::Target,
..
}
));
for c in "acme.io".chars() {
key(&mut app, KeyCode::Char(c));
}
let actions = key(&mut app, KeyCode::Enter);
assert_eq!(app.domain.as_deref(), Some("acme.io"));
assert!(actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Overview(_),
..
}
)));
}
#[test]
fn data_message_keyed_by_lens_string_stores_state() {
let mut app = App::new(None);
app.update(Msg::Data {
lens: LensKey::Dns,
gen: 0,
result: Ok(empty_dig()),
});
let dns_idx = lenses::find_by_cmd_or_key("dns").unwrap();
assert!(matches!(app.state_of(dns_idx), LensState::Loaded(_)));
}
#[test]
fn r_toggles_raw_format() {
let mut app = App::new(None);
assert_eq!(app.format, OutputFormat::Human);
key(&mut app, KeyCode::Char('r'));
assert_eq!(app.format, OutputFormat::Json);
}
#[test]
fn tick_clears_expired_toast() {
let mut app = App::new(None);
app.set_toast("ok", "hi");
for _ in 0..40 {
app.update(Msg::Tick);
}
assert!(app.toast.is_none());
}
fn make_watch_state_data(domain: &str) -> LensData {
use chrono::DateTime;
let checked_at = DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap();
LensData::Watch(Box::new(seer_core::WatchReport {
checked_at,
results: vec![seer_core::WatchResult {
domain: domain.to_string(),
ssl_days_remaining: None,
domain_days_remaining: None,
registrar: None,
http_status: None,
issues: vec![],
}],
total: 1,
warnings: 0,
critical: 0,
}))
}
fn make_watch_state(domain: &str) -> LensState {
LensState::Loaded(make_watch_state_data(domain))
}
fn make_history_entry(domain: &str) -> seer_core::HistoryEntry {
use chrono::DateTime;
use seer_core::{HistoryEntry, LookupResult, WhoisResponse};
let timestamp = DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap();
let whois = WhoisResponse {
domain: domain.to_string(),
..Default::default()
};
HistoryEntry {
domain: domain.to_string(),
timestamp,
result: LookupResult::Whois {
data: whois,
rdap_error: None,
rdap_fallback: None,
dns_nameservers: None,
},
}
}
fn make_history_state(domain: &str) -> LensState {
LensState::Loaded(LensData::History(vec![make_history_entry(domain)]))
}
fn watch_lens_idx() -> usize {
lenses::find_by_cmd_or_key("watch").unwrap()
}
fn history_lens_idx() -> usize {
lenses::find_by_cmd_or_key("history").unwrap()
}
fn app_on_watch_with_domain(domain: &str) -> App {
let mut app = App::new(None);
let idx = watch_lens_idx();
app.lens = idx;
app.focus = Focus::Pane;
app.sel = 0;
app.states
.insert(lenses::lenses()[idx].key, make_watch_state(domain));
app
}
#[test]
fn watch_d_emits_watch_mutate_remove() {
let mut app = app_on_watch_with_domain("example.com");
let actions = key(&mut app, KeyCode::Char('d'));
assert!(actions.is_empty(), "{actions:?}");
assert!(matches!(&app.toast, Some(t) if t.msg.contains("press d again")));
let actions = key(&mut app, KeyCode::Char('d'));
assert!(matches!(app.state_of(watch_lens_idx()), LensState::Loading));
assert!(
actions.iter().any(|a| matches!(
a,
Action::WatchMutate {
add: None,
remove: Some(domain),
..
} if domain == "example.com"
)),
"expected WatchMutate{{remove: Some(example.com)}}, got {actions:?}",
);
}
#[test]
fn watch_a_enters_field_watch_add() {
let mut app = app_on_watch_with_domain("example.com");
key(&mut app, KeyCode::Char('a'));
assert!(
matches!(
app.input_mode,
InputMode::Field {
target: EditTarget::WatchAdd,
..
}
),
"expected Field{{WatchAdd}}, got {:?}",
app.input_mode
);
}
#[test]
fn watch_enter_switches_to_overview() {
let mut app = app_on_watch_with_domain("example.com");
let actions = key(&mut app, KeyCode::Enter);
assert_eq!(app.lens, 0, "should switch to overview lens");
assert_eq!(app.focus, Focus::Nav);
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Overview(_),
..
}
)),
"expected Fetch(Overview), got {actions:?}",
);
}
#[test]
fn history_c_emits_history_clear() {
let mut app = App::new(None);
let idx = history_lens_idx();
app.lens = idx;
app.focus = Focus::Pane;
app.states
.insert(lenses::lenses()[idx].key, make_history_state("old.com"));
let actions = key(&mut app, KeyCode::Char('c'));
assert!(actions.is_empty(), "the first c only asks: {actions:?}");
assert!(matches!(&app.toast, Some(t) if t.msg.contains("ALL 1 history")));
let actions = key(&mut app, KeyCode::Char('c'));
assert!(
actions
.iter()
.any(|a| matches!(a, Action::HistoryClear { .. })),
"expected HistoryClear, got {actions:?}",
);
}
#[test]
fn destructive_keys_need_a_prompt_second_press() {
let mut app = App::new(None);
app.lens = history_lens_idx();
app.focus = Focus::Pane;
app.states
.insert(LensKey::History, make_history_state("old.com"));
app.lens_filter.insert(LensKey::History, "zzz".into());
key(&mut app, KeyCode::Char('c'));
assert!(matches!(&app.toast, Some(t) if t.msg.contains("ALL 1")));
key(&mut app, KeyCode::Char('j'));
let actions = key(&mut app, KeyCode::Char('c'));
assert!(actions.is_empty(), "another key in between disarms");
for _ in 0..=TOAST_TICKS {
app.update(Msg::Tick);
}
let actions = key(&mut app, KeyCode::Char('c'));
assert!(actions.is_empty(), "a lapsed confirmation disarms");
let mut app = app_on_watch_with_domain("a.com");
key(&mut app, KeyCode::Char('d'));
app.states.insert(LensKey::Watch, make_watch_state("b.com"));
let actions = key(&mut app, KeyCode::Char('d'));
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::WatchMutate { .. })),
"a second d on another row re-asks: {actions:?}"
);
}
#[test]
fn history_enter_switches_to_overview() {
let mut app = App::new(None);
let idx = history_lens_idx();
app.lens = idx;
app.focus = Focus::Pane;
app.sel = 0;
app.states
.insert(lenses::lenses()[idx].key, make_history_state("old.com"));
let actions = key(&mut app, KeyCode::Enter);
assert_eq!(app.lens, 0, "should switch to overview lens");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Overview(_),
..
}
)),
"expected Fetch(Overview), got {actions:?}",
);
}
#[test]
fn stale_data_msg_is_dropped() {
let mut app = App::new(None);
let dns_idx = lenses::find_by_cmd_or_key("dns").unwrap();
app.domain = Some("example.com".into());
app.fetch_gen.insert(lenses::lenses()[dns_idx].key, 1);
app.update(Msg::Data {
lens: LensKey::Dns,
gen: 0,
result: Ok(empty_dig()),
});
assert!(
matches!(app.state_of(dns_idx), LensState::Idle),
"stale gen-0 result must be dropped when current gen is 1",
);
}
#[test]
fn current_gen_data_msg_is_stored() {
let mut app = App::new(None);
let dns_idx = lenses::find_by_cmd_or_key("dns").unwrap();
app.fetch_gen.insert(lenses::lenses()[dns_idx].key, 2);
app.update(Msg::Data {
lens: LensKey::Dns,
gen: 2,
result: Ok(empty_dig()),
});
assert!(
matches!(app.state_of(dns_idx), LensState::Loaded(_)),
"matching gen must be stored",
);
}
#[test]
fn follow_step_dropped_on_stale_gen() {
use chrono::Utc;
let mut app = App::new(None);
app.panes.follow.gen = 3;
let it = seer_core::dns::FollowIteration {
iteration: 1,
total_iterations: 5,
timestamp: Utc::now(),
records: vec![],
changed: false,
added: vec![],
removed: vec![],
error: None,
};
app.update(Msg::FollowStep {
gen: 5,
it: Box::new(it),
});
assert!(
app.panes.follow.log.is_empty(),
"stale follow step must be dropped"
);
}
#[test]
fn follow_step_accepted_on_matching_gen() {
use chrono::Utc;
let mut app = App::new(None);
app.panes.follow.gen = 7;
let it = seer_core::dns::FollowIteration {
iteration: 1,
total_iterations: 5,
timestamp: Utc::now(),
records: vec![],
changed: false,
added: vec![],
removed: vec![],
error: None,
};
app.update(Msg::FollowStep {
gen: 7,
it: Box::new(it),
});
assert_eq!(
app.panes.follow.log.len(),
1,
"matching gen follow step must be accepted"
);
}
#[test]
fn switching_domain_resets_follow_pane_and_drops_stale_steps() {
use chrono::Utc;
let make_iter = || seer_core::dns::FollowIteration {
iteration: 1,
total_iterations: 5,
timestamp: Utc::now(),
records: vec![],
changed: false,
added: vec![],
removed: vec![],
error: None,
};
let mut app = App::new(None);
let _ = app.set_domain_and_fetch("a.com".into());
app.panes.follow.gen = 1;
app.panes.follow.running = true;
app.update(Msg::FollowStep {
gen: 1,
it: Box::new(make_iter()),
});
assert_eq!(
app.panes.follow.log.len(),
1,
"precondition: one logged step"
);
let actions = app.set_domain_and_fetch("b.com".into());
assert!(
actions.iter().any(|a| matches!(a, Action::StopFollow)),
"domain switch with a live follow run must emit StopFollow"
);
assert!(
app.panes.follow.gen > 1,
"gen must advance so the old run's callbacks are superseded"
);
assert!(!app.panes.follow.running, "running flag must clear");
assert!(
app.panes.follow.log.is_empty(),
"old domain's results must be cleared from the pane"
);
app.update(Msg::FollowStep {
gen: 1,
it: Box::new(make_iter()),
});
assert!(
app.panes.follow.log.is_empty(),
"stale step from the previous domain must be dropped"
);
}
#[test]
fn rdap_command_ip_routes_to_tab1() {
let mut app = App::new(None);
let actions = app.rdap_command("8.8.8.8");
assert_eq!(app.tab, 1, ":rdap <ip> must switch to tab 1");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::RdapIp(_),
..
}
)),
"expected Fetch(RdapIp), got {actions:?}",
);
}
#[test]
fn rdap_command_asn_routes_to_tab2() {
let mut app = App::new(None);
let actions = app.rdap_command("AS15169");
assert_eq!(app.tab, 2, ":rdap AS<n> must switch to tab 2");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::RdapAsn(15169),
..
}
)),
"expected Fetch(RdapAsn(15169)), got {actions:?}",
);
}
#[test]
fn rdap_command_domain_routes_to_tab0() {
let mut app = App::new(None);
let actions = app.rdap_command("example.com");
assert_eq!(app.tab, 0, ":rdap <domain> must stay on tab 0");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::RdapDomain(_),
..
}
)),
"expected Fetch(RdapDomain), got {actions:?}",
);
}
#[test]
fn headers_command_selects_lens_and_fetches_the_domain() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("headers");
let idx = lenses::find_by_cmd_or_key("headers").unwrap();
assert_eq!(app.lens, idx, ":headers must switch to the headers lens");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::Headers(d), .. } if d == "example.com"
)),
"expected Fetch(Headers(example.com)), got {actions:?}",
);
}
#[test]
fn takeover_command_selects_lens_and_fetches_the_domain() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("takeover");
let idx = lenses::find_by_cmd_or_key("takeover").unwrap();
assert_eq!(app.lens, idx, ":takeover must switch to the takeover lens");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::Takeover(d), .. } if d == "example.com"
)),
"expected Fetch(Takeover(example.com)), got {actions:?}",
);
}
#[test]
fn takeover_row_count_uses_reported_findings_not_hosts_checked() {
use seer_core::{TakeoverFinding, TakeoverReport, TakeoverVerdict};
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
app.lens = lenses::find_by_cmd_or_key("takeover").unwrap();
let report = TakeoverReport {
domain: "example.com".into(),
hosts_checked: 40,
hosts_skipped: 0,
vulnerable: 1,
potential: 1,
inconclusive: 0,
findings: vec![
TakeoverFinding {
host: "a.example.com".into(),
verdict: TakeoverVerdict::Vulnerable,
provider: None,
cname: None,
addresses: vec![],
evidence: None,
http_status: None,
probe_note: None,
},
TakeoverFinding {
host: "b.example.com".into(),
verdict: TakeoverVerdict::Potential,
provider: None,
cname: None,
addresses: vec![],
evidence: None,
http_status: None,
probe_note: None,
},
],
notes: vec![],
};
app.states.insert(
LensKey::Takeover,
LensState::Loaded(LensData::Takeover(Box::new(report))),
);
assert_eq!(app.row_count(), 2);
}
#[test]
fn tld_command_selects_slot_without_clobbering_domain() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("tld .io");
let tld_idx = lenses::find_by_cmd_or_key("tld").unwrap();
assert_eq!(app.lens, tld_idx, ":tld must switch to the tld lens");
assert_eq!(app.domain.as_deref(), Some("example.com"));
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::Tld(t), .. } if t == ".io"
)),
"expected Fetch(Tld(.io)), got {actions:?}",
);
}
#[test]
fn tld_command_unknown_tld_errors_and_keeps_domain() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("tld .zzz");
assert!(actions.is_empty(), "unknown tld should emit no fetch");
assert_eq!(app.domain.as_deref(), Some("example.com"));
assert!(
matches!(&app.toast, Some(t) if t.tone == "fail"),
"unknown tld should set a fail toast",
);
}
#[test]
fn watch_mutate_refresh_uses_current_gen() {
let mut app = app_on_watch_with_domain("example.com");
app.fetch_gen
.insert(lenses::lenses()[watch_lens_idx()].key, 1);
key(&mut app, KeyCode::Char('d'));
let actions = key(&mut app, KeyCode::Char('d'));
let gen = actions.iter().find_map(|a| match a {
Action::WatchMutate { gen, .. } => Some(*gen),
_ => None,
});
assert_eq!(gen, Some(2), "mutation must carry a freshly-bumped gen");
app.update(Msg::Data {
lens: LensKey::Watch,
gen: 2,
result: Ok(make_watch_state_data("kept.com")),
});
assert!(
matches!(app.state_of(watch_lens_idx()), LensState::Loaded(LensData::Watch(w)) if w.results[0].domain == "kept.com"),
"gen-correct watch refresh must replace the cached view",
);
}
#[test]
fn refresh_with_fewer_rows_clamps_the_selection() {
let mut app = app_on_watch_with_domain("a.com");
let LensState::Loaded(LensData::Watch(mut report)) = make_watch_state("a.com") else {
unreachable!()
};
let row = report.results[0].clone();
report.results = ["a.com", "b.com", "c.com"]
.iter()
.map(|d| seer_core::WatchResult {
domain: d.to_string(),
..row.clone()
})
.collect();
app.states
.insert(LensKey::Watch, LensState::Loaded(LensData::Watch(report)));
app.sel = 2; app.fetch_gen.insert(LensKey::Watch, 1);
app.update(Msg::Data {
lens: LensKey::Watch,
gen: 1,
result: Ok(make_watch_state_data("a.com")),
});
assert_eq!(app.sel, 0, "selection must move onto the remaining row");
assert_eq!(app.selected_watch_domain().as_deref(), Some("a.com"));
}
#[test]
fn enter_pane_allowed_for_bulk_lens_empty() {
let mut app = App::new(None);
let bulk_idx = lenses::find_by_cmd_or_key("bulk").unwrap();
app.lens = bulk_idx;
assert_eq!(app.row_count(), 0, "no rows");
key(&mut app, KeyCode::Enter);
assert_eq!(
app.focus,
Focus::Pane,
"bulk lens should allow EnterPane even with 0 rows",
);
}
#[test]
fn history_always_refetches_even_when_loaded() {
let mut app = App::new(None);
app.domain = Some("example.com".into());
let hidx = history_lens_idx();
app.lens = hidx;
let a1 = app.fetch_current(false);
assert!(
matches!(
a1,
Some(Action::Fetch {
req: FetchReq::History,
..
})
),
"first visit should fetch history, got {a1:?}",
);
app.states.insert(
lenses::lenses()[hidx].key,
LensState::Loaded(LensData::History(vec![])),
);
let a2 = app.fetch_current(false);
assert!(
matches!(
a2,
Some(Action::Fetch {
req: FetchReq::History,
..
})
),
"history must always refetch even when already Loaded, got {a2:?}",
);
}
#[test]
fn history_fetches_without_a_domain() {
let mut app = App::new(None); app.lens = history_lens_idx();
let a = app.fetch_current(false);
assert!(
matches!(
a,
Some(Action::Fetch {
req: FetchReq::History,
..
})
),
"history should load with no domain set, got {a:?}",
);
}
#[test]
fn paste_appends_into_active_field() {
let mut app = App::new(None);
app.input_mode = InputMode::Field {
target: EditTarget::DiffB,
buf: "a.com ".into(),
};
app.update(Msg::Input(Event::Paste("b.com c.com".into())));
assert!(
matches!(&app.input_mode, InputMode::Field { buf, .. } if buf.as_str() == "a.com b.com c.com"),
"paste should append to the field buffer, got {:?}",
app.input_mode
);
}
#[test]
fn lens_filter_live_buffer_wins_then_commits_and_clears() {
let mut app = App::new(None);
app.input_mode = InputMode::Field {
target: EditTarget::LensFilter,
buf: "api".into(),
};
assert_eq!(app.active_filter(), "api");
let _ = app.apply_field(EditTarget::LensFilter, "api".to_string());
app.input_mode = InputMode::Normal;
assert_eq!(app.active_filter(), "api");
let _ = app.apply_field(EditTarget::LensFilter, String::new());
assert_eq!(app.active_filter(), "");
}
#[test]
fn domain_switch_invalidates_other_lenses_inflight_fetches() {
let mut app = App::new(None);
let _ = app.set_domain_and_fetch("a.com".into());
let dns_idx = lenses::find_by_cmd_or_key("dns").unwrap();
let dns_key = lenses::lenses()[dns_idx].key;
app.fetch_gen.insert(dns_key, 1);
app.states.insert(dns_key, LensState::Loading);
let _ = app.set_domain_and_fetch("b.com".into());
app.update(Msg::Data {
lens: LensKey::Dns,
gen: 1,
result: Ok(empty_dig()),
});
assert!(
!matches!(app.state_of(dns_idx), LensState::Loaded(_)),
"a late result fetched under the old domain must not be stored as the new domain's data",
);
}
#[test]
fn history_enter_with_filter_pivots_to_filtered_selection() {
let mut app = App::new(None);
let idx = history_lens_idx();
app.lens = idx;
app.focus = Focus::Pane;
app.states.insert(
lenses::lenses()[idx].key,
LensState::Loaded(LensData::History(vec![
make_history_entry("alpha.com"),
make_history_entry("beta.com"),
])),
);
let _ = app.apply_field(EditTarget::LensFilter, "beta".into());
let actions = key(&mut app, KeyCode::Enter);
assert_eq!(
app.domain.as_deref(),
Some("beta.com"),
"Enter must resolve the selection against the filtered view, not the unfiltered list",
);
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Overview(d),
..
} if d == "beta.com"
)),
"expected Fetch(Overview(beta.com)), got {actions:?}",
);
}
#[test]
fn rdap_tab_switch_without_default_req_drops_inflight_result() {
let mut app = App::new(None);
app.domain = Some("example.com".into());
let idx = lenses::find_by_cmd_or_key("rdap").unwrap();
let rdap_key = lenses::lenses()[idx].key;
app.lens = idx;
app.fetch_gen.insert(rdap_key, 1);
app.states.insert(rdap_key, LensState::Loading);
app.tab = 1;
let actions = app.refetch_for_tab();
assert!(
actions.is_empty(),
"IP tab without a resolved IP must not fetch, got {actions:?}",
);
let rdap: seer_core::RdapResponse =
serde_json::from_str("{}").expect("empty RDAP object deserializes");
app.update(Msg::Data {
lens: LensKey::Rdap,
gen: 1,
result: Ok(LensData::Rdap(Box::new(rdap))),
});
assert!(
!matches!(app.state_of(idx), LensState::Loaded(_)),
"stale domain result must not render as the IP/ASN tab's data",
);
}
#[test]
fn theme_command_swaps_live_theme() {
let mut app = App::new(None);
assert_eq!(app.theme().name, "frappe", "Frappé must be the default");
let frappe_base = app.theme().base;
let actions = app.exec_command("theme latte");
assert!(actions.is_empty(), ":theme must not emit actions");
assert_eq!(app.theme().name, "latte");
assert_ne!(
app.theme().base,
frappe_base,
"latte must actually change the base color"
);
assert_eq!(app.theme().base, Theme::latte().base);
assert_eq!(app.theme().text, Theme::latte().text);
assert!(
matches!(&app.toast, Some(t) if t.tone == "ok" && t.msg.contains("latte")),
"expected ok toast naming latte, got {:?}",
app.toast
);
}
#[test]
fn unknown_theme_errors_and_keeps_current_theme() {
let mut app = App::new(None);
let _ = app.exec_command("theme latte");
let actions = app.exec_command("theme mocha");
assert!(actions.is_empty());
assert_eq!(
app.theme().name,
"latte",
"an unknown theme must not change the active theme"
);
assert!(
matches!(
&app.toast,
Some(t) if t.tone == "fail" && t.msg.contains("frappe") && t.msg.contains("latte")
),
"error toast must name the valid themes, got {:?}",
app.toast
);
let _ = app.exec_command("theme");
assert!(matches!(&app.toast, Some(t) if t.tone == "fail"));
assert_eq!(app.theme().name, "latte");
}
#[test]
fn set_theme_by_name_seam_round_trips() {
let mut app = App::new(None);
assert!(app.set_theme_by_name("latte"));
assert_eq!(app.theme().name, "latte");
assert!(app.set_theme_by_name("Frappé"), "accented alias must work");
assert_eq!(app.theme().name, "frappe");
assert!(!app.set_theme_by_name("nord"));
assert_eq!(app.theme().name, "frappe", "failed swap keeps old theme");
}
fn chord(app: &mut App, code: KeyCode, mods: KeyModifiers) -> Vec<Action> {
app.update(Msg::Input(Event::Key(KeyEvent::new(code, mods))))
}
fn app_on_lens(domain: Option<&str>, lens: &str) -> App {
let mut app = App::new(domain.map(str::to_string));
let _ = app.take_startup_actions();
app.lens = lenses::find_by_cmd_or_key(lens).unwrap();
app
}
#[test]
fn ctrl_c_on_history_pane_shows_quit_hint_instead_of_clearing() {
let mut app = app_on_lens(None, "history");
app.focus = Focus::Pane;
app.states
.insert(LensKey::History, make_history_state("old.com"));
let actions = chord(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::HistoryClear { .. })),
"Ctrl-C must never clear history, got {actions:?}"
);
assert!(
matches!(&app.toast, Some(t) if t.msg.contains(":q")),
"Ctrl-C should show the same quit hint as `q`, got {:?}",
app.toast
);
}
#[test]
fn ctrl_and_alt_chords_never_reach_pane_handlers() {
let mut app = app_on_watch_with_domain("example.com");
let actions = chord(&mut app, KeyCode::Char('d'), KeyModifiers::CONTROL);
assert!(actions.is_empty(), "Ctrl-D on watch: {actions:?}");
let mut app = app_on_lens(None, "bulk");
app.focus = Focus::Pane;
app.panes.bulk.domains = "a.com".into();
let actions = chord(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
assert!(actions.is_empty() && !app.panes.bulk.running, "{actions:?}");
app.panes.bulk.rows.push(seer_core::bulk::BulkResult {
operation: seer_core::bulk::BulkOperation::Lookup {
domain: "a.com".into(),
},
success: true,
data: None,
error: None,
duration_ms: 1,
});
let actions = chord(&mut app, KeyCode::Char('e'), KeyModifiers::ALT);
assert!(actions.is_empty(), "Alt-E on bulk: {actions:?}");
let mut app = app_on_lens(Some("example.com"), "follow");
app.focus = Focus::Pane;
let actions = chord(&mut app, KeyCode::Char('s'), KeyModifiers::CONTROL);
assert!(
actions.is_empty() && !app.panes.follow.running,
"{actions:?}"
);
}
#[test]
fn shift_and_altgr_keys_keep_working_in_normal_mode() {
let mut app = App::new(None);
chord(&mut app, KeyCode::Char('G'), KeyModifiers::SHIFT);
assert_eq!(app.lens, lenses::lenses().len() - 1);
let mut app = app_on_lens(None, "rdap");
chord(
&mut app,
KeyCode::Char(']'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
);
assert_eq!(app.tab, 1);
}
#[test]
fn dns_revisit_does_not_serve_dnssec_cache_under_records_tab() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let first = key(&mut app, KeyCode::Char('7')); let gen = first
.iter()
.find_map(|a| match a {
Action::Fetch { gen, .. } => Some(*gen),
_ => None,
})
.expect("records fetch");
app.update(Msg::Data {
lens: LensKey::Dns,
gen,
result: Ok(empty_dig()),
});
let dnssec = key(&mut app, KeyCode::Char(']')); assert!(dnssec.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Dnssec(_),
..
}
)));
let again = key(&mut app, KeyCode::Char('7'));
assert_eq!(app.tab, 0);
assert!(
again.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Dns { .. },
..
}
)),
"expected a Records refetch, got {again:?}"
);
}
#[test]
fn rdap_asn_cache_is_not_served_on_the_domain_tab() {
let rdap = |app: &mut App, gen| {
let r: seer_core::RdapResponse = serde_json::from_str("{}").unwrap();
app.update(Msg::Data {
lens: LensKey::Rdap,
gen,
result: Ok(LensData::Rdap(Box::new(r))),
});
};
let gen_of = |actions: &[Action]| {
actions.iter().find_map(|a| match a {
Action::Fetch { gen, .. } => Some(*gen),
_ => None,
})
};
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let asn = app.exec_command("rdap AS15169");
rdap(&mut app, gen_of(&asn).unwrap());
key(&mut app, KeyCode::Char('j'));
let back = key(&mut app, KeyCode::Char('k'));
assert_eq!(app.tab, 0);
assert!(
back.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::RdapDomain(d), .. } if d == "example.com"
)),
"expected RdapDomain refetch, got {back:?}"
);
let mut app = App::new(None);
let asn = app.exec_command("rdap AS15169");
rdap(&mut app, gen_of(&asn).unwrap());
key(&mut app, KeyCode::Char('j'));
key(&mut app, KeyCode::Char('k'));
let idx = lenses::find_by_cmd_or_key("rdap").unwrap();
assert!(
matches!(app.state_of(idx), LensState::Idle),
"stale ASN object served under the Domain tab"
);
}
#[test]
fn follow_interval_field_opens_empty_so_typing_replaces() {
let mut app = app_on_lens(Some("example.com"), "follow");
app.focus = Focus::Pane;
key(&mut app, KeyCode::Char('i'));
assert!(
matches!(&app.input_mode, InputMode::Field { target: EditTarget::FollowInterval, buf } if buf.as_str().is_empty()),
"got {:?}",
app.input_mode
);
key(&mut app, KeyCode::Char('6'));
key(&mut app, KeyCode::Char('0'));
key(&mut app, KeyCode::Enter);
assert_eq!(
app.panes.follow.interval_secs, 60,
"was 3060 when prefilled"
);
}
#[test]
fn watch_subcommands_do_not_clobber_the_session_domain() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("watch add example.org");
assert_eq!(app.domain.as_deref(), Some("example.com"));
assert_eq!(app.lens, watch_lens_idx());
assert!(
actions.iter().any(|a| matches!(
a,
Action::WatchMutate { add: Some(d), remove: None, .. } if d == "example.org"
)),
"got {actions:?}"
);
for line in ["history clear", "bulk file.txt", "watch frob"] {
app.toast = None;
let actions = app.exec_command(line);
assert!(actions.is_empty(), "{line}: {actions:?}");
assert_eq!(app.domain.as_deref(), Some("example.com"), "{line}");
assert!(matches!(&app.toast, Some(t) if t.tone == "fail"), "{line}");
}
}
#[test]
fn explicit_requests_mark_the_lens_loading() {
let mut app = App::new(None);
let _ = app.rdap_command("8.8.8.8");
let idx = lenses::find_by_cmd_or_key("rdap").unwrap();
assert!(
matches!(app.state_of(idx), LensState::Loading),
"`:rdap <ip>` must show a spinner, not the idle hint"
);
assert_eq!(app.pending_target(idx).as_deref(), Some("8.8.8.8"));
let _ = app.exec_command("diff a.com b.com");
let idx = lenses::find_by_cmd_or_key("diff").unwrap();
assert!(matches!(app.state_of(idx), LensState::Loading));
}
#[test]
fn diff_command_domain_a_drives_the_pane_rerun() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let _ = app.exec_command("diff a.com b.com");
assert_eq!(app.panes.diff.a, "a.com");
app.focus = Focus::Pane;
let actions = key(&mut app, KeyCode::Enter);
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::Diff { a, b }, .. } if a == "a.com" && b == "b.com"
)),
"↵ must re-run a.com ⇄ b.com, got {actions:?}"
);
let _ = app.set_domain_and_fetch("new.com".into());
assert!(app.panes.diff.a.is_empty());
}
#[test]
fn compare_command_domain_drives_resolver_cycling() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let _ = app.exec_command("compare other.com 9.9.9.9 8.8.4.4");
app.focus = Focus::Pane;
let actions = key(&mut app, KeyCode::Char('b'));
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::Compare { domain, a, .. }, .. }
if domain == "other.com" && a == "9.9.9.9"
)),
"`b` must re-run against other.com, got {actions:?}"
);
}
#[test]
fn dig_command_carries_the_record_type() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("dig example.com MX");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Dns {
record_type: RecordType::MX,
..
},
..
}
)),
"got {actions:?}"
);
let actions = app.exec_command("dig example.com BOGUS");
assert!(actions.is_empty());
assert!(matches!(&app.toast, Some(t) if t.tone == "fail" && t.msg.contains("BOGUS")));
}
#[test]
fn side_effect_toasts_are_shown_verbatim() {
let mut app = App::new(None);
app.update(Msg::Toast {
tone: "ok",
msg: "wrote seer-bulk-lookup.csv".into(),
});
assert!(
matches!(&app.toast, Some(t) if t.msg == "wrote seer-bulk-lookup.csv"),
"was \"copied wrote …\", got {:?}",
app.toast
);
}
#[test]
fn enter_focuses_an_empty_watchlist_so_add_is_reachable() {
let mut app = app_on_lens(None, "watch");
app.states.insert(
LensKey::Watch,
LensState::Loaded(LensData::Watch(Box::new(seer_core::WatchReport {
checked_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap(),
results: vec![],
total: 0,
warnings: 0,
critical: 0,
}))),
);
key(&mut app, KeyCode::Enter);
assert_eq!(app.focus, Focus::Pane);
key(&mut app, KeyCode::Char('a'));
assert!(matches!(
app.input_mode,
InputMode::Field {
target: EditTarget::WatchAdd,
..
}
));
}
#[test]
fn rdap_asn_detection_only_strips_an_as_prefix() {
assert_eq!(parse_asn("AS15169"), Some(15169));
assert_eq!(parse_asn("as15169"), Some(15169));
assert_eq!(parse_asn("15169"), Some(15169));
assert_eq!(parse_asn("ab64496"), None);
assert_eq!(parse_asn("apple123"), None);
assert_eq!(parse_asn("AS"), None);
let mut app = App::new(None);
let actions = app.rdap_command("apple123");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::RdapDomain(d), .. } if d == "apple123"
)),
"got {actions:?}"
);
}
#[test]
fn copy_refuses_history_instead_of_copying_a_placeholder() {
let mut app = app_on_lens(None, "history");
app.states
.insert(LensKey::History, make_history_state("old.com"));
let actions = key(&mut app, KeyCode::Char('y'));
assert!(
!actions.iter().any(|a| matches!(a, Action::Copy { .. })),
"got {actions:?}"
);
assert!(matches!(&app.toast, Some(t) if t.tone != "ok"));
}
#[test]
fn history_filter_counts_by_reference() {
let mut app = app_on_lens(None, "history");
let data = LensData::History(vec![
make_history_entry("alpha.com"),
make_history_entry("beta.com"),
]);
assert!(crate::tui::filter::apply(&data, "beta").is_none());
app.states.insert(LensKey::History, LensState::Loaded(data));
let _ = app.apply_field(EditTarget::LensFilter, "beta".into());
assert_eq!(app.row_count(), 1);
}
#[test]
fn paste_appends_into_command_buffer() {
let mut app = App::new(None);
app.input_mode = InputMode::Command("look".into());
app.update(Msg::Input(Event::Paste("up x.com".into())));
assert!(
matches!(&app.input_mode, InputMode::Command(buf) if buf.as_str() == "lookup x.com"),
"paste should append to the command buffer, got {:?}",
app.input_mode
);
}
fn chained_dig(n: u8) -> LensData {
use crate::payload::fixtures;
let mut answers = vec![fixtures::cname("www.seer.test", "edge.cdn.test.")];
answers.extend((1..=n).map(|i| fixtures::a("edge.cdn.test", &format!("192.0.2.{i}"))));
LensData::Dig(Box::new(fixtures::dig(RecordType::A, answers)))
}
fn dns_app_with(data: LensData) -> App {
let mut app = app_on_lens(Some("example.com"), "dns");
let actions = app.fetch_with_current(false);
let Some(Action::Fetch { gen, .. }) = actions.first() else {
panic!("expected a Records fetch, got {actions:?}");
};
app.update(Msg::Data {
lens: LensKey::Dns,
gen: *gen,
result: Ok(data),
});
app
}
fn fetch_gen_of(actions: &[Action]) -> u64 {
actions
.iter()
.find_map(|a| match a {
Action::Fetch { gen, .. } => Some(*gen),
_ => None,
})
.expect("a fetch")
}
#[test]
fn dig_rows_are_the_selectable_answers_chain_included() {
let app = dns_app_with(chained_dig(2));
assert!(matches!(
app.state_of(app.lens),
LensState::Loaded(LensData::Dig(_))
));
assert_eq!(app.row_count(), 3, "the CNAME hop and both addresses");
}
#[test]
fn a_dig_answer_resolves_the_ip_past_the_cname_chain() {
let app = dns_app_with(chained_dig(2));
assert_eq!(app.panes.dns.resolved_ip.as_deref(), Some("192.0.2.1"));
let app = dns_app_with(LensData::Dig(Box::new(
crate::payload::fixtures::dig_status(RecordType::A, seer_core::DnsStatus::NxDomain),
)));
assert_eq!(app.panes.dns.resolved_ip, None);
}
#[test]
fn selection_is_clamped_when_a_refetch_returns_fewer_answers() {
let mut app = dns_app_with(chained_dig(4));
app.focus = Focus::Pane;
key(&mut app, KeyCode::Char('G'));
assert_eq!(app.sel, 4);
let actions = key(&mut app, KeyCode::Char('s'));
let gen = fetch_gen_of(&actions);
app.update(Msg::Data {
lens: LensKey::Dns,
gen,
result: Ok(chained_dig(1)),
});
assert_eq!(app.row_count(), 2);
assert_eq!(app.sel, 1, "back on the last real row");
}
#[test]
fn slash_filters_the_dns_records_rows() {
let mut app = dns_app_with(chained_dig(3));
app.focus = Focus::Pane;
key(&mut app, KeyCode::Char('/'));
assert!(matches!(
app.input_mode,
InputMode::Field {
target: EditTarget::LensFilter,
..
}
));
for c in "192.0.2.3".chars() {
key(&mut app, KeyCode::Char(c));
}
assert_eq!(app.row_count(), 1, "the live buffer filters as you type");
key(&mut app, KeyCode::Enter);
assert_eq!(app.active_filter(), "192.0.2.3");
assert_eq!(app.row_count(), 1);
assert_eq!(app.sel, 0);
key(&mut app, KeyCode::Char(']'));
assert_eq!(app.tab, 1);
assert_eq!(app.active_filter(), "");
key(&mut app, KeyCode::Char('/'));
assert!(matches!(
app.input_mode,
InputMode::Field {
target: EditTarget::Target,
..
}
));
}
#[test]
fn the_trace_tab_fetches_a_trace_and_drops_a_late_records_result() {
let mut app = app_on_lens(Some("example.com"), "dns");
app.panes.dns.record_type = RecordType::MX;
let records_gen = fetch_gen_of(&app.fetch_with_current(false));
let actions = key(&mut app, KeyCode::Char('['));
assert_eq!(app.tab, 3);
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Trace {
domain,
record_type: RecordType::MX,
},
..
} if domain == "example.com"
)),
"got {actions:?}"
);
let trace_gen = fetch_gen_of(&actions);
app.update(Msg::Data {
lens: LensKey::Dns,
gen: records_gen,
result: Ok(chained_dig(1)),
});
assert!(matches!(app.state_of(app.lens), LensState::Loading));
let trace = crate::payload::fixtures::trace(vec![], None);
app.update(Msg::Data {
lens: LensKey::Dns,
gen: trace_gen,
result: Ok(LensData::Trace(Box::new(trace))),
});
assert!(matches!(
app.state_of(app.lens),
LensState::Loaded(LensData::Trace(_))
));
assert_eq!(app.row_count(), 2, "one row per hop");
}
#[test]
fn dig_command_selects_the_nameserver_and_the_trace_tab() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let actions = app.exec_command("dig @9.9.9.9 AAAA example.org");
assert_eq!(app.tab, 0);
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Dns {
domain,
record_type: RecordType::AAAA,
nameserver: Some(ns),
},
..
} if domain == "example.org" && ns == "9.9.9.9"
)),
"got {actions:?}"
);
assert_eq!(
app.panes.dns.slot_labels(),
["system", "8.8.8.8", "1.1.1.1", "9.9.9.9"]
);
let actions = app.exec_command("dig example.org https +trace");
assert_eq!(app.tab, 3, "Trace tab");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Trace {
record_type: RecordType::HTTPS,
..
},
..
}
)),
"got {actions:?}"
);
let actions = app.exec_command("dig example.org A MX");
assert!(actions.is_empty());
assert!(
matches!(&app.toast, Some(t) if t.tone == "fail" && t.msg.contains("one record type"))
);
}
#[test]
fn dig_queries_a_www_host_as_typed() {
let mut app = App::new(None);
let actions = app.exec_command("dig www.seer.test");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Dns { domain, .. },
..
} if domain == "www.seer.test"
)),
"got {actions:?}"
);
let actions = app.exec_command("dig WWW.Seer.test. +trace");
assert!(
actions.iter().any(|a| matches!(
a,
Action::Fetch {
req: FetchReq::Trace { domain, .. },
..
} if domain == "www.seer.test"
)),
"got {actions:?}"
);
assert_eq!(app.domain.as_deref(), Some("www.seer.test"));
}
#[test]
fn dig_and_compare_to_another_tab_drop_the_records_filter() {
for command in [
"dig example.com +trace",
"compare example.com 8.8.8.8 1.1.1.1",
] {
let mut app = dns_app_with(chained_dig(3));
app.focus = Focus::Pane;
key(&mut app, KeyCode::Char('/'));
for c in "192.0.2.3".chars() {
key(&mut app, KeyCode::Char(c));
}
key(&mut app, KeyCode::Enter);
assert_eq!(app.active_filter(), "192.0.2.3");
app.exec_command(command);
assert_ne!(app.tab, 0, "{command}");
assert_eq!(app.active_filter(), "", "{command}");
app.exec_command("dig example.com");
assert_eq!(app.tab, 0, "{command}");
assert_eq!(app.active_filter(), "", "{command}");
}
let mut app = dns_app_with(chained_dig(3));
app.lens_filter.insert(LensKey::Dns, "192.0.2.3".into());
app.exec_command("dig example.com");
assert_eq!(app.tab, 0);
assert_eq!(app.active_filter(), "192.0.2.3");
}
#[test]
fn copy_and_raw_view_serialize_the_dig_result() {
let mut app = dns_app_with(chained_dig(1));
let actions = key(&mut app, KeyCode::Char('y'));
let Some(Action::Copy { text, label }) = actions.first() else {
panic!("expected a copy, got {actions:?}");
};
assert!(
text.starts_with("## DNS A Records: www.seer.test"),
"{text}"
);
assert!(label.contains("markdown"), "{label}");
app.format = OutputFormat::Json;
let actions = key(&mut app, KeyCode::Char('y'));
let Some(Action::Copy { text, .. }) = actions.first() else {
panic!("expected a copy, got {actions:?}");
};
let value: serde_json::Value = serde_json::from_str(text).expect("one JSON object");
assert_eq!(value["status"], "NOERROR");
assert_eq!(value["answers"][1]["name"], "edge.cdn.test");
}
fn has_fetch(actions: &[Action]) -> bool {
actions.iter().any(|a| matches!(a, Action::Fetch { .. }))
}
#[test]
fn heavy_lenses_wait_for_enter_instead_of_scanning_on_nav() {
for lens in [LensKey::Subdomains, LensKey::Takeover] {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let mut landed = vec![];
while app.current_lens().key != lens {
landed = key(&mut app, KeyCode::Char('j'));
}
assert!(
!has_fetch(&landed),
"{lens:?} must not auto-run: {landed:?}"
);
assert!(matches!(app.state_of(app.lens), LensState::Idle));
let actions = key(&mut app, KeyCode::Enter);
assert!(has_fetch(&actions), "{lens:?}: {actions:?}");
assert!(matches!(app.state_of(app.lens), LensState::Loading));
key(&mut app, KeyCode::Char('k'));
assert!(!has_fetch(&key(&mut app, KeyCode::Char('j'))));
}
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
app.input_mode = InputMode::Command("subdomains".into());
assert!(has_fetch(&key(&mut app, KeyCode::Enter)));
}
#[test]
fn superseded_fetches_are_cancelled_before_their_replacement() {
let mut app = App::new(Some("a.com".into()));
let _ = app.take_startup_actions();
key(&mut app, KeyCode::Char('2')); let actions = app.update(Msg::Input(Event::Paste(String::new())));
assert!(actions.is_empty(), "a no-op update cancels nothing");
app.input_mode = InputMode::Field {
target: EditTarget::Target,
buf: "b.com".into(),
};
let actions = key(&mut app, KeyCode::Enter);
assert_eq!(app.domain.as_deref(), Some("b.com"));
let first_fetch = actions
.iter()
.position(|a| matches!(a, Action::Fetch { .. }))
.expect("the new target fetches");
for lens in [LensKey::Overview, LensKey::Whois] {
let cancel = actions
.iter()
.position(|a| matches!(a, Action::CancelFetch(k) if *k == lens))
.unwrap_or_else(|| panic!("{lens:?} not cancelled: {actions:?}"));
assert!(cancel < first_fetch, "{actions:?}");
}
app.lens = LensKey::Rdap.index();
let actions = key(&mut app, KeyCode::Char(']'));
assert!(matches!(
actions.first(),
Some(Action::CancelFetch(LensKey::Rdap))
));
}
#[test]
fn every_lens_change_resets_tab_and_selection() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
app.update(Msg::Data {
lens: LensKey::Overview,
gen: 1,
result: Ok(empty_dig()),
});
assert!(matches!(app.state_of(0), LensState::Loaded(_)));
app.lens = LensKey::Dns.index();
app.tab = 3;
app.sel = 4;
let actions = key(&mut app, KeyCode::Char('g'));
assert_eq!((app.lens, app.tab, app.sel), (0, 0, 0));
assert!(
!has_fetch(&actions),
"the cached Overview is served: {actions:?}"
);
app.tab = 2;
key(&mut app, KeyCode::Char('G'));
assert_eq!((app.tab, app.sel), (0, 0));
app.lens = LensKey::Dns.index();
app.tab = 3;
key(&mut app, KeyCode::Char('/'));
let actions = key(&mut app, KeyCode::Enter);
assert_eq!((app.lens, app.tab), (0, 0));
assert!(!has_fetch(&actions), "{actions:?}");
}
#[test]
fn invalid_targets_are_refused_and_ip_literals_accepted() {
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
key(&mut app, KeyCode::Char('/'));
app.input_mode = InputMode::Field {
target: EditTarget::Target,
buf: "not a domain!".into(),
};
let actions = key(&mut app, KeyCode::Enter);
assert!(actions.is_empty(), "{actions:?}");
assert_eq!(app.domain.as_deref(), Some("example.com"));
assert!(matches!(&app.toast, Some(t) if t.tone == "fail"));
key(&mut app, KeyCode::Char(':'));
app.input_mode = InputMode::Command("reverse 2001:DB8::1".into());
let actions = key(&mut app, KeyCode::Enter);
assert_eq!(app.domain.as_deref(), Some("2001:db8::1"));
assert!(actions.iter().any(|a| matches!(
a,
Action::Fetch { req: FetchReq::Reverse(ip), .. } if ip == "2001:db8::1"
)));
app.input_mode = InputMode::Command("dig *.example.com".into());
key(&mut app, KeyCode::Enter);
assert_eq!(app.domain.as_deref(), Some("*.example.com"));
}
#[test]
fn pasted_control_characters_become_spaces() {
let mut app = App::new(None);
key(&mut app, KeyCode::Char('/'));
app.update(Msg::Input(Event::Paste(
"a.com\r\nb.com\tc\u{1b}[2J".into(),
)));
let InputMode::Field { buf, .. } = &app.input_mode else {
panic!("field closed");
};
assert_eq!(buf.as_str(), "a.com b.com c [2J");
}
#[test]
fn idle_ticks_do_not_redraw() {
let mut app = App::new(None);
assert!(app.take_redraw(), "the first frame");
app.update(Msg::Tick);
assert!(!app.take_redraw(), "nothing animates");
key(&mut app, KeyCode::Char('j'));
assert!(app.take_redraw(), "input changes the frame");
app.set_toast("ok", "hi");
for _ in 0..TOAST_TICKS - 1 {
app.update(Msg::Tick);
assert!(!app.take_redraw());
}
app.update(Msg::Tick);
assert!(app.toast.is_none());
assert!(app.take_redraw(), "the expired toast must be cleared");
app.states
.insert(app.current_lens().key, LensState::Loading);
app.update(Msg::Tick);
assert!(app.take_redraw());
}
#[test]
fn a_session_opens_on_the_rendered_view() {
assert_eq!(App::new(None).format, OutputFormat::Human);
}
#[test]
fn the_raw_view_scrolls_and_resets_on_lens_change() {
let mut app = App::new(None);
app.lens = LensKey::Dns.index();
app.update(Msg::Data {
lens: LensKey::Dns,
gen: 0,
result: Ok(chained_dig(3)),
});
key(&mut app, KeyCode::Char('r'));
assert!(app.raw_view());
let lines = app.raw_line_count() as u16;
assert!(lines > PAGE_ROWS, "fixture too short: {lines}");
key(&mut app, KeyCode::PageDown);
assert_eq!(app.raw_scroll, PAGE_ROWS);
key(&mut app, KeyCode::Enter);
assert_eq!(app.focus, Focus::Pane);
key(&mut app, KeyCode::Char('j'));
assert_eq!(app.raw_scroll, PAGE_ROWS + 1);
key(&mut app, KeyCode::Char('k'));
key(&mut app, KeyCode::PageUp);
key(&mut app, KeyCode::PageUp);
assert_eq!(app.raw_scroll, 0, "clamped at the top");
for _ in 0..(lines / PAGE_ROWS + 3) {
key(&mut app, KeyCode::PageDown);
}
assert_eq!(app.raw_scroll, lines - 1, "clamped at the last line");
key(&mut app, KeyCode::Esc);
key(&mut app, KeyCode::Char('j'));
assert_eq!(app.raw_scroll, 0, "another lens starts at its top");
}
}