use std::{
collections::{BTreeMap, HashMap},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use color_eyre::eyre::Result;
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use ratatui::{
DefaultTerminal,
layout::{Position, Rect},
};
use crate::{
discovery::{
BrowseMode, DiscoveryEvent, DiscoverySession, Entry, EntryGroup, EntryGroupId, EntryId,
GroupingMode, RowHost, SessionPoll, SessionState, browse_groups, browse_row_count,
},
plumber::{ActionOutcome, CommandConfig, MatchResult, PreparedCommand, RuleEngine},
};
use super::{
cli::Cli,
filter::FilterState,
keymap::{Action, KeyBindings, Mode as KeyMode},
layout::{Content, LayoutSnapshot},
render,
viewport::Window,
};
pub enum ReloadOutcome {
Loaded(Box<dyn RuleEngine>),
Rejected(Vec<String>),
}
pub type ConfigLoader = Box<dyn Fn(&Cli) -> ReloadOutcome>;
pub type DiscoveryFactory = Box<dyn Fn() -> DiscoverySession>;
#[derive(Debug, Clone)]
pub struct CommandGroup {
pub command: CommandConfig,
pub services: Vec<EntryGroup>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickerAnchor {
Row(EntryGroupId),
Service {
command: String,
service: EntryGroupId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Browse,
Search,
TypeFilter,
ActionPicker,
InstancePicker,
ServicePicker,
Help,
}
impl AppMode {
pub fn key_mode(self) -> KeyMode {
match self {
AppMode::Browse => KeyMode::Browse,
AppMode::Search => KeyMode::Search,
AppMode::TypeFilter => KeyMode::TypeFilter,
AppMode::ActionPicker | AppMode::InstancePicker | AppMode::ServicePicker => {
KeyMode::Picker
}
AppMode::Help => KeyMode::Help,
}
}
}
pub struct App {
pub cli: Cli,
pub matcher: Box<dyn RuleEngine>,
pub keybindings: KeyBindings,
pub session: DiscoverySession,
pub records: BTreeMap<EntryId, Entry>,
pub filter: FilterState,
pub visible_groups: Vec<EntryGroup>,
pub selected: usize,
pub mode: AppMode,
pub type_filter_index: usize,
pub picker_anchor: Option<PickerAnchor>,
pub action_matches: Vec<MatchResult>,
pub action_index: usize,
pub pending_action: Option<MatchResult>,
pub instance_index: usize,
pub status: String,
pub should_quit: bool,
pub reload_requested: Arc<AtomicBool>,
pub config_loader: Option<ConfigLoader>,
pub reload_diagnostics: Vec<String>,
pub discovery_factory: Option<DiscoveryFactory>,
pub group_matches: Vec<Vec<MatchResult>>,
pub tab_counts: [usize; GroupingMode::TABS.len()],
pub ticks: u64,
pub command_groups: Vec<CommandGroup>,
pub service_picker_index: usize,
pub help_scroll: usize,
pub details_scroll: usize,
pub(crate) layout: LayoutSnapshot,
}
impl App {
pub fn new(
cli: Cli,
matcher: impl RuleEngine + 'static,
keybindings: KeyBindings,
session: DiscoverySession,
) -> Self {
let status = format!(
"domain: {} | commands: {} | waiting for services",
cli.domain,
matcher.command_count()
);
Self {
cli,
matcher: Box::new(matcher),
keybindings,
session,
records: BTreeMap::new(),
filter: FilterState::default(),
visible_groups: Vec::new(),
selected: 0,
mode: AppMode::Browse,
type_filter_index: 0,
picker_anchor: None,
action_matches: Vec::new(),
action_index: 0,
pending_action: None,
instance_index: 0,
status,
should_quit: false,
reload_requested: Arc::new(AtomicBool::new(false)),
config_loader: None,
reload_diagnostics: Vec::new(),
discovery_factory: None,
group_matches: Vec::new(),
tab_counts: [0; GroupingMode::TABS.len()],
ticks: 0,
command_groups: Vec::new(),
service_picker_index: 0,
help_scroll: 0,
details_scroll: 0,
layout: LayoutSnapshot::default(),
}
}
pub fn with_discovery_factory(mut self, factory: DiscoveryFactory) -> Self {
self.discovery_factory = Some(factory);
self
}
pub fn with_config_loader(mut self, loader: ConfigLoader) -> Self {
self.config_loader = Some(loader);
self
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<PreparedCommand>> {
let _mouse_capture = MouseCaptureGuard::enable();
self.recompute_visible();
loop {
self.ticks = self.ticks.wrapping_add(1);
self.poll_reload();
self.drain_discovery();
terminal.autoresize()?;
self.update_layout(terminal.get_frame().area());
terminal.draw(|frame| render::render(frame, self))?;
if event::poll(Duration::from_millis(120))? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Release {
continue;
}
if let Some(command) = self.handle_key(key, self.layout.area())? {
return Ok(Some(command));
}
if self.should_quit {
return Ok(None);
}
}
Event::Mouse(mouse) => self.handle_mouse(mouse),
_ => {}
}
}
}
}
pub(crate) fn update_layout(&mut self, area: Rect) {
self.layout = LayoutSnapshot::compute(
area,
Content {
list_total: self.active_count(),
details_total: render::details_content_height(self),
},
);
self.details_scroll = self.details_scroll.min(self.layout.details_max_scroll());
}
pub(crate) fn drain_discovery(&mut self) {
let mut changed = false;
loop {
let event = match self.session.poll() {
SessionPoll::Event(event) => event,
SessionPoll::Idle => break,
SessionPoll::Ended(state) => {
changed |= self.apply_session_end(&state);
break;
}
};
match event {
DiscoveryEvent::Upsert(record) => {
let id = record.id();
if !id.is_pending() {
self.records
.remove(&EntryId::pending(record.registration()));
}
self.records.insert(id, record);
changed = true;
}
DiscoveryEvent::Remove(id) => {
changed |= self.records.remove(&id).is_some();
}
DiscoveryEvent::RemoveRegistration(registration) => {
let before = self.records.len();
self.records
.retain(|id, _| *id.registration() != registration);
changed |= self.records.len() != before;
}
DiscoveryEvent::Status(status) => {
self.status = status;
}
}
}
if changed {
self.recompute_visible();
}
}
fn apply_session_end(&mut self, state: &SessionState) -> bool {
match state {
SessionState::Listening => false,
SessionState::Complete => {
self.status = format!(
"sample discovery complete | {} record(s)",
self.records.len()
);
false
}
SessionState::Failed(failure) => {
self.close_pickers();
let had_records = !self.records.is_empty();
self.records.clear();
self.status = failure.message();
had_records
}
}
}
fn recompute_visible(&mut self) {
let records = self.records.values().cloned().collect::<Vec<_>>();
self.filter.observe_types(&records);
let filtered = self.filter.apply(&records);
self.tab_counts = self.count_tabs(&filtered);
let Some(browse) = self.filter.grouping.browse_mode() else {
self.recompute_command_groups(&filtered);
return;
};
self.command_groups = Vec::new();
let previous = self
.visible_groups
.get(self.selected)
.map(|group| group.id().clone());
self.visible_groups = browse_groups(&filtered, browse);
self.group_matches = self
.visible_groups
.iter()
.map(|group| self.matcher.matches_group(group))
.collect();
match find_selection(&self.visible_groups, previous.clone(), |group| {
group.id().clone()
}) {
Some(index) => self.selected = index,
None => self.clamp_selection(),
}
self.refocus_details(previous, |app| {
app.visible_groups.get(app.selected).map(|g| g.id().clone())
});
self.reconcile_action_pickers();
}
fn refocus_details<K: PartialEq>(
&mut self,
before: Option<K>,
focused: impl Fn(&Self) -> Option<K>,
) {
if focused(self) != before {
self.details_scroll = 0;
}
}
fn resolve_anchor(&self, anchor: &PickerAnchor) -> Vec<MatchResult> {
match anchor {
PickerAnchor::Row(id) => self
.visible_groups
.iter()
.position(|group| group.id() == id)
.and_then(|index| self.group_matches.get(index))
.cloned()
.unwrap_or_default(),
PickerAnchor::Service { command, service } => self
.command_groups
.iter()
.find(|group| group.command.name == *command)
.and_then(|group| group.services.iter().find(|s| s.id() == service))
.map(|group| {
self.matcher
.matches_group(group)
.into_iter()
.filter(|result| result.command.name == *command)
.collect()
})
.unwrap_or_default(),
}
}
fn anchor_label(&self, anchor: &PickerAnchor) -> String {
match anchor {
PickerAnchor::Row(id) => self
.visible_groups
.iter()
.find(|group| group.id() == id)
.map(|group| group.label().to_string())
.unwrap_or_else(|| "the selected service".to_string()),
PickerAnchor::Service { service, .. } => self
.command_groups
.iter()
.flat_map(|group| &group.services)
.find(|s| s.id() == service)
.map(|group| group.label().to_string())
.unwrap_or_else(|| "the selected service".to_string()),
}
}
fn reconcile_action_pickers(&mut self) {
let Some(anchor) = self.picker_anchor.clone() else {
return;
};
let chosen_action = self
.action_matches
.get(self.action_index)
.map(|result| result.command.name.clone());
let pending_command = self
.pending_action
.as_ref()
.map(|result| result.command.name.clone());
let chosen_target = self.pending_action.as_ref().and_then(|result| {
result
.targets
.get(self.instance_index)
.map(|target| result.command.action.prepare(target).ok())
});
let matches = self.resolve_anchor(&anchor);
if matches.is_empty() {
let label = self.anchor_label(&anchor);
self.abandon_picker(format!("`{label}` is no longer available"));
return;
}
self.action_matches = matches;
if self.mode == AppMode::ActionPicker {
match chosen_action.and_then(|name| self.position_of_action(&name)) {
Some(index) => self.action_index = index,
None => self.abandon_picker("the selected action no longer matches".to_string()),
}
return;
}
let Some(name) = pending_command else {
self.abandon_picker("the selected action no longer matches".to_string());
return;
};
let Some(live) = self
.action_matches
.iter()
.find(|result| result.command.name == name)
.cloned()
else {
self.abandon_picker(format!("`{name}` no longer matches"));
return;
};
let position = chosen_target.and_then(|chosen| {
live.targets
.iter()
.position(|target| live.command.action.prepare(target).ok() == chosen)
});
match position {
Some(index) => {
self.pending_action = Some(live);
self.instance_index = index;
}
None => self.abandon_picker("the selected target is gone".to_string()),
}
}
fn position_of_action(&self, name: &str) -> Option<usize> {
self.action_matches
.iter()
.position(|result| result.command.name == name)
}
fn count_tabs(&self, filtered: &[Entry]) -> [usize; GroupingMode::TABS.len()] {
GroupingMode::TABS.map(|mode| match mode.browse_mode() {
Some(browse) => browse_row_count(filtered, browse),
None => self.matcher.command_count(),
})
}
fn recompute_command_groups(&mut self, filtered: &[Entry]) {
let previous = self
.command_groups
.get(self.selected)
.map(|group| group.command.name.clone());
let chosen_service = self
.command_groups
.get(self.selected)
.and_then(|group| group.services.get(self.service_picker_index))
.map(|service| service.id().clone());
let service_groups = browse_groups(filtered, BrowseMode::LogicalService);
let mut command_groups: Vec<CommandGroup> = self
.matcher
.commands()
.iter()
.map(|command| CommandGroup {
command: command.clone(),
services: Vec::new(),
})
.collect();
let index: HashMap<String, usize> = command_groups
.iter()
.enumerate()
.map(|(i, group)| (group.command.name.clone(), i))
.collect();
for service_group in &service_groups {
for result in self.matcher.matches_group(service_group) {
if let Some(&i) = index.get(&result.command.name) {
command_groups[i].services.push(service_group.clone());
}
}
}
self.command_groups = command_groups;
self.visible_groups = Vec::new();
self.group_matches = Vec::new();
match find_selection(&self.command_groups, previous.clone(), |group| {
group.command.name.clone()
}) {
Some(index) => self.selected = index,
None => self.clamp_selection(),
}
self.refocus_details(previous, |app| {
app.command_groups
.get(app.selected)
.map(|group| group.command.name.clone())
});
self.reconcile_service_picker(chosen_service);
self.reconcile_action_pickers();
}
fn reconcile_service_picker(&mut self, chosen: Option<EntryGroupId>) {
if self.mode != AppMode::ServicePicker {
return;
}
let Some(services) = self.command_groups.get(self.selected).map(|g| &g.services) else {
self.abandon_picker("the selected command is gone".to_string());
return;
};
match chosen.and_then(|id| services.iter().position(|service| *service.id() == id)) {
Some(index) => self.service_picker_index = index,
None => self.abandon_picker("the selected service is gone".to_string()),
}
}
fn active_count(&self) -> usize {
if self.filter.grouping == GroupingMode::Command {
self.command_groups.len()
} else {
self.visible_groups.len()
}
}
fn clamp_selection(&mut self) {
let count = self.active_count();
if count == 0 {
self.selected = 0;
} else if self.selected >= count {
self.selected = count - 1;
}
}
fn return_to_browse(&mut self) {
self.mode = AppMode::Browse;
self.picker_anchor = None;
self.action_matches.clear();
self.pending_action = None;
}
fn abandon_picker(&mut self, reason: String) {
self.return_to_browse();
self.status = reason;
}
fn handle_key(&mut self, key: KeyEvent, area: Rect) -> Result<Option<PreparedCommand>> {
let action = self.keybindings.resolve(self.mode.key_mode(), key);
if action == Some(Action::Quit) {
self.should_quit = true;
return Ok(None);
}
match self.mode {
AppMode::Browse => self.handle_browse_key(action, key),
AppMode::Search => {
self.handle_search_key(action, key);
Ok(None)
}
AppMode::TypeFilter => {
self.handle_type_filter_key(action);
Ok(None)
}
AppMode::ActionPicker => self.handle_action_picker_key(action),
AppMode::InstancePicker => self.handle_instance_picker_key(action),
AppMode::ServicePicker => self.handle_service_picker_key(action),
AppMode::Help => {
self.handle_help_key(action, area);
Ok(None)
}
}
}
fn handle_help_key(&mut self, action: Option<Action>, area: Rect) {
match action {
Some(Action::HelpClose) => self.return_to_browse(),
Some(Action::HelpDown) => self.scroll_help(1, area),
Some(Action::HelpUp) => self.scroll_help(-1, area),
_ => {}
}
}
fn scroll_help(&mut self, delta: isize, area: Rect) {
let total = render::help_lines(self).len();
let max = Window::max_scroll(total, render::help_viewport(area)) as isize;
self.help_scroll = (self.help_scroll as isize + delta).clamp(0, max) as usize;
}
fn handle_browse_key(
&mut self,
action: Option<Action>,
key: KeyEvent,
) -> Result<Option<PreparedCommand>> {
match action {
Some(Action::BrowseQuit) => self.should_quit = true,
Some(Action::MoveDown) => self.move_selection(1),
Some(Action::MoveUp) => self.move_selection(-1),
Some(Action::Invoke) => return self.invoke_selected(),
Some(Action::OpenSearch) => self.mode = AppMode::Search,
Some(Action::OpenTypeFilter) => {
self.type_filter_index = 0;
self.mode = AppMode::TypeFilter;
}
Some(Action::TabNext) => self.cycle_tab(1),
Some(Action::TabPrev) => self.cycle_tab(-1),
Some(Action::SameHost) => self.toggle_same_host_filter(),
Some(Action::Refresh) => self.refresh_services(),
Some(Action::DetailsDown) => self.scroll_details(1),
Some(Action::DetailsUp) => self.scroll_details(-1),
Some(Action::OpenHelp) => {
self.help_scroll = 0;
self.mode = AppMode::Help;
}
None => {
if let Some(ch) = typed_char(key) {
self.filter.text_query.push(ch);
self.mode = AppMode::Search;
self.recompute_visible();
}
}
_ => {}
}
Ok(None)
}
fn handle_search_key(&mut self, action: Option<Action>, key: KeyEvent) {
match action {
Some(Action::SearchClose) => self.return_to_browse(),
Some(Action::SearchClear) => {
self.filter.clear_text();
self.recompute_visible();
}
None => match key.code {
KeyCode::Backspace | KeyCode::Delete => {
self.filter.text_query.pop();
self.recompute_visible();
}
_ => {
if let Some(ch) = typed_char(key) {
self.filter.text_query.push(ch);
self.recompute_visible();
}
}
},
_ => {}
}
}
fn handle_type_filter_key(&mut self, action: Option<Action>) {
let count = self.filter.discovered_types().len();
match action {
Some(Action::TypeFilterClose) => self.return_to_browse(),
Some(Action::TypeFilterDown) => {
self.type_filter_index = move_index(self.type_filter_index, count, 1);
}
Some(Action::TypeFilterUp) => {
self.type_filter_index = move_index(self.type_filter_index, count, -1);
}
Some(Action::TypeFilterToggle) => {
if let Some(service_type) = self
.filter
.discovered_types()
.get(self.type_filter_index)
.cloned()
{
self.filter.toggle_service_type(&service_type);
self.recompute_visible();
}
}
_ => {}
}
}
fn select_tab(&mut self, index: usize) {
let Some(&mode) = GroupingMode::TABS.get(index) else {
return;
};
if self.filter.grouping == mode {
return;
}
self.filter.grouping = mode;
self.selected = 0;
self.details_scroll = 0;
self.recompute_visible();
}
fn cycle_tab(&mut self, delta: isize) {
let len = GroupingMode::TABS.len() as isize;
let current = GroupingMode::TABS
.iter()
.position(|mode| *mode == self.filter.grouping)
.unwrap_or(0) as isize;
let next = (current + delta).rem_euclid(len) as usize;
self.select_tab(next);
}
fn handle_action_picker_key(
&mut self,
action: Option<Action>,
) -> Result<Option<PreparedCommand>> {
let len = self.action_matches.len();
match action {
Some(Action::PickerClose) => self.return_to_browse(),
Some(Action::PickerDown) => {
self.action_index = move_index(self.action_index, len, 1);
}
Some(Action::PickerUp) => {
self.action_index = move_index(self.action_index, len, -1);
}
Some(Action::PickerSelect) => {
if let Some(chosen) = self.action_matches.get(self.action_index).cloned() {
return self.choose_action(chosen);
}
}
_ => {}
}
Ok(None)
}
fn handle_instance_picker_key(
&mut self,
action: Option<Action>,
) -> Result<Option<PreparedCommand>> {
let count = self
.pending_action
.as_ref()
.map(|action| action.targets.len())
.unwrap_or(0);
match action {
Some(Action::PickerClose) => self.return_to_browse(),
Some(Action::PickerDown) => {
self.instance_index = move_index(self.instance_index, count, 1);
}
Some(Action::PickerUp) => {
self.instance_index = move_index(self.instance_index, count, -1);
}
Some(Action::PickerSelect) => {
let Some(pending) = self.pending_action.clone() else {
self.return_to_browse();
return Ok(None);
};
let Some(record) = pending.targets.get(self.instance_index) else {
self.return_to_browse();
return Ok(None);
};
return self.execute_action(&pending, record);
}
_ => {}
}
Ok(None)
}
fn move_selection(&mut self, delta: isize) {
self.selected = move_index(self.selected, self.active_count(), delta);
self.details_scroll = 0;
}
fn scroll_details(&mut self, direction: isize) {
let step = (self.layout.details_viewport() / 2).max(1) as isize;
self.scroll_details_by(direction * step);
}
fn scroll_details_by(&mut self, delta: isize) {
let max = self.layout.details_max_scroll() as isize;
self.details_scroll = (self.details_scroll as isize + delta).clamp(0, max) as usize;
}
fn handle_mouse(&mut self, mouse: MouseEvent) {
if !matches!(self.mode, AppMode::Browse | AppMode::Search) {
return;
}
let position = Position::new(mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::ScrollDown => self.mouse_scroll(position, 1),
MouseEventKind::ScrollUp => self.mouse_scroll(position, -1),
MouseEventKind::Down(MouseButton::Left) => {
if let Some(index) = self.layout.list_row_at(position, self.selected) {
self.selected = index;
self.details_scroll = 0;
}
}
_ => {}
}
}
fn mouse_scroll(&mut self, position: Position, direction: isize) {
if self.layout.is_over_details(position) {
self.scroll_details_by(direction);
} else if self.layout.list().contains(position) {
self.move_selection(direction);
}
}
fn invoke_selected(&mut self) -> Result<Option<PreparedCommand>> {
if self.filter.grouping == GroupingMode::Command {
return self.invoke_command();
}
let Some(group) = self.visible_groups.get(self.selected) else {
self.status = "no service selected".to_string();
return Ok(None);
};
let matches = self
.group_matches
.get(self.selected)
.cloned()
.unwrap_or_default();
match matches.len() {
0 => {
self.status = format!("no configured actions match `{}`", group.label());
Ok(None)
}
_ => {
self.picker_anchor = Some(PickerAnchor::Row(group.id().clone()));
if matches.len() == 1 {
return self.choose_action(matches.into_iter().next().unwrap());
}
self.action_matches = matches;
self.action_index = 0;
self.mode = AppMode::ActionPicker;
Ok(None)
}
}
}
fn invoke_command(&mut self) -> Result<Option<PreparedCommand>> {
let Some(group) = self.command_groups.get(self.selected) else {
self.status = "no command selected".to_string();
return Ok(None);
};
match group.services.len() {
0 => {
self.status = format!("no services match command `{}`", group.command.name);
Ok(None)
}
1 => {
let command = group.command.clone();
let service = group.services[0].clone();
self.run_command_on(&command, &service)
}
_ => {
self.service_picker_index = 0;
self.mode = AppMode::ServicePicker;
Ok(None)
}
}
}
fn run_command_on(
&mut self,
command: &CommandConfig,
service: &EntryGroup,
) -> Result<Option<PreparedCommand>> {
let Some(result) = self
.matcher
.matches_group(service)
.into_iter()
.find(|result| result.command.name == command.name)
else {
self.status = format!("`{}` no longer matches `{}`", command.name, service.label());
self.return_to_browse();
return Ok(None);
};
self.picker_anchor = Some(PickerAnchor::Service {
command: command.name.clone(),
service: service.id().clone(),
});
self.choose_action(result)
}
fn handle_service_picker_key(
&mut self,
action: Option<Action>,
) -> Result<Option<PreparedCommand>> {
let count = self
.command_groups
.get(self.selected)
.map(|group| group.services.len())
.unwrap_or(0);
match action {
Some(Action::PickerClose) => self.return_to_browse(),
Some(Action::PickerDown) => {
self.service_picker_index = move_index(self.service_picker_index, count, 1);
}
Some(Action::PickerUp) => {
self.service_picker_index = move_index(self.service_picker_index, count, -1);
}
Some(Action::PickerSelect) => {
let Some(group) = self.command_groups.get(self.selected) else {
self.return_to_browse();
return Ok(None);
};
let Some(service) = group.services.get(self.service_picker_index).cloned() else {
self.return_to_browse();
return Ok(None);
};
let command = group.command.clone();
return self.run_command_on(&command, &service);
}
_ => {}
}
Ok(None)
}
fn choose_action(&mut self, action: MatchResult) -> Result<Option<PreparedCommand>> {
if action.needs_selection() {
self.pending_action = Some(action);
self.instance_index = 0;
self.mode = AppMode::InstancePicker;
return Ok(None);
}
let Some(record) = action.targets.first() else {
self.status = "selected action has no matching services".to_string();
self.return_to_browse();
return Ok(None);
};
self.execute_action(&action, record)
}
fn execute_action(
&mut self,
action: &MatchResult,
record: &Entry,
) -> Result<Option<PreparedCommand>> {
let name = &action.command.name;
match action.command.run(record) {
Ok(ActionOutcome::Forked) => {
self.status = format!("launched `{name}`");
self.return_to_browse();
Ok(None)
}
Ok(ActionOutcome::Handoff(prepared)) => Ok(Some(prepared)),
Err(err) => self.fail(format!("cannot run `{name}`: {err}")),
}
}
fn poll_reload(&mut self) {
if self.reload_requested.swap(false, Ordering::Relaxed) {
self.reload_config();
}
}
fn reload_config(&mut self) {
let Some(loader) = &self.config_loader else {
self.status = "config reload is not available".to_string();
return;
};
match loader(&self.cli) {
ReloadOutcome::Loaded(matcher) => {
self.matcher = matcher;
self.reload_diagnostics.clear();
self.close_pickers();
self.recompute_visible();
self.status = format!("reloaded {} command(s)", self.matcher.command_count());
}
ReloadOutcome::Rejected(diagnostics) => {
self.status = format!(
"config reload rejected: {} invalid config file(s); \
keeping the {} command(s) already loaded; details printed on exit",
diagnostics.len(),
self.matcher.command_count()
);
self.reload_diagnostics = diagnostics;
}
}
}
fn refresh_services(&mut self) {
let Some(factory) = &self.discovery_factory else {
self.status = "refresh is not available".to_string();
return;
};
self.session.shutdown();
let replacement = factory();
self.session = replacement;
self.records.clear();
self.close_pickers();
self.selected = 0;
self.details_scroll = 0;
self.recompute_visible();
self.status = "refreshing: service discovery restarted".to_string();
}
fn close_pickers(&mut self) {
if matches!(
self.mode,
AppMode::ActionPicker | AppMode::InstancePicker | AppMode::ServicePicker
) {
self.return_to_browse();
}
}
fn fail(&mut self, message: String) -> Result<Option<PreparedCommand>> {
self.status = message;
self.return_to_browse();
Ok(None)
}
fn toggle_same_host_filter(&mut self) {
if self.filter.host_filter.is_some() {
self.filter.clear_host_filter();
self.status = "host filter cleared".to_string();
self.recompute_visible();
return;
}
let view = self.filter.grouping.label();
let unavailable =
|reason| format!("same-host filter is unavailable in the {view} view: {reason}");
if self.filter.grouping.browse_mode().is_none() {
self.status = unavailable("it lists commands, not discovered services");
return;
}
let Some(group) = self.visible_groups.get(self.selected) else {
self.status = "no row selected".to_string();
return;
};
match group.facts().host() {
RowHost::Resolved(host) => {
let host = host.to_string();
self.status = format!("filtering by host `{host}`");
self.filter.set_host_filter(host);
self.recompute_visible();
}
RowHost::Unresolved => {
self.status = "selected row has no resolved host yet".to_string();
}
RowHost::Varies => {
self.status = unavailable("its rows span several hosts");
}
}
}
}
struct MouseCaptureGuard;
impl MouseCaptureGuard {
fn enable() -> Self {
let _ = crossterm::execute!(std::io::stdout(), event::EnableMouseCapture);
Self
}
}
impl Drop for MouseCaptureGuard {
fn drop(&mut self) {
let _ = crossterm::execute!(std::io::stdout(), event::DisableMouseCapture);
}
}
fn move_index(index: usize, len: usize, delta: isize) -> usize {
if len == 0 {
return 0;
}
(index as isize + delta).clamp(0, len as isize - 1) as usize
}
fn typed_char(key: KeyEvent) -> Option<char> {
let modified = key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
match key.code {
KeyCode::Char(ch) if !modified && !ch.is_control() => Some(ch),
_ => None,
}
}
fn find_selection<T, K: PartialEq>(
items: &[T],
previous: Option<K>,
key: impl Fn(&T) -> K,
) -> Option<usize> {
let previous = previous?;
items.iter().position(|item| key(item) == previous)
}
#[cfg(test)]
mod tests {
use std::{net::IpAddr, num::NonZeroU32, sync::mpsc};
use super::*;
use crate::ui::keymap::KeyBindings;
use crate::{
discovery::{OccurrenceId, Registration, RowServiceType, UNRESOLVED_HOST_LABEL},
plumber::Matcher,
};
fn test_cli() -> Cli {
Cli {
domain: "local".to_string(),
config_dirs: Vec::new(),
service_type: None,
backend: crate::discovery::DiscoveryBackend::default(),
command: crate::ui::cli::CliCommand::Run,
}
}
#[test]
fn resolved_service_replaces_pending_record() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
);
let pending = Entry::new("workstation", "_ssh._tcp", "local");
let mut resolved = Entry::new("workstation", "_ssh._tcp", "local");
resolved.hostname = Some("workstation.local".to_string());
resolved.addresses = vec!["192.168.1.20".parse().unwrap()];
resolved.port = Some(22);
tx.send(DiscoveryEvent::Upsert(pending)).unwrap();
tx.send(DiscoveryEvent::Upsert(resolved)).unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].instances().len(), 1);
assert_eq!(
app.visible_groups[0].facts().host(),
RowHost::Resolved("workstation.local")
);
}
#[test]
fn command_grouping_counts_distinct_matching_services() {
use crate::plumber::MatcherBuilder;
let mut builder = MatcherBuilder::new();
builder
.add_str(
"ssh",
r#"
[metadata]
name = "ssh"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "ssh {hostname}"
mode = "execute"
"#,
)
.unwrap();
let matcher = builder.build();
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
matcher,
KeyBindings::default(),
DiscoverySession::detached(rx),
);
let mut alpha = Entry::new("alpha", "_ssh._tcp", "local");
alpha.hostname = Some("alpha.local".to_string());
alpha.addresses = vec!["192.168.1.10".parse().unwrap()];
alpha.port = Some(22);
let mut beta = Entry::new("beta", "_ssh._tcp", "local");
beta.hostname = Some("beta.local".to_string());
beta.addresses = vec!["192.168.1.11".parse().unwrap()];
beta.port = Some(22);
let web = Entry::new("web", "_http._tcp", "local");
tx.send(DiscoveryEvent::Upsert(alpha)).unwrap();
tx.send(DiscoveryEvent::Upsert(beta)).unwrap();
tx.send(DiscoveryEvent::Upsert(web)).unwrap();
app.drain_discovery();
app.filter.grouping = GroupingMode::Command;
app.recompute_visible();
assert_eq!(app.command_groups.len(), 1);
assert_eq!(app.command_groups[0].command.name, "ssh");
assert_eq!(app.command_groups[0].services.len(), 2);
}
#[test]
fn multiple_resolved_addresses_collapse_onto_one_service() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
);
let mut service = Entry::new("workstation", "_ssh._tcp", "local");
service.hostname = Some("workstation.local".to_string());
service.addresses = vec![
"192.168.1.20".parse().unwrap(),
"192.168.1.21".parse().unwrap(),
];
service.port = Some(22);
tx.send(DiscoveryEvent::Upsert(service)).unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].instances().len(), 1);
assert_eq!(app.visible_groups[0].instances()[0].addresses.len(), 2);
}
use crate::plumber::MatcherBuilder;
use crate::test_support::{remove, temp_file};
use crossterm::event::KeyModifiers;
const SSH: &str = r#"
[metadata]
name = "ssh"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "ssh {hostname}"
mode = "execute"
"#;
const PING: &str = r#"
[metadata]
name = "ping"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "true"
mode = "fork"
"#;
const PING_ADDR: &str = r#"
[metadata]
name = "ping-addr"
[match.address]
regex = "^10[.]"
[action]
command = "echo {address}"
mode = "execute"
"#;
fn matcher_from(sources: &[&str]) -> Matcher {
let mut builder = MatcherBuilder::new();
builder.start_layer();
for (index, source) in sources.iter().enumerate() {
builder.add_str(&format!("test-{index}"), source).unwrap();
}
builder.build()
}
fn app_with(matcher: Matcher, records: Vec<Entry>) -> App {
let mut app = App::new(
test_cli(),
matcher,
KeyBindings::default(),
DiscoverySession::inert(),
);
for record in records {
app.records.insert(record.id(), record);
}
app.recompute_visible();
app
}
fn ssh(name: &str, addr: &str) -> Entry {
let mut record = Entry::new(name, "_ssh._tcp", "local");
record.hostname = Some(format!("{name}.local"));
record.addresses = vec![addr.parse().unwrap()];
record.port = Some(22);
record
}
fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
const MOUSE_SCREEN: Rect = Rect {
x: 0,
y: 0,
width: 100,
height: 10,
};
fn mouse_app(count: usize) -> App {
let names = ["a", "b", "c", "d", "e", "f", "g", "h"];
let records = names[..count]
.iter()
.map(|name| ssh(name, "10.0.0.1"))
.collect();
let mut app = app_with(Matcher::default(), records);
app.update_layout(MOUSE_SCREEN);
app
}
#[test]
fn wheel_over_list_moves_the_selection() {
let mut app = mouse_app(8);
assert_eq!(app.selected, 0);
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 5, 4));
assert_eq!(app.selected, 1);
app.handle_mouse(mouse_event(MouseEventKind::ScrollUp, 5, 4));
app.handle_mouse(mouse_event(MouseEventKind::ScrollUp, 5, 4));
assert_eq!(app.selected, 0, "selection clamps at the top");
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 5, 0));
assert_eq!(app.selected, 0);
}
#[test]
fn wheel_over_details_scrolls_content_line_by_line() {
let mut app = mouse_app(8);
let max = app.layout.details_max_scroll();
assert!(max > 2, "the fixture's details must overflow its pane");
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
assert_eq!(app.details_scroll, 2);
for _ in 0..max + 5 {
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
}
assert_eq!(app.details_scroll, max);
for _ in 0..max + 5 {
app.handle_mouse(mouse_event(MouseEventKind::ScrollUp, 70, 4));
}
assert_eq!(app.details_scroll, 0);
assert_eq!(app.selected, 0);
}
#[test]
fn click_selects_the_row_under_the_cursor() {
let mut app = mouse_app(8);
app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 6));
assert_eq!(app.selected, 3);
app.selected = 7;
app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 3));
assert_eq!(app.selected, 3);
}
#[test]
fn clicks_on_borders_and_empty_rows_are_ignored() {
let mut app = mouse_app(8);
app.selected = 2;
for (x, y) in [(5, 2), (5, 8), (70, 4)] {
app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), x, y));
assert_eq!(app.selected, 2, "click at ({x},{y}) must not select");
}
let mut small = mouse_app(2);
small.selected = 1;
small.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 6));
assert_eq!(small.selected, 1);
}
#[test]
fn mouse_is_ignored_in_modal_modes() {
let mut app = mouse_app(8);
app.mode = AppMode::Help;
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 5, 4));
app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 6));
assert_eq!(app.selected, 0);
}
fn ssh_multi(name: &str, addrs: &[&str]) -> Entry {
let mut record = ssh(name, addrs[0]);
record.addresses = addrs.iter().map(|a| a.parse().unwrap()).collect();
record
}
fn http(name: &str) -> Entry {
let mut record = Entry::new(name, "_http._tcp", "local");
record.hostname = Some(format!("{name}.local"));
record.addresses = vec!["192.168.1.50".parse().unwrap()];
record.port = Some(80);
record
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
const SCREEN: Rect = Rect {
x: 0,
y: 0,
width: 120,
height: 40,
};
fn send(app: &mut App, code: KeyCode) -> Option<PreparedCommand> {
app.handle_key(key(code), SCREEN).unwrap()
}
#[test]
fn navigation_moves_and_clamps_selection_and_resets_scroll() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
);
assert_eq!(app.visible_groups.len(), 2);
app.details_scroll = 4;
send(&mut app, KeyCode::Down);
assert_eq!(app.selected, 1);
assert_eq!(
app.details_scroll, 0,
"moving rows resets the detail scroll"
);
send(&mut app, KeyCode::Down);
assert_eq!(app.selected, 1, "down clamps at the last row");
send(&mut app, KeyCode::Up);
send(&mut app, KeyCode::Up);
assert_eq!(app.selected, 0, "up clamps at the first row");
}
#[test]
fn typing_in_browse_enters_search_and_filters() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
);
send(&mut app, KeyCode::Char('z'));
assert_eq!(app.mode, AppMode::Search);
assert_eq!(app.filter.text_query, "z");
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].label(), "zulu");
}
#[test]
fn search_backspace_clear_and_close() {
let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
send(&mut app, KeyCode::Char('z'));
send(&mut app, KeyCode::Char('u'));
assert_eq!(app.filter.text_query, "zu");
send(&mut app, KeyCode::Backspace);
assert_eq!(app.filter.text_query, "z");
app.handle_key(
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
SCREEN,
)
.unwrap();
assert_eq!(app.filter.text_query, "", "ctrl-u clears the query");
send(&mut app, KeyCode::Enter);
assert_eq!(app.mode, AppMode::Browse, "enter closes search");
}
#[test]
fn delete_removes_the_last_search_character_like_backspace() {
let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
send(&mut app, KeyCode::Char('z'));
send(&mut app, KeyCode::Char('u'));
assert_eq!(app.filter.text_query, "zu");
send(&mut app, KeyCode::Delete);
assert_eq!(app.filter.text_query, "z");
send(&mut app, KeyCode::Delete);
assert_eq!(app.filter.text_query, "");
send(&mut app, KeyCode::Delete);
assert_eq!(app.filter.text_query, "");
assert_eq!(app.mode, AppMode::Search, "editing stays open");
}
#[test]
fn deleting_a_character_recomputes_the_visible_rows() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
);
send(&mut app, KeyCode::Char('z'));
assert_eq!(app.visible_groups.len(), 1);
send(&mut app, KeyCode::Delete);
assert_eq!(app.filter.text_query, "");
assert_eq!(app.visible_groups.len(), 2, "both rows are visible again");
}
#[test]
fn escape_and_enter_close_search_but_keep_the_query() {
for close in [KeyCode::Esc, KeyCode::Enter] {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
);
send(&mut app, KeyCode::Char('z'));
assert_eq!(app.mode, AppMode::Search);
send(&mut app, close);
assert_eq!(app.mode, AppMode::Browse, "{close:?} leaves search");
assert_eq!(
app.filter.text_query, "z",
"{close:?} must keep the active query"
);
assert_eq!(
app.visible_groups.len(),
1,
"{close:?} keeps the list filtered"
);
}
}
#[test]
fn clear_empties_the_query_and_restores_every_row() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
);
send(&mut app, KeyCode::Char('z'));
assert_eq!(app.visible_groups.len(), 1);
app.handle_key(
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
SCREEN,
)
.unwrap();
assert_eq!(app.filter.text_query, "");
assert_eq!(app.mode, AppMode::Search, "clearing stays in search");
assert_eq!(app.visible_groups.len(), 2);
}
#[test]
fn a_rebound_clear_replaces_the_default_clear_key() {
let path = temp_file(
"search-clear",
r#"
[search]
clear = ["ctrl-k"]
"#,
);
let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();
send(&mut app, KeyCode::Char('z'));
app.handle_key(
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
SCREEN,
)
.unwrap();
assert_eq!(app.filter.text_query, "z", "ctrl-u no longer clears");
app.handle_key(
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
SCREEN,
)
.unwrap();
assert_eq!(app.filter.text_query, "");
remove(&path);
}
const SHORT_SCREEN: Rect = Rect {
x: 0,
y: 0,
width: 60,
height: 18,
};
fn help_app() -> App {
let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
app.mode = AppMode::Help;
app
}
fn help_max_scroll(app: &App, area: Rect) -> usize {
Window::max_scroll(render::help_lines(app).len(), render::help_viewport(area))
}
#[test]
fn help_opens_at_the_top_and_scrolls_down_a_row_at_a_time() {
let mut app = help_app();
app.help_scroll = 7;
app.mode = AppMode::Browse;
send(&mut app, KeyCode::Char('?'));
assert_eq!(app.mode, AppMode::Help);
assert_eq!(app.help_scroll, 0, "help must open where it is read from");
app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
assert_eq!(app.help_scroll, 1);
app.handle_key(key(KeyCode::Char('j')), SHORT_SCREEN)
.unwrap();
assert_eq!(app.help_scroll, 2);
app.handle_key(key(KeyCode::Up), SHORT_SCREEN).unwrap();
assert_eq!(app.help_scroll, 1);
}
#[test]
fn help_scrolling_stops_at_both_ends_of_the_content() {
let mut app = help_app();
let max = help_max_scroll(&app, SHORT_SCREEN);
assert!(
max > 0,
"help must be clipped at 60x18 for this to mean much"
);
for _ in 0..max + 10 {
app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
}
assert_eq!(app.help_scroll, max);
for _ in 0..max + 10 {
app.handle_key(key(KeyCode::Up), SHORT_SCREEN).unwrap();
}
assert_eq!(app.help_scroll, 0);
}
#[test]
fn help_does_not_scroll_when_all_of_it_fits() {
let mut app = help_app();
assert_eq!(help_max_scroll(&app, SCREEN), 0);
app.handle_key(key(KeyCode::Down), SCREEN).unwrap();
assert_eq!(app.help_scroll, 0);
}
#[test]
fn rebound_help_scroll_keys_replace_the_defaults() {
let path = temp_file(
"help-scroll",
r#"
[help]
down = ["ctrl-n"]
up = ["ctrl-p"]
"#,
);
let mut app = help_app();
app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();
app.handle_key(
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
SHORT_SCREEN,
)
.unwrap();
assert_eq!(app.help_scroll, 1);
app.handle_key(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
SHORT_SCREEN,
)
.unwrap();
assert_eq!(app.help_scroll, 0);
app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
assert_eq!(app.help_scroll, 0, "`down` is no longer bound to help.down");
remove(&path);
}
#[test]
fn unbound_help_scroll_keys_do_nothing() {
let path = temp_file(
"help-scroll-off",
r#"
[help]
down = []
up = []
"#,
);
let mut app = help_app();
app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();
app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
app.handle_key(key(KeyCode::Char('j')), SHORT_SCREEN)
.unwrap();
assert_eq!(app.help_scroll, 0);
remove(&path);
}
#[test]
fn growing_the_terminal_pulls_a_scrolled_help_window_back_onto_its_content() {
let mut app = help_app();
app.help_scroll = help_max_scroll(&app, SHORT_SCREEN);
assert!(app.help_scroll > 0);
app.handle_key(key(KeyCode::Down), SCREEN).unwrap();
assert_eq!(app.help_scroll, 0, "nothing left to scroll to");
}
#[test]
fn an_unbound_control_chord_does_not_type_into_the_search_query() {
let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
send(&mut app, KeyCode::Char('z'));
app.handle_key(
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
SCREEN,
)
.unwrap();
assert_eq!(app.filter.text_query, "z");
}
#[test]
fn shifted_characters_type_into_the_search_query() {
let mut app = app_with(Matcher::default(), vec![ssh("Zulu", "10.0.0.2")]);
app.handle_key(
KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT),
SCREEN,
)
.unwrap();
assert_eq!(app.mode, AppMode::Search);
assert_eq!(app.filter.text_query, "Z");
}
#[test]
fn rebound_browse_keys_dispatch_and_defaults_stop_working() {
let path = temp_file(
"browse-rebind",
r#"
[browse]
down = ["n"]
up = ["p"]
help = ["f1"]
"#,
);
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
);
app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();
send(&mut app, KeyCode::Char('n'));
assert_eq!(app.selected, 1, "the rebound down key moves the cursor");
send(&mut app, KeyCode::Char('p'));
assert_eq!(app.selected, 0);
send(&mut app, KeyCode::Char('j'));
assert_eq!(app.mode, AppMode::Search);
assert_eq!(app.filter.text_query, "j");
send(&mut app, KeyCode::Esc);
send(&mut app, KeyCode::F(1));
assert_eq!(app.mode, AppMode::Help, "the rebound help key opens help");
remove(&path);
}
#[test]
fn an_unbound_browse_action_does_nothing() {
let path = temp_file(
"unbind-same-host",
r#"
[browse]
same_host = []
"#,
);
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();
send(&mut app, KeyCode::Char('s'));
assert_eq!(app.filter.host_filter, None);
assert_eq!(app.mode, AppMode::Search);
remove(&path);
}
#[test]
fn type_filter_toggle_hides_a_service_type() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), http("web")],
);
assert_eq!(app.visible_groups.len(), 2);
send(&mut app, KeyCode::Char('t'));
assert_eq!(app.mode, AppMode::TypeFilter);
send(&mut app, KeyCode::Char(' '));
assert!(
app.visible_groups
.iter()
.all(|g| g.facts().service_type() == RowServiceType::Invariant("_ssh._tcp"))
);
assert_eq!(app.visible_groups.len(), 1);
}
#[test]
fn tab_keys_cycle_active_view_and_wrap() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
assert_eq!(app.filter.grouping, GroupingMode::LogicalService);
send(&mut app, KeyCode::Tab);
assert_eq!(app.filter.grouping, GroupingMode::Host);
send(&mut app, KeyCode::Tab);
assert_eq!(app.filter.grouping, GroupingMode::ServiceType);
assert_eq!(app.mode, AppMode::Browse);
send(&mut app, KeyCode::BackTab);
send(&mut app, KeyCode::BackTab);
send(&mut app, KeyCode::BackTab);
assert_eq!(app.filter.grouping, GroupingMode::Command);
}
#[test]
fn switching_tabs_resets_selection_and_scroll() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
);
send(&mut app, KeyCode::Down);
app.details_scroll = 3;
assert_eq!(app.selected, 1);
send(&mut app, KeyCode::Tab);
assert_eq!(app.selected, 0, "switching views resets the cursor");
assert_eq!(
app.details_scroll, 0,
"switching views resets detail scroll"
);
}
#[test]
fn invoke_single_matching_action_returns_prepared_execute_command() {
let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")]);
let command = send(&mut app, KeyCode::Enter).expect("execute action returns a command");
assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
}
#[test]
fn invoke_with_multiple_actions_opens_picker_then_runs_selection() {
let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")]);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
assert_eq!(app.action_matches.len(), 2);
let command = send(&mut app, KeyCode::Enter).expect("picked action runs");
assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
}
#[test]
fn invoke_without_a_matching_command_reports_status() {
let mut app = app_with(matcher_from(&[SSH]), vec![http("web")]);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert!(app.status.contains("no configured actions match"));
}
#[test]
fn fork_action_launches_and_returns_to_browse() {
let mut app = app_with(matcher_from(&[PING]), vec![ssh("alpha", "10.0.0.1")]);
assert!(
send(&mut app, KeyCode::Enter).is_none(),
"fork does not exec"
);
assert_eq!(app.mode, AppMode::Browse);
assert!(app.status.contains("launched `ping`"));
}
#[test]
fn a_missing_requirement_reports_failure_without_trying_another_target() {
const NEEDS_ABSENT: &str = r#"
[metadata]
name = "needs-absent"
requirements = ["definitely-absent-xyz"]
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "ssh {hostname}"
mode = "execute"
"#;
let mut app = app_with(
matcher_from(&[NEEDS_ABSENT]),
vec![
service_on("alpha", "_ssh._tcp", "alpha.local", 22),
service_on("beta", "_ssh._tcp", "beta.local", 22),
],
);
app.filter.grouping = GroupingMode::ServiceType;
app.recompute_visible();
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::InstancePicker);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert!(
app.status.contains("definitely-absent-xyz"),
"status should name the missing dependency, was: {}",
app.status
);
}
#[test]
fn instance_picker_disambiguates_then_executes_chosen_address() {
let mut app = app_with(
matcher_from(&[PING_ADDR]),
vec![ssh_multi("alpha", &["10.0.0.1", "10.0.0.2"])],
);
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].instances().len(), 1);
assert_eq!(app.visible_groups[0].instances()[0].addresses.len(), 2);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::InstancePicker);
send(&mut app, KeyCode::Down);
let command = send(&mut app, KeyCode::Enter).expect("instance chosen");
assert_eq!(command.argv, vec!["echo", "10.0.0.2"]);
}
fn service_on(name: &str, service_type: &str, host: &str, port: u16) -> Entry {
let mut record = Entry::new(name, service_type, "local");
record.hostname = Some(host.to_string());
record.addresses = vec!["10.0.0.1".parse().unwrap()];
record.port = Some(port);
record
}
fn tab_count(app: &App, mode: GroupingMode) -> usize {
let index = GroupingMode::TABS
.iter()
.position(|tab| *tab == mode)
.expect("a tab for the mode");
app.tab_counts[index]
}
#[test]
fn tab_counts_follow_their_exact_definitions() {
let app = app_with(
matcher_from(&[SSH, PING]),
vec![
service_on("shell", "_ssh._tcp", "nas.local", 22),
service_on("site", "_http._tcp", "nas.local", 80),
service_on("shell", "_ssh._tcp", "pi.local", 22),
Entry::new("ghost", "_ipp._tcp", "local"),
],
);
assert_eq!(tab_count(&app, GroupingMode::LogicalService), 4);
assert_eq!(tab_count(&app, GroupingMode::Host), 3);
assert_eq!(tab_count(&app, GroupingMode::ServiceType), 3);
assert_eq!(tab_count(&app, GroupingMode::Command), 2);
}
#[test]
fn every_tab_count_matches_the_rows_that_tab_lists() {
let mut app = app_with(
matcher_from(&[SSH]),
vec![
service_on("shell", "_ssh._tcp", "nas.local", 22),
service_on("site", "_http._tcp", "nas.local", 80),
service_on("shell", "_ssh._tcp", "pi.local", 22),
Entry::new("ghost", "_ipp._tcp", "local"),
],
);
for mode in GroupingMode::TABS {
app.filter.grouping = mode;
app.recompute_visible();
let rows = if mode == GroupingMode::Command {
app.command_groups.len()
} else {
app.visible_groups.len()
};
assert_eq!(tab_count(&app, mode), rows, "{mode:?} count vs its rows");
}
}
#[test]
fn a_host_row_offers_its_services_without_borrowing_one_childs_metadata() {
let mut app = app_with(
matcher_from(&[SSH]),
vec![
service_on("shell", "_ssh._tcp", "nas.local", 22),
service_on("site", "_http._tcp", "nas.local", 80),
],
);
app.filter.grouping = GroupingMode::Host;
app.recompute_visible();
assert_eq!(app.visible_groups.len(), 1);
let host = &app.visible_groups[0];
assert_eq!(host.label(), "nas.local");
assert_eq!(host.facts().host(), RowHost::Resolved("nas.local"));
assert_eq!(host.facts().service_type(), RowServiceType::Varies);
assert_eq!(host.logical_service_count(), 2);
let command = send(&mut app, KeyCode::Enter).expect("the ssh child runs");
assert_eq!(command.argv, vec!["ssh", "nas.local"]);
}
#[test]
fn a_service_type_row_targets_the_concrete_child_the_user_picks() {
let mut alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
alpha.addresses = vec!["10.0.0.1".parse().unwrap()];
let mut beta = service_on("beta", "_ssh._tcp", "beta.local", 2222);
beta.addresses = vec!["10.0.0.2".parse().unwrap()];
let mut app = app_with(matcher_from(&[SSH]), vec![alpha, beta]);
app.filter.grouping = GroupingMode::ServiceType;
app.recompute_visible();
assert_eq!(app.visible_groups.len(), 1);
let by_type = &app.visible_groups[0];
assert_eq!(by_type.label(), "_ssh._tcp");
assert_eq!(by_type.facts().host(), RowHost::Varies);
assert_eq!(by_type.resolved_host_count(), 2);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::InstancePicker);
send(&mut app, KeyCode::Down);
let command = send(&mut app, KeyCode::Enter).expect("the chosen child runs");
assert_eq!(command.argv, vec!["ssh", "beta.local"]);
}
#[test]
fn a_command_row_runs_against_a_concrete_service() {
let mut app = app_with(
matcher_from(&[SSH]),
vec![
service_on("alpha", "_ssh._tcp", "alpha.local", 22),
service_on("beta", "_ssh._tcp", "beta.local", 22),
],
);
app.filter.grouping = GroupingMode::Command;
app.recompute_visible();
assert_eq!(app.command_groups[0].services.len(), 2);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ServicePicker);
let command = send(&mut app, KeyCode::Enter).expect("the picked service runs");
assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
}
#[test]
fn same_host_filter_is_offered_only_by_invariant_host_projections() {
let mut app = app_with(
matcher_from(&[SSH]),
vec![
service_on("shell", "_ssh._tcp", "nas.local", 22),
service_on("site", "_http._tcp", "pi.local", 80),
],
);
app.filter.grouping = GroupingMode::Host;
app.recompute_visible();
send(&mut app, KeyCode::Char('s'));
assert_eq!(app.filter.host_filter.as_deref(), Some("nas.local"));
send(&mut app, KeyCode::Char('s'));
assert!(app.filter.host_filter.is_none());
app.filter.grouping = GroupingMode::ServiceType;
app.recompute_visible();
send(&mut app, KeyCode::Char('s'));
assert!(app.filter.host_filter.is_none(), "no host was filtered by");
assert!(app.status.contains("unavailable"), "status: {}", app.status);
assert!(app.status.contains("span several hosts"));
app.filter.grouping = GroupingMode::Command;
app.recompute_visible();
send(&mut app, KeyCode::Char('s'));
assert!(app.filter.host_filter.is_none());
assert!(app.status.contains("unavailable"));
}
#[test]
fn an_active_host_filter_can_be_cleared_from_any_view() {
let mut app = app_with(
matcher_from(&[SSH]),
vec![service_on("shell", "_ssh._tcp", "nas.local", 22)],
);
send(&mut app, KeyCode::Char('s'));
assert_eq!(app.filter.host_filter.as_deref(), Some("nas.local"));
app.filter.grouping = GroupingMode::Command;
app.recompute_visible();
send(&mut app, KeyCode::Char('s'));
assert!(app.filter.host_filter.is_none());
assert!(app.status.contains("host filter cleared"));
}
#[test]
fn an_unresolved_host_row_never_collides_with_the_sentinel_hostname() {
let mut impostor = Entry::new("impostor", "_ssh._tcp", "local");
impostor.hostname = Some(UNRESOLVED_HOST_LABEL.to_string());
impostor.port = Some(22);
let mut app = app_with(
matcher_from(&[SSH]),
vec![impostor, Entry::new("ghost", "_ipp._tcp", "local")],
);
app.filter.grouping = GroupingMode::Host;
app.recompute_visible();
assert_eq!(app.visible_groups.len(), 2);
assert!(
app.visible_groups
.iter()
.all(|group| group.label() == UNRESOLVED_HOST_LABEL)
);
send(&mut app, KeyCode::Char('s'));
assert_eq!(
app.filter.host_filter.as_deref(),
Some(UNRESOLVED_HOST_LABEL)
);
assert_eq!(app.visible_groups.len(), 1);
send(&mut app, KeyCode::Char('s'));
app.selected = 1;
send(&mut app, KeyCode::Char('s'));
assert!(app.filter.host_filter.is_none());
assert!(
app.status.contains("no resolved host"),
"status: {}",
app.status
);
}
#[test]
fn selection_survives_recomputation_by_structured_row_identity() {
let mut app = app_with(
matcher_from(&[SSH]),
vec![
service_on("shell", "_ssh._tcp", "nas.local", 22),
service_on("shell", "_ssh._tcp", "pi.local", 22),
],
);
send(&mut app, KeyCode::Down);
let chosen = app.visible_groups[app.selected].id().clone();
let earlier = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
app.records.insert(earlier.id(), earlier);
app.recompute_visible();
assert_eq!(app.selected, 2, "the cursor followed its row");
assert_eq!(*app.visible_groups[app.selected].id(), chosen);
}
#[test]
fn same_host_filter_toggles_on_and_off() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
);
send(&mut app, KeyCode::Char('s'));
assert_eq!(app.filter.host_filter.as_deref(), Some("alpha.local"));
assert_eq!(app.visible_groups.len(), 1);
send(&mut app, KeyCode::Char('s'));
assert!(app.filter.host_filter.is_none());
assert_eq!(app.visible_groups.len(), 2);
}
fn on_interface(name: &str, addr: &str, index: u32) -> Entry {
let mut record = ssh(name, addr);
record.addresses = vec![addr.parse().unwrap()];
record.with_occurrence(Some(OccurrenceId(NonZeroU32::new(index).unwrap())))
}
#[test]
fn occurrences_of_one_registration_coexist_with_their_own_addresses() {
let mut app = app_with(
Matcher::default(),
vec![
on_interface("alpha", "10.0.0.1", 1),
on_interface("alpha", "10.0.0.2", 2),
],
);
app.recompute_visible();
assert_eq!(app.records.len(), 2);
let addresses: Vec<_> = app
.records
.values()
.flat_map(|record| record.addresses.clone())
.collect();
assert!(addresses.contains(&"10.0.0.1".parse().unwrap()));
assert!(addresses.contains(&"10.0.0.2".parse().unwrap()));
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].instances().len(), 2);
}
#[test]
fn removing_one_occurrence_preserves_its_sibling() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
);
tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
.unwrap();
tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.2", 2)))
.unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 2);
tx.send(DiscoveryEvent::Remove(
on_interface("alpha", "10.0.0.1", 1).id(),
))
.unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
assert_eq!(app.visible_groups.len(), 1);
let survivor = app.records.values().next().expect("surviving occurrence");
assert_eq!(
survivor.addresses,
vec!["10.0.0.2".parse::<IpAddr>().unwrap()]
);
}
#[test]
fn registration_removal_clears_every_occurrence() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
);
tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
.unwrap();
tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.2", 2)))
.unwrap();
tx.send(DiscoveryEvent::Upsert(ssh("alpha", "10.0.0.3")))
.unwrap();
tx.send(DiscoveryEvent::Upsert(ssh("beta", "10.0.0.4")))
.unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 4);
tx.send(DiscoveryEvent::RemoveRegistration(Registration::new(
"alpha",
"_ssh._tcp",
"local",
)))
.unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].label(), "beta");
}
#[test]
fn removing_an_unknown_occurrence_leaves_the_registration_alone() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
);
tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
.unwrap();
app.drain_discovery();
tx.send(DiscoveryEvent::Remove(
on_interface("alpha", "10.0.0.9", 7).id(),
))
.unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
}
#[test]
fn upserting_an_occurrence_replaces_it_across_endpoint_and_txt_changes() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
);
tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
.unwrap();
let mut moved = on_interface("alpha", "10.0.0.9", 1);
moved.port = Some(2222);
moved.txt.insert("path".to_string(), "/admin".to_string());
tx.send(DiscoveryEvent::Upsert(moved)).unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
let record = app.records.values().next().expect("record");
assert_eq!(
record.addresses,
vec!["10.0.0.9".parse::<IpAddr>().unwrap()]
);
assert_eq!(record.port, Some(2222));
assert_eq!(record.txt.get("path").map(String::as_str), Some("/admin"));
}
#[test]
fn command_view_runs_single_service_and_picks_among_many() {
let mut single = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")]);
single.filter.grouping = GroupingMode::Command;
single.recompute_visible();
assert_eq!(single.command_groups.len(), 1);
assert_eq!(single.command_groups[0].services.len(), 1);
let command = send(&mut single, KeyCode::Enter).expect("single service runs");
assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
let mut many = app_with(
matcher_from(&[SSH]),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
);
many.filter.grouping = GroupingMode::Command;
many.recompute_visible();
assert_eq!(many.command_groups[0].services.len(), 2);
assert!(send(&mut many, KeyCode::Enter).is_none());
assert_eq!(many.mode, AppMode::ServicePicker);
send(&mut many, KeyCode::Down);
let command = send(&mut many, KeyCode::Enter).expect("picked service runs");
assert_eq!(command.argv, vec!["ssh", "beta.local"]);
}
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
fn channel_factory() -> (
DiscoveryFactory,
Arc<Mutex<Vec<mpsc::Sender<DiscoveryEvent>>>>,
) {
let spawned: Arc<Mutex<Vec<mpsc::Sender<DiscoveryEvent>>>> =
Arc::new(Mutex::new(Vec::new()));
let factory = {
let spawned = spawned.clone();
Box::new(move || {
let (tx, rx) = mpsc::channel();
spawned.lock().unwrap().push(tx);
DiscoverySession::detached(rx)
})
};
(factory, spawned)
}
#[test]
fn refresh_restarts_discovery_and_repopulates_like_startup() {
let (factory, spawned) = channel_factory();
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
)
.with_discovery_factory(factory);
assert_eq!(app.visible_groups.len(), 2);
send(&mut app, KeyCode::Char('r'));
assert!(app.records.is_empty());
assert!(app.visible_groups.is_empty());
assert!(app.status.contains("refresh"));
assert_eq!(spawned.lock().unwrap().len(), 1);
spawned.lock().unwrap()[0]
.send(DiscoveryEvent::Upsert(ssh("gamma", "10.0.0.3")))
.unwrap();
app.drain_discovery();
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].label(), "gamma");
}
#[test]
fn refresh_resets_cursor_and_scroll_but_keeps_filters() {
let (factory, _spawned) = channel_factory();
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
)
.with_discovery_factory(factory);
send(&mut app, KeyCode::Down);
app.details_scroll = 3;
app.filter.text_query = "bet".to_string();
app.refresh_services();
assert_eq!(app.selected, 0);
assert_eq!(app.details_scroll, 0);
assert_eq!(
app.filter.text_query, "bet",
"refresh restarts discovery; it does not discard what the user asked to see"
);
}
#[test]
fn refresh_without_discovery_control_reports_status_and_keeps_records() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
send(&mut app, KeyCode::Char('r'));
assert!(app.status.contains("refresh is not available"));
assert_eq!(app.records.len(), 1, "records must not be lost");
}
fn app_with_session(
matcher: Matcher,
records: Vec<Entry>,
) -> (App, mpsc::Sender<DiscoveryEvent>) {
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
matcher,
KeyBindings::default(),
DiscoverySession::detached(rx),
);
for record in records {
app.records.insert(record.id(), record);
}
app.recompute_visible();
(app, tx)
}
#[test]
fn a_real_disconnect_clears_records_and_reports_a_persistent_failure() {
let (mut app, tx) = app_with_session(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
assert_eq!(app.visible_groups.len(), 1);
drop(tx);
app.drain_discovery();
assert!(app.records.is_empty(), "unverifiable records must not stay");
assert!(app.visible_groups.is_empty());
assert!(matches!(app.session.state(), SessionState::Failed(_)));
assert!(
!app.session.state().is_listening(),
"the app must stop implying it is listening"
);
assert!(app.status.contains("discovery stopped"));
}
#[test]
fn a_real_disconnect_with_no_records_still_reports_a_failure() {
let (mut app, tx) = app_with_session(Matcher::default(), Vec::new());
drop(tx);
app.drain_discovery();
assert!(matches!(app.session.state(), SessionState::Failed(_)));
assert!(app.status.contains("discovery stopped"));
}
#[test]
fn a_startup_failure_keeps_its_cause_text_across_later_drains() {
let (mut app, tx) = app_with_session(Matcher::default(), Vec::new());
tx.send(DiscoveryEvent::Status(
"mDNS discovery unavailable (no such device); try --backend fake in a build with the fake feature for sample records, or refresh to retry"
.to_string(),
))
.unwrap();
drop(tx);
app.drain_discovery();
let reported = app.status.clone();
for _ in 0..5 {
app.drain_discovery();
}
assert_eq!(app.status, reported, "the failure must be persistent");
assert!(app.status.contains("discovery stopped"));
assert!(matches!(app.session.state(), SessionState::Failed(_)));
}
#[test]
fn a_real_disconnect_closes_a_derived_picker() {
let (mut app, tx) =
app_with_session(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")]);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
drop(tx);
app.drain_discovery();
assert_eq!(app.mode, AppMode::Browse);
assert!(app.records.is_empty());
}
#[test]
fn removing_the_selected_service_closes_an_open_action_picker() {
let alpha = ssh("alpha", "10.0.0.1");
let (mut app, tx) = app_with_session(matcher_from(&[SSH, PING]), vec![alpha.clone()]);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
tx.send(DiscoveryEvent::Remove(alpha.id())).unwrap();
app.drain_discovery();
assert_eq!(app.mode, AppMode::Browse, "the picker must not survive");
assert!(
send(&mut app, KeyCode::Enter).is_none(),
"confirming must not run the retracted service"
);
}
#[test]
fn removing_the_selected_target_closes_an_open_instance_picker() {
let alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
let beta = service_on("beta", "_ssh._tcp", "beta.local", 22);
let (mut app, tx) =
app_with_session(matcher_from(&[SSH]), vec![alpha.clone(), beta.clone()]);
app.filter.grouping = GroupingMode::ServiceType;
app.recompute_visible();
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::InstancePicker);
assert_eq!(app.pending_action.as_ref().unwrap().targets.len(), 2);
send(&mut app, KeyCode::Down);
tx.send(DiscoveryEvent::Remove(beta.id())).unwrap();
app.drain_discovery();
assert_eq!(app.mode, AppMode::Browse);
assert!(app.status.contains("gone"), "status was: {}", app.status);
}
#[test]
fn updating_the_selected_address_cannot_execute_the_stale_argv() {
let original = ssh_multi("alpha", &["10.0.0.1", "10.0.0.2"]);
let (mut app, tx) = app_with_session(matcher_from(&[PING_ADDR]), vec![original.clone()]);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::InstancePicker);
send(&mut app, KeyCode::Down);
let mut renumbered = original.clone();
renumbered.addresses = ["10.0.0.1", "10.0.0.9"]
.iter()
.map(|a| a.parse().unwrap())
.collect();
assert_eq!(
renumbered.id(),
original.id(),
"the occurrence must keep its identity"
);
tx.send(DiscoveryEvent::Upsert(renumbered)).unwrap();
app.drain_discovery();
assert_eq!(
app.mode,
AppMode::Browse,
"the chosen address is gone, so the picker must not stand"
);
assert!(
send(&mut app, KeyCode::Enter).is_none(),
"`echo 10.0.0.2` must be unrunnable once .2 is retracted"
);
}
#[test]
fn updating_an_unselected_address_keeps_the_instance_picker() {
let original = ssh_multi("alpha", &["10.0.0.1", "10.0.0.2"]);
let (mut app, tx) = app_with_session(matcher_from(&[PING_ADDR]), vec![original.clone()]);
assert!(send(&mut app, KeyCode::Enter).is_none());
send(&mut app, KeyCode::Down);
let mut renumbered = original.clone();
renumbered.addresses = ["10.0.0.7", "10.0.0.2"]
.iter()
.map(|a| a.parse().unwrap())
.collect();
tx.send(DiscoveryEvent::Upsert(renumbered)).unwrap();
app.drain_discovery();
assert_eq!(app.mode, AppMode::InstancePicker, "the picker must survive");
let command = send(&mut app, KeyCode::Enter).expect("the chosen address runs");
assert_eq!(
command.argv,
vec!["echo", "10.0.0.2"],
"the cursor must still be on the address the user chose"
);
}
#[test]
fn an_unrelated_removal_keeps_the_picker_and_its_selection() {
let alpha = ssh("alpha", "10.0.0.1");
let unrelated = http("web");
let (mut app, tx) = app_with_session(
matcher_from(&[SSH, PING]),
vec![alpha.clone(), unrelated.clone()],
);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
send(&mut app, KeyCode::Down);
let chosen = app.action_matches[app.action_index].command.name.clone();
tx.send(DiscoveryEvent::Remove(unrelated.id())).unwrap();
app.drain_discovery();
assert_eq!(app.mode, AppMode::ActionPicker, "the picker must survive");
assert_eq!(
app.action_matches[app.action_index].command.name, chosen,
"the cursor must stay on the action the user chose"
);
}
#[test]
fn removing_a_service_above_the_cursor_does_not_retarget_the_service_picker() {
let alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
let (mut app, tx) = app_with_session(
matcher_from(&[SSH]),
vec![
alpha.clone(),
service_on("beta", "_ssh._tcp", "beta.local", 22),
service_on("gamma", "_ssh._tcp", "gamma.local", 22),
],
);
app.filter.grouping = GroupingMode::Command;
app.recompute_visible();
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ServicePicker);
assert_eq!(app.command_groups[0].services.len(), 3);
send(&mut app, KeyCode::Down);
assert_eq!(app.service_picker_index, 1);
tx.send(DiscoveryEvent::Remove(alpha.id())).unwrap();
app.drain_discovery();
assert_eq!(app.mode, AppMode::ServicePicker, "the picker must survive");
assert_eq!(
app.service_picker_index, 0,
"the cursor must follow beta, not stay on the index gamma now holds"
);
let command = send(&mut app, KeyCode::Enter).expect("the chosen service runs");
assert_eq!(
command.argv,
vec!["ssh", "beta.local"],
"the service the user chose must run, never the one that inherited its index"
);
}
#[test]
fn a_txt_update_that_unmatches_the_rule_closes_the_action_picker() {
const TXT_RULE: &str = r#"
[metadata]
name = "open-admin"
[match.txt.role]
equals = "admin"
[action]
command = "open {hostname}"
mode = "execute"
"#;
const ANY_HTTP: &str = r#"
[metadata]
name = "curl"
[match.service_type]
equals = "_http._tcp"
[action]
command = "curl {hostname}"
mode = "execute"
"#;
let mut record = Entry::new("nas", "_http._tcp", "local");
record.hostname = Some("nas.local".to_string());
record.txt.insert("role".to_string(), "admin".to_string());
let (mut app, tx) =
app_with_session(matcher_from(&[TXT_RULE, ANY_HTTP]), vec![record.clone()]);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
assert_eq!(
app.action_matches[app.action_index].command.name,
"open-admin"
);
let mut demoted = record.clone();
demoted.txt.insert("role".to_string(), "guest".to_string());
assert_eq!(demoted.id(), record.id(), "the service itself is unchanged");
tx.send(DiscoveryEvent::Upsert(demoted)).unwrap();
app.drain_discovery();
assert_eq!(
app.mode,
AppMode::Browse,
"the action under the cursor no longer matches, so the picker must go"
);
assert!(
app.status.contains("no longer matches"),
"status was: {}",
app.status
);
assert!(app.action_matches.is_empty());
}
#[cfg(feature = "fake")]
#[test]
fn finite_fake_completion_keeps_its_samples_and_reports_completion() {
let mut cli = test_cli();
cli.backend = crate::discovery::DiscoveryBackend::Fake;
cli.service_type = Some("_ssh._tcp".to_string());
let session = crate::discovery::start(
&cli.discovery_options()
.expect("valid test discovery options"),
);
let mut app = App::new(cli, Matcher::default(), KeyBindings::default(), session);
while app.session.state().is_listening() {
app.drain_discovery();
std::thread::yield_now();
}
assert_eq!(*app.session.state(), SessionState::Complete);
assert_eq!(
app.records.len(),
2,
"a finished sample stream keeps its records"
);
assert!(app.status.contains("complete"));
assert!(
!app.status.contains("failed") && !app.status.contains("stopped"),
"finishing a finite stream is not a failure: {}",
app.status
);
}
#[cfg(feature = "fake")]
#[test]
fn fake_samples_offer_host_selection_on_the_service_type_row() {
let mut cli = test_cli();
cli.backend = crate::discovery::DiscoveryBackend::Fake;
cli.service_type = Some("_ssh._tcp".to_string());
let session = crate::discovery::start(
&cli.discovery_options()
.expect("valid test discovery options"),
);
let mut app = App::new(cli, matcher_from(&[SSH]), KeyBindings::default(), session);
while app.session.state().is_listening() {
app.drain_discovery();
std::thread::yield_now();
}
app.filter.grouping = GroupingMode::ServiceType;
app.recompute_visible();
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].label(), "_ssh._tcp");
assert_eq!(
app.visible_groups[0].resolved_host_count(),
2,
"the sample set must put SSH on two hosts"
);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(
app.mode,
AppMode::InstancePicker,
"the row must ask which host rather than run one"
);
send(&mut app, KeyCode::Down);
let command = send(&mut app, KeyCode::Enter).expect("the chosen host runs");
assert_eq!(command.argv, vec!["ssh", "workstation.local"]);
}
#[test]
fn refresh_recovers_from_a_failed_session() {
let (factory, spawned) = channel_factory();
let (tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
)
.with_discovery_factory(factory);
drop(tx);
app.drain_discovery();
assert!(matches!(app.session.state(), SessionState::Failed(_)));
send(&mut app, KeyCode::Char('r'));
assert!(
app.session.state().is_listening(),
"refresh must recover a failed session"
);
spawned.lock().unwrap()[0]
.send(DiscoveryEvent::Upsert(ssh("gamma", "10.0.0.3")))
.unwrap();
app.drain_discovery();
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[0].label(), "gamma");
}
#[test]
fn refresh_restarts_a_completed_session() {
let (factory, spawned) = channel_factory();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::ended(SessionState::Complete),
)
.with_discovery_factory(factory);
assert!(!app.session.state().is_listening());
send(&mut app, KeyCode::Char('r'));
assert!(
app.session.state().is_listening(),
"refresh must restart a completed session"
);
spawned.lock().unwrap()[0]
.send(DiscoveryEvent::Upsert(ssh("gamma", "10.0.0.3")))
.unwrap();
app.drain_discovery();
assert_eq!(app.visible_groups.len(), 1);
}
#[test]
fn events_from_a_replaced_session_never_reach_the_new_list() {
let (factory, spawned) = channel_factory();
let (old_tx, rx) = mpsc::channel();
let mut app = App::new(
test_cli(),
Matcher::default(),
KeyBindings::default(),
DiscoverySession::detached(rx),
)
.with_discovery_factory(factory);
send(&mut app, KeyCode::Char('r'));
assert!(
old_tx
.send(DiscoveryEvent::Upsert(ssh("stale", "10.0.0.9")))
.is_err(),
"a replaced session's receiver must be gone with it"
);
spawned.lock().unwrap()[0]
.send(DiscoveryEvent::Upsert(ssh("fresh", "10.0.0.1")))
.unwrap();
app.drain_discovery();
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(
app.visible_groups[0].label(),
"fresh",
"only the current session's events may populate the list"
);
}
#[test]
fn a_refresh_that_fails_does_not_retain_the_old_records() {
let (factory, spawned) = channel_factory();
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
.with_discovery_factory(factory);
assert_eq!(app.visible_groups.len(), 1);
send(&mut app, KeyCode::Char('r'));
spawned.lock().unwrap().clear();
app.drain_discovery();
assert!(app.records.is_empty());
assert!(app.visible_groups.is_empty());
assert!(matches!(app.session.state(), SessionState::Failed(_)));
}
fn loads(sources: &'static [&'static str]) -> ConfigLoader {
Box::new(move |_cli| ReloadOutcome::Loaded(Box::new(matcher_from(sources))))
}
fn rejects(diagnostics: &'static [&'static str]) -> ConfigLoader {
Box::new(move |_cli| {
ReloadOutcome::Rejected(diagnostics.iter().map(|d| d.to_string()).collect())
})
}
#[test]
fn requested_reload_swaps_commands_and_recomputes_matches() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(loads(&[SSH]));
assert_eq!(app.matcher.command_count(), 0);
assert_eq!(app.group_matches[0].len(), 0);
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(app.matcher.command_count(), 1);
assert_eq!(
app.group_matches[0].len(),
1,
"matches are recomputed against the reloaded rules"
);
assert!(app.status.contains("reloaded 1 command"));
assert!(
!app.reload_requested.load(Ordering::Relaxed),
"the request is consumed"
);
}
#[test]
fn poll_without_a_reload_request_does_nothing() {
let calls = Arc::new(AtomicUsize::new(0));
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader({
let calls = calls.clone();
Box::new(move |_cli| {
calls.fetch_add(1, Ordering::Relaxed);
ReloadOutcome::Loaded(Box::new(Matcher::default()))
})
});
let status = app.status.clone();
app.poll_reload();
assert_eq!(calls.load(Ordering::Relaxed), 0);
assert_eq!(app.status, status);
}
#[test]
fn rejected_reload_keeps_the_working_rules_runnable() {
let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(rejects(&["/cfg/ssh.toml: unterminated quote"]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(app.matcher.command_count(), 1, "old rules stay in force");
assert_eq!(
app.group_matches[0].len(),
1,
"and still match the discovered services"
);
let command = send(&mut app, KeyCode::Enter).expect("the working action still runs");
assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
}
#[test]
fn a_mixed_valid_and_invalid_overlay_does_not_partially_swap() {
let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(rejects(&["/cfg/ping.toml: unknown placeholder `{bogus}`"]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(
app.matcher.command_count(),
2,
"a rule set is installed whole or not at all"
);
assert_eq!(app.group_matches[0].len(), 2);
assert!(
app.status
.contains("keeping the 2 command(s) already loaded")
);
}
#[test]
fn rejected_reload_retains_full_diagnostics_across_status_updates() {
let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(rejects(&[
"/etc/kinjo/commands/a.toml: unterminated quote",
"/home/u/.config/kinjo/commands/b.toml: unsupported match field `typ`",
]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert!(app.status.contains("2 invalid config file(s)"));
app.fail("something else entirely".to_string()).unwrap();
assert!(!app.status.contains("invalid config file"));
assert_eq!(
app.reload_diagnostics,
[
"/etc/kinjo/commands/a.toml: unterminated quote",
"/home/u/.config/kinjo/commands/b.toml: unsupported match field `typ`",
],
"full source paths and messages survive for the exit report"
);
}
#[test]
fn a_successful_reload_clears_the_previous_failures_diagnostics() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(rejects(&["/cfg/ssh.toml: unterminated quote"]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert!(!app.reload_diagnostics.is_empty());
app.config_loader = Some(loads(&[SSH]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert!(app.reload_diagnostics.is_empty(), "latest reload only");
assert_eq!(app.matcher.command_count(), 1);
assert!(app.status.contains("reloaded 1 command"));
}
#[test]
fn a_second_rejected_reload_replaces_the_earlier_diagnostics() {
let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(rejects(&["/cfg/first.toml: unterminated quote"]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
app.config_loader = Some(rejects(&["/cfg/second.toml: invalid action mode `frok`"]));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(
app.reload_diagnostics,
["/cfg/second.toml: invalid action mode `frok`"]
);
}
#[test]
fn a_valid_reload_swaps_every_rule_together_and_rebuilds_command_groups() {
let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(loads(&[SSH, PING]));
app.filter.grouping = GroupingMode::Command;
app.recompute_visible();
assert_eq!(app.command_groups.len(), 1);
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(
app.command_groups
.iter()
.map(|group| group.command.name.as_str())
.collect::<Vec<_>>(),
["ssh", "ping"],
"both new rules arrive at once, projected against current records"
);
assert!(app.reload_diagnostics.is_empty());
}
#[test]
fn reload_closes_a_picker_built_from_the_old_rules() {
let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(Box::new(|_cli| {
ReloadOutcome::Loaded(Box::new(Matcher::default()))
}));
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(app.mode, AppMode::Browse);
assert!(app.action_matches.is_empty());
}
#[test]
fn a_rejected_reload_leaves_an_open_picker_alone() {
let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(rejects(&["/cfg/ssh.toml: unterminated quote"]));
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(app.mode, AppMode::ActionPicker);
assert_eq!(app.action_matches.len(), 2);
}
#[test]
fn reload_without_a_loader_reports_status() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert!(app.status.contains("config reload is not available"));
}
#[test]
fn quit_keys_request_quit() {
let mut common = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
assert!(
common
.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
SCREEN
)
.unwrap()
.is_none()
);
assert!(common.should_quit);
let mut browse = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
send(&mut browse, KeyCode::Char('q'));
assert!(browse.should_quit);
}
#[test]
fn ctrl_c_quits_immediately_from_a_modal() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
send(&mut app, KeyCode::Char('?'));
assert_eq!(app.mode, AppMode::Help);
app.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
SCREEN,
)
.unwrap();
assert!(app.should_quit, "ctrl-c must quit while a modal is open");
}
#[test]
fn help_modal_opens_and_closes() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
send(&mut app, KeyCode::Char('?'));
assert_eq!(app.mode, AppMode::Help);
send(&mut app, KeyCode::Esc);
assert_eq!(app.mode, AppMode::Browse);
}
#[test]
fn scroll_details_steps_by_half_viewport_and_clamps() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
app.update_layout(MOUSE_SCREEN);
let max = app.layout.details_max_scroll();
let half = app.layout.details_viewport() / 2;
assert!(half > 0 && max > half, "max={max} half={half}");
send(&mut app, KeyCode::Char('d'));
assert_eq!(app.details_scroll, half);
for _ in 0..max {
send(&mut app, KeyCode::Char('d'));
}
assert_eq!(app.details_scroll, max, "cannot scroll past the maximum");
for _ in 0..max {
send(&mut app, KeyCode::Char('u'));
}
assert_eq!(app.details_scroll, 0, "scrolling up returns to the top");
}
#[test]
fn details_scroll_is_bounded_before_anything_has_been_drawn() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
send(&mut app, KeyCode::Char('d'));
assert_eq!(app.details_scroll, 0);
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
assert_eq!(app.details_scroll, 0);
app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 3));
assert_eq!(app.selected, 0);
}
#[test]
fn a_resize_reclamps_the_details_scroll_before_the_next_input() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
app.update_layout(MOUSE_SCREEN);
let short_max = app.layout.details_max_scroll();
app.details_scroll = short_max;
assert!(short_max > 0);
app.update_layout(SCREEN);
assert_eq!(app.layout.details_max_scroll(), 0);
assert_eq!(app.details_scroll, 0, "the resize pulled the scroll back");
send(&mut app, KeyCode::Char('d'));
assert_eq!(app.details_scroll, 0, "there is nothing below to scroll to");
}
#[test]
fn removing_the_selected_row_focuses_a_replacement_at_the_top_of_its_details() {
let mut app = app_with(
Matcher::default(),
vec![
ssh("alpha", "10.0.0.1"),
ssh("beta", "10.0.0.2"),
ssh("gamma", "10.0.0.3"),
],
);
app.update_layout(MOUSE_SCREEN);
app.selected = 1;
app.details_scroll = 2;
assert_eq!(app.visible_groups[app.selected].label(), "beta");
app.records.remove(&ssh("beta", "10.0.0.2").id());
app.recompute_visible();
assert_eq!(app.visible_groups[app.selected].label(), "gamma");
assert_eq!(app.details_scroll, 0);
}
#[test]
fn filtering_out_the_selected_row_resets_the_details_scroll() {
let mut app = app_with(
Matcher::default(),
vec![ssh("alpha", "10.0.0.1"), http("beta")],
);
app.update_layout(MOUSE_SCREEN);
app.selected = app
.visible_groups
.iter()
.position(|group| group.label() == "beta")
.expect("beta is listed");
app.details_scroll = 2;
app.filter.toggle_service_type("_http._tcp");
app.recompute_visible();
assert_eq!(app.visible_groups.len(), 1);
assert_eq!(app.visible_groups[app.selected].label(), "alpha");
assert_eq!(app.details_scroll, 0);
}
#[test]
fn updating_the_selected_row_keeps_its_place_in_the_details() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
app.update_layout(MOUSE_SCREEN);
app.details_scroll = 2;
let mut updated = ssh("alpha", "10.0.0.1");
updated.port = Some(2222);
app.records.insert(updated.id(), updated);
app.recompute_visible();
assert_eq!(app.visible_groups[app.selected].label(), "alpha");
assert_eq!(app.details_scroll, 2, "the reader stayed where they were");
}
#[test]
fn a_kept_scroll_is_clamped_to_the_updated_content() {
let mut verbose = ssh("alpha", "10.0.0.1");
for i in 0..12 {
verbose.txt.insert(format!("key{i:02}"), i.to_string());
}
let mut app = app_with(Matcher::default(), vec![verbose.clone()]);
app.update_layout(MOUSE_SCREEN);
let tall = app.layout.details_max_scroll();
app.details_scroll = tall;
let terse = ssh("alpha", "10.0.0.1");
assert_eq!(terse.id(), verbose.id(), "the row's identity is unchanged");
app.records.insert(terse.id(), terse);
app.recompute_visible();
app.update_layout(MOUSE_SCREEN);
let short = app.layout.details_max_scroll();
assert!(
short < tall,
"the details must have got shorter: {tall} → {short}"
);
assert_eq!(
app.details_scroll, short,
"the reader was pulled back to the new end, not stranded past it"
);
}
#[test]
fn losing_every_row_leaves_the_details_at_the_top() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
app.update_layout(MOUSE_SCREEN);
app.details_scroll = 2;
app.records.clear();
app.recompute_visible();
assert!(app.visible_groups.is_empty());
assert_eq!(app.selected, 0);
assert_eq!(app.details_scroll, 0);
}
#[test]
fn move_index_clamps_and_handles_empty_lists() {
assert_eq!(move_index(0, 0, 1), 0);
assert_eq!(move_index(0, 3, -1), 0);
assert_eq!(move_index(2, 3, 1), 2);
assert_eq!(move_index(1, 3, 1), 2);
}
const NEEDS_TOOL: &str = r#"
[metadata]
name = "needs-tool"
requirements = ["kinjo-absent-tool-xyz"]
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "echo hi"
mode = "execute"
"#;
const FORK_MISSING_BINARY: &str = r#"
[metadata]
name = "ghost"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "kinjo-absent-binary-xyz --flag"
mode = "fork"
"#;
const OPTIONAL_REQ: &str = r#"
[metadata]
name = "with-optional"
requirements = ["kinjo-absent-tool-xyz, optional"]
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "echo hi"
mode = "execute"
"#;
#[test]
fn unsatisfied_requirement_reports_status_without_executing() {
let mut app = app_with(matcher_from(&[NEEDS_TOOL]), vec![ssh("alpha", "10.0.0.1")]);
assert!(
send(&mut app, KeyCode::Enter).is_none(),
"a missing requirement must not execute"
);
assert!(app.status.contains("kinjo-absent-tool-xyz"));
assert_eq!(app.mode, AppMode::Browse);
}
#[test]
fn optional_requirement_does_not_block_execution() {
let mut app = app_with(
matcher_from(&[OPTIONAL_REQ]),
vec![ssh("alpha", "10.0.0.1")],
);
let command = send(&mut app, KeyCode::Enter).expect("optional requirement is skipped");
assert_eq!(command.argv, vec!["echo", "hi"]);
}
#[test]
fn fork_failure_reports_status_and_stays_in_browse() {
let mut app = app_with(
matcher_from(&[FORK_MISSING_BINARY]),
vec![ssh("alpha", "10.0.0.1")],
);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert!(app.status.contains("cannot run `ghost`"));
assert!(
app.status
.contains("command `kinjo-absent-binary-xyz` not found")
);
assert_eq!(app.mode, AppMode::Browse);
}
#[test]
fn failed_action_closes_an_open_picker() {
let mut app = app_with(
matcher_from(&[SSH, FORK_MISSING_BINARY]),
vec![ssh("alpha", "10.0.0.1")],
);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::ActionPicker);
assert_eq!(app.action_matches.len(), 2);
send(&mut app, KeyCode::Down);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert_eq!(app.mode, AppMode::Browse);
assert!(app.action_matches.is_empty());
assert!(app.status.contains("cannot run `ghost`"));
}
}