use std::{
cell::Cell,
collections::{BTreeMap, HashMap},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::Duration,
};
use color_eyre::eyre::Result;
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{
DefaultTerminal,
layout::{Position, Rect},
};
use crate::{
discovery::{
Discovery, DiscoveryConfig, DiscoveryEvent, Entry, EntryGroup, EntryId, GroupingMode,
group_entries,
},
plumber::{self, ActionMode, CommandConfig, MatchResult, PreparedCommand, RuleEngine},
};
use super::{cli::Cli, filter::FilterState, keymap::KeyBindings, render};
pub type ConfigLoader = Box<dyn Fn(&Cli) -> Result<(Box<dyn RuleEngine>, Vec<String>)>>;
pub type DiscoveryFactory = Box<dyn Fn(&DiscoveryConfig) -> Box<dyn Discovery>>;
#[derive(Debug, Clone)]
pub struct CommandGroup {
pub command: CommandConfig,
pub services: Vec<EntryGroup>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Browse,
Search,
TypeFilter,
ActionPicker,
InstancePicker,
ServicePicker,
Help,
}
pub struct App {
pub cli: Cli,
pub matcher: Box<dyn RuleEngine>,
pub keybindings: KeyBindings,
pub discovery_rx: mpsc::Receiver<DiscoveryEvent>,
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 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 discovery: Option<Box<dyn Discovery>>,
pub discovery_factory: Option<DiscoveryFactory>,
pub group_matches: Vec<Vec<MatchResult>>,
pub service_types: Vec<String>,
pub ticks: u64,
pub command_groups: Vec<CommandGroup>,
pub service_picker_index: usize,
pub details_scroll: usize,
pub details_max_scroll: Cell<usize>,
pub details_viewport: Cell<usize>,
pub list_area: Cell<Rect>,
pub details_area: Cell<Rect>,
}
impl App {
pub fn new(
cli: Cli,
matcher: impl RuleEngine + 'static,
keybindings: KeyBindings,
discovery_rx: mpsc::Receiver<DiscoveryEvent>,
) -> Self {
let status = format!(
"domain: {} | commands: {} | waiting for services",
cli.domain,
matcher.command_count()
);
Self {
cli,
matcher: Box::new(matcher),
keybindings,
discovery_rx,
records: BTreeMap::new(),
filter: FilterState::default(),
visible_groups: Vec::new(),
selected: 0,
mode: AppMode::Browse,
type_filter_index: 0,
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,
discovery: None,
discovery_factory: None,
group_matches: Vec::new(),
service_types: Vec::new(),
ticks: 0,
command_groups: Vec::new(),
service_picker_index: 0,
details_scroll: 0,
details_max_scroll: Cell::new(0),
details_viewport: Cell::new(0),
list_area: Cell::new(Rect::default()),
details_area: Cell::new(Rect::default()),
}
}
pub fn with_discovery(
mut self,
backend: Box<dyn Discovery>,
factory: DiscoveryFactory,
) -> Self {
self.discovery = Some(backend);
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.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)? {
return Ok(Some(command));
}
if self.should_quit {
return Ok(None);
}
}
Event::Mouse(mouse) => self.handle_mouse(mouse),
_ => {}
}
}
}
}
fn drain_discovery(&mut self) {
let mut changed = false;
while let Ok(event) = self.discovery_rx.try_recv() {
match event {
DiscoveryEvent::Upsert(record) => {
if record.has_instance_data() {
self.records.remove(&record.pending_id());
}
self.records.insert(record.id.clone(), record);
changed = true;
}
DiscoveryEvent::Remove(id) => {
let registration_key = id.registration_key();
let before = self.records.len();
self.records
.retain(|record_id, _| record_id.registration_key() != registration_key);
changed |= self.records.len() != before;
}
DiscoveryEvent::Status(status) => {
self.status = status;
}
}
}
if changed {
self.recompute_visible();
}
}
fn recompute_visible(&mut self) {
let records = self.records.values().cloned().collect::<Vec<_>>();
self.service_types = FilterState::discovered_types(&records);
self.filter.sync_service_types(&records);
let filtered = self.filter.apply(&records);
if self.filter.grouping == GroupingMode::Command {
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 = group_entries(&filtered, self.filter.grouping);
self.group_matches = self
.visible_groups
.iter()
.map(|group| self.matcher.matches_group(group))
.collect();
match find_selection(&self.visible_groups, previous, |group| group.id.clone()) {
Some(index) => self.selected = index,
None => self.clamp_selection(),
}
}
fn recompute_command_groups(&mut self, filtered: &[Entry]) {
let previous = self
.command_groups
.get(self.selected)
.map(|group| group.command.name.clone());
let service_groups = group_entries(filtered, GroupingMode::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, |group| {
group.command.name.clone()
}) {
Some(index) => self.selected = index,
None => self.clamp_selection(),
}
}
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.action_matches.clear();
self.pending_action = None;
}
fn handle_key(&mut self, key: KeyEvent) -> Result<Option<PreparedCommand>> {
if self.keybindings.is("common", "quit", key) {
self.should_quit = true;
return Ok(None);
}
match self.mode {
AppMode::Browse => self.handle_browse_key(key),
AppMode::Search => {
self.handle_search_key(key);
Ok(None)
}
AppMode::TypeFilter => {
self.handle_type_filter_key(key);
Ok(None)
}
AppMode::ActionPicker => self.handle_action_picker_key(key),
AppMode::InstancePicker => self.handle_instance_picker_key(key),
AppMode::ServicePicker => self.handle_service_picker_key(key),
AppMode::Help => {
if self.keybindings.is("help", "close", key) {
self.return_to_browse();
}
Ok(None)
}
}
}
fn handle_browse_key(&mut self, key: KeyEvent) -> Result<Option<PreparedCommand>> {
match key.code {
_ if self.keybindings.is("browse", "quit", key) => self.should_quit = true,
_ if self.keybindings.is("browse", "down", key) => self.move_selection(1),
_ if self.keybindings.is("browse", "up", key) => self.move_selection(-1),
_ if self.keybindings.is("browse", "invoke", key) => return self.invoke_selected(),
_ if self.keybindings.is("browse", "search", key) => self.mode = AppMode::Search,
_ if self.keybindings.is("browse", "type_filter", key) => {
self.type_filter_index = 0;
self.mode = AppMode::TypeFilter;
}
_ if self.keybindings.is("browse", "tab_next", key) => self.cycle_tab(1),
_ if self.keybindings.is("browse", "tab_prev", key) => self.cycle_tab(-1),
_ if self.keybindings.is("browse", "same_host", key) => {
self.toggle_same_host_filter();
}
_ if self.keybindings.is("browse", "refresh", key) => self.refresh_services(),
_ if self.keybindings.is("browse", "details_down", key) => self.scroll_details(1),
_ if self.keybindings.is("browse", "details_up", key) => self.scroll_details(-1),
_ if self.keybindings.is("browse", "help", key) => self.mode = AppMode::Help,
KeyCode::Char(ch) => {
if !ch.is_control() {
self.filter.text_query.push(ch);
self.mode = AppMode::Search;
self.recompute_visible();
}
}
_ => {}
}
Ok(None)
}
fn handle_search_key(&mut self, key: KeyEvent) {
match key.code {
_ if self.keybindings.is("search", "close", key) => self.return_to_browse(),
KeyCode::Backspace => {
self.filter.text_query.pop();
self.recompute_visible();
}
_ if self.keybindings.is("search", "clear", key) => {
self.filter.clear_text();
self.recompute_visible();
}
KeyCode::Char(ch) => {
if !ch.is_control() {
self.filter.text_query.push(ch);
self.recompute_visible();
}
}
_ => {}
}
}
fn handle_type_filter_key(&mut self, key: KeyEvent) {
let count = self.service_types.len();
match key.code {
_ if self.keybindings.is("type_filter", "close", key) => self.return_to_browse(),
_ if self.keybindings.is("type_filter", "down", key) => {
self.type_filter_index = move_index(self.type_filter_index, count, 1);
}
_ if self.keybindings.is("type_filter", "up", key) => {
self.type_filter_index = move_index(self.type_filter_index, count, -1);
}
_ if self.keybindings.is("type_filter", "toggle", key) => {
if let Some(service_type) = self.service_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, key: KeyEvent) -> Result<Option<PreparedCommand>> {
let len = self.action_matches.len();
match key.code {
_ if self.keybindings.is("picker", "close", key) => self.return_to_browse(),
_ if self.keybindings.is("picker", "down", key) => {
self.action_index = move_index(self.action_index, len, 1);
}
_ if self.keybindings.is("picker", "up", key) => {
self.action_index = move_index(self.action_index, len, -1);
}
_ if self.keybindings.is("picker", "select", key) => {
if let Some(action) = self.action_matches.get(self.action_index).cloned() {
return self.choose_action(action);
}
}
_ => {}
}
Ok(None)
}
fn handle_instance_picker_key(&mut self, key: KeyEvent) -> Result<Option<PreparedCommand>> {
let count = self
.pending_action
.as_ref()
.map(|action| action.matching_records.len())
.unwrap_or(0);
match key.code {
_ if self.keybindings.is("picker", "close", key) => self.return_to_browse(),
_ if self.keybindings.is("picker", "down", key) => {
self.instance_index = move_index(self.instance_index, count, 1);
}
_ if self.keybindings.is("picker", "up", key) => {
self.instance_index = move_index(self.instance_index, count, -1);
}
_ if self.keybindings.is("picker", "select", key) => {
let Some(action) = self.pending_action.clone() else {
self.return_to_browse();
return Ok(None);
};
let Some(record) = action.matching_records.get(self.instance_index) else {
self.return_to_browse();
return Ok(None);
};
return self.execute_action(&action, 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.details_viewport.get() / 2).max(1) as isize;
self.scroll_details_by(direction * step);
}
fn scroll_details_by(&mut self, delta: isize) {
let max = self.details_max_scroll.get() 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.list_row_at(position) {
self.selected = index;
self.details_scroll = 0;
}
}
_ => {}
}
}
fn mouse_scroll(&mut self, position: Position, direction: isize) {
if self.details_area.get().contains(position) {
self.scroll_details_by(direction);
} else if self.list_area.get().contains(position) {
self.move_selection(direction);
}
}
fn list_row_at(&self, position: Position) -> Option<usize> {
let area = self.list_area.get();
if !area.contains(position) {
return None;
}
let row = (position.y.checked_sub(area.y + 1))? as usize;
let inner_h = area.height.saturating_sub(2) as usize;
if row >= inner_h {
return None;
}
let total = self.active_count();
let index = render::scroll_offset(self.selected, total, inner_h) + row;
(index < total).then_some(index)
}
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)
}
1 => 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.choose_action(result)
}
fn handle_service_picker_key(&mut self, key: KeyEvent) -> Result<Option<PreparedCommand>> {
let count = self
.command_groups
.get(self.selected)
.map(|group| group.services.len())
.unwrap_or(0);
match key.code {
_ if self.keybindings.is("picker", "close", key) => self.return_to_browse(),
_ if self.keybindings.is("picker", "down", key) => {
self.service_picker_index = move_index(self.service_picker_index, count, 1);
}
_ if self.keybindings.is("picker", "up", key) => {
self.service_picker_index = move_index(self.service_picker_index, count, -1);
}
_ if self.keybindings.is("picker", "select", key) => {
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_instance && action.matching_records.len() > 1 {
self.pending_action = Some(action);
self.instance_index = 0;
self.mode = AppMode::InstancePicker;
return Ok(None);
}
let Some(record) = action.matching_records.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;
if let Some(missing) = plumber::exec::missing_requirement(&action.command.requirements) {
return self.fail(format!(
"`{name}` needs `{missing}`, which is not installed"
));
}
let prepared = match plumber::exec::prepare(&action.command.action, record) {
Ok(prepared) => prepared,
Err(err) => return self.fail(format!("cannot run `{name}`: {err}")),
};
match prepared.mode {
ActionMode::Fork => match plumber::exec::fork(&prepared) {
Ok(()) => {
self.status = format!("launched `{name}`");
self.return_to_browse();
Ok(None)
}
Err(err) => self.fail(format!("cannot run `{name}`: {err}")),
},
ActionMode::Execute => Ok(Some(prepared)),
}
}
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) {
Ok((matcher, warnings)) => {
self.matcher = matcher;
self.close_pickers();
self.recompute_visible();
self.status = match warnings.len() {
0 => format!("reloaded {} command(s)", self.matcher.command_count()),
skipped => format!(
"reloaded {} command(s); skipped {skipped} config file(s)",
self.matcher.command_count()
),
};
}
Err(err) => self.status = format!("config reload failed: {err}"),
}
}
fn refresh_services(&mut self) {
let Some(factory) = &self.discovery_factory else {
self.status = "refresh is not available".to_string();
return;
};
self.discovery = None;
let mut backend = factory(&self.cli.discovery_config());
self.discovery_rx = backend.events();
self.discovery = Some(backend);
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.grouping == GroupingMode::Command && self.filter.host_filter.is_none() {
self.status = "same-host filter is unavailable in command view".to_string();
return;
}
if self.filter.host_filter.is_some() {
self.filter.clear_host_filter();
self.status = "host filter cleared".to_string();
self.recompute_visible();
return;
}
match self
.visible_groups
.get(self.selected)
.and_then(|group| group.hostname.clone())
{
Some(host) => {
self.status = format!("filtering by host `{host}`");
self.filter.set_host_filter(host);
self.recompute_visible();
}
None => self.status = "selected service has no resolved host yet".to_string(),
}
}
}
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 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::sync::mpsc;
use super::*;
use crate::plumber::Matcher;
use crate::ui::keymap::KeyBindings;
fn test_cli() -> Cli {
Cli {
domain: "local".to_string(),
config_dirs: Vec::new(),
service_type: None,
fake_discovery: true,
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(), rx);
let pending = Entry::new("workstation", "_ssh._tcp", "local").with_instance_id();
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);
let resolved = resolved.with_instance_id();
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].hostname.as_deref(),
Some("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(), 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.with_instance_id()))
.unwrap();
tx.send(DiscoveryEvent::Upsert(beta.with_instance_id()))
.unwrap();
tx.send(DiscoveryEvent::Upsert(web.with_instance_id()))
.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(), 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.with_instance_id()))
.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 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 (_tx, rx) = mpsc::channel();
let mut app = App::new(test_cli(), matcher, KeyBindings::default(), rx);
for record in records {
let record = record.with_instance_id();
app.records.insert(record.id.clone(), 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,
}
}
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 app = app_with(Matcher::default(), records);
app.list_area.set(Rect::new(0, 2, 60, 7));
app.details_area.set(Rect::new(60, 2, 40, 7));
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);
app.details_max_scroll.set(3);
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..5 {
app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
}
assert_eq!(app.details_scroll, 3);
for _ in 0..9 {
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)
}
fn send(app: &mut App, code: KeyCode) -> Option<PreparedCommand> {
app.handle_key(key(code)).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))
.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 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.service_type == "_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 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"]);
}
#[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);
}
#[test]
fn remove_event_drops_records_sharing_a_registration_key() {
let (tx, rx) = mpsc::channel();
let mut app = App::new(test_cli(), Matcher::default(), KeyBindings::default(), rx);
tx.send(DiscoveryEvent::Upsert(
ssh("alpha", "10.0.0.1").with_instance_id(),
))
.unwrap();
app.drain_discovery();
assert_eq!(app.records.len(), 1);
let removal = Entry::new("alpha", "_ssh._tcp", "local")
.with_instance_id()
.id;
tx.send(DiscoveryEvent::Remove(removal)).unwrap();
app.drain_discovery();
assert!(app.records.is_empty());
assert!(app.visible_groups.is_empty());
}
#[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;
use color_eyre::eyre::eyre;
struct ChannelDiscovery {
receiver: Option<mpsc::Receiver<DiscoveryEvent>>,
dropped: Option<Arc<AtomicBool>>,
}
impl Discovery for ChannelDiscovery {
fn events(&mut self) -> mpsc::Receiver<DiscoveryEvent> {
self.receiver
.take()
.expect("discovery receiver can only be taken once")
}
}
impl Drop for ChannelDiscovery {
fn drop(&mut self) {
if let Some(flag) = &self.dropped {
flag.store(true, Ordering::Relaxed);
}
}
}
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 |_config: &DiscoveryConfig| {
let (tx, rx) = mpsc::channel();
spawned.lock().unwrap().push(tx);
Box::new(ChannelDiscovery {
receiver: Some(rx),
dropped: None,
}) as Box<dyn Discovery>
})
};
(factory, spawned)
}
#[test]
fn refresh_restarts_discovery_and_repopulates_like_startup() {
let old_backend_dropped = Arc::new(AtomicBool::new(false));
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(
Box::new(ChannelDiscovery {
receiver: None,
dropped: Some(old_backend_dropped.clone()),
}),
factory,
);
assert_eq!(app.visible_groups.len(), 2);
send(&mut app, KeyCode::Char('r'));
assert!(old_backend_dropped.load(Ordering::Relaxed));
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").with_instance_id(),
))
.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(
Box::new(ChannelDiscovery {
receiver: None,
dropped: None,
}),
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");
}
#[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(Box::new(|_cli| {
Ok((
Box::new(matcher_from(&[SSH])) as Box<dyn RuleEngine>,
Vec::new(),
))
}));
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);
Ok((
Box::new(Matcher::default()) as Box<dyn RuleEngine>,
Vec::new(),
))
})
});
let status = app.status.clone();
app.poll_reload();
assert_eq!(calls.load(Ordering::Relaxed), 0);
assert_eq!(app.status, status);
}
#[test]
fn failed_reload_keeps_the_current_commands() {
let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(Box::new(|_cli| Err(eyre!("boom"))));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert_eq!(app.matcher.command_count(), 1, "old rules stay in force");
assert!(app.status.contains("config reload failed"));
assert!(app.status.contains("boom"));
}
#[test]
fn reload_reports_skipped_config_files() {
let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
.with_config_loader(Box::new(|_cli| {
Ok((
Box::new(matcher_from(&[SSH])) as Box<dyn RuleEngine>,
vec!["bad.toml: not toml".to_string()],
))
}));
app.reload_requested.store(true, Ordering::Relaxed);
app.poll_reload();
assert!(app.status.contains("skipped 1 config file"));
}
#[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| {
Ok((
Box::new(Matcher::default()) as Box<dyn RuleEngine>,
Vec::new(),
))
}));
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 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))
.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))
.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.details_viewport.set(10);
app.details_max_scroll.set(5);
send(&mut app, KeyCode::Char('d'));
assert_eq!(app.details_scroll, 5, "half of 10 clamps to the max of 5");
send(&mut app, KeyCode::Char('d'));
assert_eq!(app.details_scroll, 5, "cannot scroll past the maximum");
send(&mut app, KeyCode::Char('u'));
assert_eq!(app.details_scroll, 0, "scrolling up returns to the top");
}
#[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 BAD_TEMPLATE: &str = r#"
[metadata]
name = "bad-template"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "echo {nonexistent_field}"
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 bad_template_reports_status_instead_of_crashing_the_loop() {
let mut app = app_with(
matcher_from(&[BAD_TEMPLATE]),
vec![ssh("alpha", "10.0.0.1")],
);
assert!(send(&mut app, KeyCode::Enter).is_none());
assert!(app.status.contains("cannot run `bad-template`"));
assert_eq!(app.mode, AppMode::Browse);
}
#[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, BAD_TEMPLATE]),
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 `bad-template`"));
}
}