use std::process::Command;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
use ratatui::layout::Rect;
use ratatui_interact::components::TreeNode;
use ratatui_interact::state::FocusManager;
use ratatui_interact::traits::ClickRegionRegistry;
use ratatui_themes::{ThemeName, ThemePalette};
use usage::{Spec, SpecCommand, SpecFlag};
use crate::components::arg_panel::{ArgPanelAction, ArgPanelComponent, ArgPanelEnterRequest};
use crate::components::command_panel::{CommandPanelAction, CommandPanelComponent};
use crate::components::execution::{ExecutionAction, ExecutionComponent};
use crate::components::filterable::{FilterAction, FilterableComponent};
use crate::components::flag_panel::{FlagPanelAction, FlagPanelComponent, FlagPanelEnterRequest};
use crate::components::theme_picker::{ThemePickerAction, ThemePickerComponent};
use crate::components::{Component, EventResult};
#[derive(Debug, Clone, Copy)]
pub struct MatchScores {
pub name_score: u32,
pub help_score: u32,
}
impl MatchScores {
pub fn overall(&self) -> u32 {
self.name_score.max(self.help_score)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Quit,
Execute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Builder,
Executing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Focus {
Commands,
Flags,
Args,
Preview,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FlagValue {
Bool(bool),
NegBool(Option<bool>),
String(String),
Count(u32),
}
#[derive(Debug, Clone)]
pub struct ArgValue {
pub name: String,
pub value: String,
pub required: bool,
pub choices: Vec<String>,
pub help: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CmdData {
pub name: String,
pub help: Option<String>,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct FlatCommand {
pub id: String,
pub name: String,
pub help: Option<String>,
pub aliases: Vec<String>,
pub depth: usize,
pub full_path: String,
}
pub(crate) fn collect_visible_flags<'a>(cmd: &'a SpecCommand, spec: &'a Spec) -> Vec<&'a SpecFlag> {
let mut flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect();
for flag in &spec.cmd.flags {
if flag.global && !flag.hide && !flags.iter().any(|f| f.name == flag.name) {
flags.push(flag);
}
}
flags
}
pub struct UiLayout {
pub click_regions: ClickRegionRegistry<Focus>,
pub flag_overlay_rect: Option<Rect>,
pub arg_overlay_rect: Option<Rect>,
pub theme_overlay_rect: Option<Rect>,
pub theme_indicator_rect: Option<Rect>,
}
impl UiLayout {
pub fn new() -> Self {
Self {
click_regions: ClickRegionRegistry::new(),
flag_overlay_rect: None,
arg_overlay_rect: None,
theme_overlay_rect: None,
theme_indicator_rect: None,
}
}
fn area_for(&self, focus: Focus) -> Rect {
self.click_regions
.regions()
.iter()
.find(|r| r.data == focus)
.map(|r| r.area)
.unwrap_or_default()
}
fn region_at(&self, col: u16, row: u16) -> Option<(Focus, Rect)> {
self.click_regions
.regions()
.iter()
.find(|r| r.contains(col, row))
.map(|region| (region.data, region.area))
}
}
pub struct App {
pub spec: Spec,
pub mode: AppMode,
pub execution: Option<ExecutionComponent>,
pub theme_name: ThemeName,
pub command_path: Vec<String>,
pub command_panel: FilterableComponent<CommandPanelComponent>,
pub flag_panel: FilterableComponent<FlagPanelComponent>,
pub flag_values: std::collections::HashMap<String, Vec<(String, FlagValue)>>,
arg_values_by_path: std::collections::HashMap<String, Vec<ArgValue>>,
pub arg_values: Vec<ArgValue>,
pub focus_manager: FocusManager<Focus>,
pub arg_panel: FilterableComponent<ArgPanelComponent>,
pub layout: UiLayout,
pub theme_picker: ThemePickerComponent,
pub mouse_position: Option<(u16, u16)>,
}
impl App {
pub fn new(spec: Spec) -> Self {
Self::with_theme(spec, ThemeName::default())
}
pub fn is_executing(&self) -> bool {
self.mode == AppMode::Executing
}
pub fn close_execution(&mut self) {
self.mode = AppMode::Builder;
self.execution = None;
}
pub fn start_execution(&mut self, component: ExecutionComponent) {
self.mode = AppMode::Executing;
self.execution = Some(component);
}
pub fn spawn_execution(&mut self, terminal_size: ratatui::layout::Size) -> color_eyre::Result<()> {
let parts = self.build_command_parts();
let command_display = self.build_command();
let component = ExecutionComponent::spawn(command_display, &parts, terminal_size)?;
self.start_execution(component);
Ok(())
}
pub fn resize_execution_to_terminal(&self, terminal_size: ratatui::layout::Size) {
if let Some(ref exec) = self.execution {
exec.resize_to_terminal(terminal_size);
}
}
#[cfg(test)]
pub fn resize_pty(&self, rows: u16, cols: u16) {
if let Some(ref exec) = self.execution {
exec.resize_pty(rows, cols);
}
}
pub fn with_theme(spec: usage::Spec, theme_name: ThemeName) -> Self {
let tree_nodes = build_command_tree(&spec);
let command_panel = FilterableComponent::new(CommandPanelComponent::new(tree_nodes));
let mut app = Self {
spec,
mode: AppMode::Builder,
execution: None,
theme_name,
command_path: Vec::new(),
command_panel,
flag_panel: FilterableComponent::new(FlagPanelComponent::new()),
flag_values: std::collections::HashMap::new(),
arg_values_by_path: std::collections::HashMap::new(),
arg_values: Vec::new(),
focus_manager: FocusManager::new(),
arg_panel: FilterableComponent::new(ArgPanelComponent::new()),
layout: UiLayout::new(),
theme_picker: ThemePickerComponent::new(),
mouse_position: None,
};
app.sync_state();
app.sync_command_path_from_tree();
app.notify_focus_gained(app.focus());
app
}
fn rebuild_focus_manager(&mut self) {
let had_focus = !self.focus_manager.is_empty();
let current = self.focus();
self.focus_manager.clear();
if self.has_any_commands() {
self.focus_manager.register(Focus::Commands);
}
if !self.visible_flags().is_empty() {
self.focus_manager.register(Focus::Flags);
}
if !self.arg_values.is_empty() {
self.focus_manager.register(Focus::Args);
}
self.focus_manager.register(Focus::Preview);
if had_focus {
self.focus_manager.set(current);
}
}
pub fn palette(&self) -> ThemePalette {
self.theme_name.palette()
}
pub fn next_theme(&mut self) {
self.theme_name = self.theme_name.next();
}
pub fn prev_theme(&mut self) {
self.theme_name = self.theme_name.prev();
}
pub fn focus(&self) -> Focus {
self.focus_manager
.current()
.copied()
.unwrap_or(Focus::Preview)
}
pub fn set_focus(&mut self, panel: Focus) {
let previous = self.focus();
if previous == panel {
return;
}
self.notify_focus_lost(previous);
self.focus_manager.set(panel);
self.notify_focus_gained(panel);
}
pub fn is_editing(&self) -> bool {
self.flag_panel.is_editing() || self.arg_panel.is_editing()
}
pub fn finish_editing(&mut self) {
if self.flag_panel.is_editing() {
let idx = self.flag_panel.selected_index();
let value = self.flag_panel.finish_editing();
self.apply_flag_string_value(idx, &value);
}
if self.arg_panel.is_editing() {
let idx = self.arg_panel.selected_index();
let value = self.arg_panel.finish_editing();
self.set_arg_value(idx, value);
}
}
pub fn is_theme_picking(&self) -> bool {
self.theme_picker.is_open()
}
pub fn open_theme_picker(&mut self) {
self.theme_picker.open(self.theme_name);
}
fn process_theme_picker_action(&mut self, action: ThemePickerAction) {
match action {
ThemePickerAction::PreviewTheme(name) => {
self.theme_name = name;
}
ThemePickerAction::Confirmed => {
}
ThemePickerAction::Cancelled(original) => {
self.theme_name = original;
}
}
}
fn handle_theme_picker_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
if let EventResult::Action(action) = self.theme_picker.handle_key(key) {
self.process_theme_picker_action(action);
}
Action::None
}
pub fn is_choosing(&self) -> bool {
self.flag_panel.is_choosing() || self.arg_panel.is_choosing()
}
fn dispatch_filter_result<A>(
&mut self,
result: EventResult<FilterAction<A>>,
process: impl FnOnce(&mut Self, A) -> Action,
) -> Action {
self.dispatch_filter_result_with(result, process, |_| {})
}
fn dispatch_filter_result_with<A>(
&mut self,
result: EventResult<FilterAction<A>>,
process: impl FnOnce(&mut Self, A) -> Action,
on_not_handled: impl FnOnce(&mut Self),
) -> Action {
match result {
EventResult::Action(FilterAction::Inner(action)) => process(self, action),
EventResult::Action(FilterAction::FocusNext) => {
self.focus_next();
Action::None
}
EventResult::Action(FilterAction::FocusPrev) => {
self.focus_prev();
Action::None
}
EventResult::NotHandled => {
on_not_handled(self);
Action::None
}
EventResult::Consumed => Action::None,
}
}
fn dispatch_filter_result_option<A>(
&mut self,
result: EventResult<FilterAction<A>>,
process: impl FnOnce(&mut Self, A) -> Action,
) -> Option<Action> {
match result {
EventResult::Action(FilterAction::Inner(action)) => Some(process(self, action)),
EventResult::Action(FilterAction::FocusNext) => {
self.focus_next();
Some(Action::None)
}
EventResult::Action(FilterAction::FocusPrev) => {
self.focus_prev();
Some(Action::None)
}
EventResult::Consumed => Some(Action::None),
EventResult::NotHandled => None,
}
}
fn notify_focus_gained(&mut self, panel: Focus) {
match panel {
Focus::Commands => {
let result = self.command_panel.handle_focus_gained();
self.dispatch_filter_result(result, |s, action| {
s.process_command_action(action);
Action::None
});
}
Focus::Flags => {
let result = self.flag_panel.handle_focus_gained();
self.dispatch_filter_result(result, |s, action| s.process_flag_action(action));
}
Focus::Args => {
let result = self.arg_panel.handle_focus_gained();
self.dispatch_filter_result(result, |s, action| s.process_arg_action(action));
}
Focus::Preview => {}
}
}
fn notify_focus_lost(&mut self, panel: Focus) {
match panel {
Focus::Commands => {
let result = self.command_panel.handle_focus_lost();
self.dispatch_filter_result(result, |s, action| {
s.process_command_action(action);
Action::None
});
}
Focus::Flags => {
let result = self.flag_panel.handle_focus_lost();
self.dispatch_filter_result(result, |s, action| s.process_flag_action(action));
}
Focus::Args => {
let result = self.arg_panel.handle_focus_lost();
self.dispatch_filter_result(result, |s, action| s.process_arg_action(action));
}
Focus::Preview => {}
}
}
fn focus_next(&mut self) {
let previous = self.focus();
self.focus_manager.next();
let current = self.focus();
if current != previous {
self.notify_focus_lost(previous);
self.notify_focus_gained(current);
}
}
fn focus_prev(&mut self) {
let previous = self.focus();
self.focus_manager.prev();
let current = self.focus();
if current != previous {
self.notify_focus_lost(previous);
self.notify_focus_gained(current);
}
}
fn focused_panel_is_handling_input(&self) -> bool {
match self.focus() {
Focus::Commands => self.command_panel.is_filtering(),
Focus::Flags => self.flag_panel.is_filtering() || self.flag_panel.is_editing() || self.flag_panel.is_choosing(),
Focus::Args => self.arg_panel.is_filtering() || self.arg_panel.is_editing() || self.arg_panel.is_choosing(),
Focus::Preview => false,
}
}
fn handle_focused_panel_key(&mut self, key: crossterm::event::KeyEvent) -> Option<Action> {
match self.focus() {
Focus::Commands => {
let result = self.command_panel.handle_key(key);
self.dispatch_filter_result_option(result, |s, action| {
s.process_command_action(action);
Action::None
})
}
Focus::Flags => {
let result = self.flag_panel.handle_key(key);
self.dispatch_filter_result_option(result, |s, action| s.process_flag_action(action))
}
Focus::Args => {
let result = self.arg_panel.handle_key(key);
self.dispatch_filter_result_option(result, |s, action| s.process_arg_action(action))
}
Focus::Preview => None,
}
}
fn apply_flag_string_value(&mut self, flag_idx: usize, value: &str) {
let mut changed = false;
{
let values = self.current_flag_values_mut();
if let Some((name, FlagValue::String(ref mut s))) = values.get_mut(flag_idx) {
let flag_name = name.clone();
*s = value.to_string();
let new_val = FlagValue::String(s.clone());
self.sync_global_flag(&flag_name, &new_val);
changed = true;
}
}
if changed {
self.refresh_flag_panel_inputs();
}
}
fn process_arg_action(&mut self, action: ArgPanelAction) -> Action {
match action {
ArgPanelAction::EnterRequest(request) => {
self.process_arg_enter_request(request);
Action::None
}
ArgPanelAction::ClearArg(idx) => {
self.set_arg_value(idx, String::new());
Action::None
}
ArgPanelAction::ValueChanged { index, value } => {
self.set_arg_value(index, value);
Action::None
}
ArgPanelAction::EditFinished { index, value } => {
self.set_arg_value(index, value);
Action::None
}
ArgPanelAction::ChoiceSelected { index, value }
| ArgPanelAction::ChoiceCancelled { index, value } => {
self.set_arg_value(index, value);
Action::None
}
}
}
fn process_flag_action(&mut self, action: FlagPanelAction) -> Action {
match action {
FlagPanelAction::ToggleFlag(_idx) => {
self.toggle_simple_flag();
Action::None
}
FlagPanelAction::ClearFlag(idx) => {
let mut changed = false;
{
let values = self.current_flag_values_mut();
if let Some((name, value)) = values.get_mut(idx) {
let flag_name = name.clone();
match value {
FlagValue::Bool(b) => {
*b = false;
let new_val = FlagValue::Bool(false);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::Count(c) => {
*c = c.saturating_sub(1);
let new_val = FlagValue::Count(*c);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::String(s) => {
s.clear();
let new_val = FlagValue::String(String::new());
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::NegBool(state) => {
*state = None;
let new_val = FlagValue::NegBool(None);
self.sync_global_flag(&flag_name, &new_val);
}
}
changed = true;
}
}
if changed {
self.refresh_flag_panel_inputs();
}
Action::None
}
FlagPanelAction::EnterRequest(request) => {
self.process_flag_enter_request(request);
Action::None
}
FlagPanelAction::ChoiceSelected { index, value }
| FlagPanelAction::ChoiceCancelled { index, value } => {
self.apply_flag_string_value(index, &value);
Action::None
}
FlagPanelAction::ValueChanged { index, value } => {
self.apply_flag_string_value(index, &value);
Action::None
}
FlagPanelAction::EditFinished { index, value } => {
self.apply_flag_string_value(index, &value);
Action::None
}
FlagPanelAction::NegBoolClick(idx, target) => {
let mut changed = false;
{
let values = self.current_flag_values_mut();
if let Some((name, FlagValue::NegBool(state))) = values.get_mut(idx) {
let flag_name = name.clone();
*state = if *state == target { None } else { target };
let new_val = FlagValue::NegBool(*state);
self.sync_global_flag(&flag_name, &new_val);
changed = true;
}
}
if changed {
self.refresh_flag_panel_inputs();
}
Action::None
}
}
}
pub fn filter_active(&self) -> bool {
self.command_panel.has_active_filter()
|| self.flag_panel.has_active_filter()
|| self.arg_panel.has_active_filter()
}
pub fn is_filtering(&self) -> bool {
self.command_panel.is_filtering()
|| self.flag_panel.is_filtering()
|| self.arg_panel.is_filtering()
}
#[allow(dead_code)]
pub fn command_index(&self) -> usize {
self.command_panel.selected_index()
}
#[allow(dead_code)]
pub fn set_command_index(&mut self, idx: usize) {
if self.command_panel.select_item(idx) {
self.set_command_path(self.command_panel.path().to_vec());
}
}
pub fn flag_index(&self) -> usize {
self.flag_panel.selected_index()
}
#[cfg(test)]
pub fn set_flag_index(&mut self, idx: usize) {
self.flag_panel.select(idx);
}
pub fn arg_index(&self) -> usize {
self.arg_panel.selected_index()
}
#[cfg(test)]
pub fn set_arg_index(&mut self, idx: usize) {
self.arg_panel.select(idx);
}
#[cfg(test)]
pub fn command_scroll(&self) -> usize {
self.command_panel.scroll_offset()
}
#[allow(dead_code)] pub fn filter(&self) -> &str {
match self.focus() {
Focus::Commands => self.command_panel.filter_text(),
Focus::Flags => self.flag_panel.filter_text(),
Focus::Args => self.arg_panel.filter_text(),
_ => "",
}
}
pub fn current_command(&self) -> &SpecCommand {
let mut cmd = &self.spec.cmd;
for name in &self.command_path {
if let Some(sub) = cmd.find_subcommand(name) {
cmd = sub;
} else {
break;
}
}
cmd
}
pub fn find_completion(&self, arg_name: &str) -> Option<&usage::SpecComplete> {
let cmd = self.current_command();
cmd.complete.get(arg_name)
}
pub fn run_completion(
run_cmd: &str,
descriptions: bool,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let output = Command::new("sh")
.arg("-c")
.arg(run_cmd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut choices = Vec::new();
let mut descs = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if descriptions {
if let Some((value, desc)) = parse_completion_line(line) {
choices.push(value);
descs.push(Some(desc));
} else {
choices.push(line.to_string());
descs.push(None);
}
} else {
choices.push(line.to_string());
descs.push(None);
}
}
if choices.is_empty() {
return None;
}
Some((choices, descs))
}
#[cfg(test)]
pub fn run_and_open_completion(
&mut self,
arg_name: &str,
_current_value: &str,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let complete = self.find_completion(arg_name)?.clone();
let run_cmd = complete.run.as_ref()?;
Self::run_completion(run_cmd, complete.descriptions)
}
fn has_any_commands(&self) -> bool {
!self.spec.cmd.subcommands.is_empty()
}
#[cfg(test)]
pub fn visible_subcommands(&self) -> Vec<(&String, &SpecCommand)> {
let cmd = self.current_command();
let items: Vec<(&String, &SpecCommand)> =
cmd.subcommands.iter().filter(|(_, c)| !c.hide).collect();
if self.command_panel.is_filtering() && !self.command_panel.filter_text().is_empty() && self.focus() == Focus::Commands {
let filter_lower = self.command_panel.filter_text().to_lowercase();
items
.into_iter()
.filter(|(name, c)| {
fuzzy_match(&name.to_lowercase(), &filter_lower)
|| c.aliases
.iter()
.any(|a| fuzzy_match(&a.to_lowercase(), &filter_lower))
|| c.help
.as_ref()
.map(|h| fuzzy_match(&h.to_lowercase(), &filter_lower))
.unwrap_or(false)
})
.collect()
} else {
items
}
}
pub fn visible_flags(&self) -> Vec<&SpecFlag> {
collect_visible_flags(self.current_command(), &self.spec)
}
#[cfg(test)]
pub fn visible_args(&self) -> Vec<&usage::SpecArg> {
let cmd = self.current_command();
cmd.args.iter().filter(|a| !a.hide).collect()
}
fn default_arg_values_for_command(cmd: &SpecCommand) -> Vec<ArgValue> {
cmd.args
.iter()
.filter(|a| !a.hide)
.map(|a| {
let choices = a
.choices
.as_ref()
.map(|c| c.choices.clone())
.unwrap_or_default();
let default = a.default.first().cloned().unwrap_or_default();
ArgValue {
name: a.name.clone(),
value: default,
required: a.required,
choices,
help: a.help.clone(),
}
})
.collect()
}
fn persist_current_arg_values(&mut self) {
let path_key = self.command_path_key();
self.arg_values_by_path
.insert(path_key, self.arg_values.clone());
}
fn set_command_path(&mut self, new_path: Vec<String>) {
if self.command_path != new_path {
self.persist_current_arg_values();
self.command_path = new_path;
}
self.sync_state();
}
fn set_arg_value(&mut self, index: usize, value: String) {
if let Some(arg) = self.arg_values.get_mut(index) {
arg.value = value;
self.persist_current_arg_values();
}
self.refresh_arg_panel_inputs();
}
fn refresh_flag_panel_inputs(&mut self) {
let flags = self.visible_flags_snapshot();
let flag_refs: Vec<&SpecFlag> = flags.iter().collect();
let flag_values = self.current_flag_values().to_vec();
self.flag_panel.set_filterable_items_from_flags(&flag_refs);
self.flag_panel
.set_enter_requests_from_flags(&flag_refs, &flag_values);
}
fn refresh_arg_panel_inputs(&mut self) {
self.arg_panel.set_filterable_items_from_args(&self.arg_values);
self.arg_panel.set_enter_requests_from_args(&self.arg_values);
}
pub fn sync_state(&mut self) {
let path_key = self.command_path_key();
if !self.arg_values_by_path.contains_key(&path_key) {
let defaults = {
let cmd = self.current_command();
Self::default_arg_values_for_command(cmd)
};
self.arg_values_by_path.insert(path_key.clone(), defaults);
}
self.arg_values = self
.arg_values_by_path
.get(&path_key)
.cloned()
.unwrap_or_default();
if !self.flag_values.contains_key(&path_key) {
let root_global_values: std::collections::HashMap<String, FlagValue> = self
.flag_values
.get("")
.map(|root_flags| {
root_flags
.iter()
.filter(|(name, _)| {
self.spec
.cmd
.flags
.iter()
.any(|f| f.global && f.name == *name)
})
.map(|(name, val)| (name.clone(), val.clone()))
.collect()
})
.unwrap_or_default();
let flags = self.visible_flags_snapshot();
let values: Vec<(String, FlagValue)> = flags
.iter()
.map(|f| {
if let Some(global_val) = root_global_values.get(&f.name) {
return (f.name.clone(), global_val.clone());
}
let val = if f.count {
FlagValue::Count(0)
} else if f.arg.is_some() {
let default = f.default.first().cloned().unwrap_or_default();
FlagValue::String(default)
} else if f.negate.is_some() {
FlagValue::NegBool(None)
} else {
FlagValue::Bool(false)
};
(f.name.clone(), val)
})
.collect();
self.flag_values.insert(path_key, values);
}
let flag_count = self.current_flag_values().len();
let arg_count = self.arg_values.len();
self.flag_panel.set_total(flag_count);
self.arg_panel.set_total(arg_count);
self.refresh_flag_panel_inputs();
self.refresh_arg_panel_inputs();
self.rebuild_focus_manager();
}
fn visible_flags_snapshot(&self) -> Vec<SpecFlag> {
self.visible_flags().into_iter().cloned().collect()
}
fn command_path_key(&self) -> String {
self.command_path.join(" ")
}
pub fn current_flag_values(&self) -> &[(String, FlagValue)] {
let key = self.command_path_key();
self.flag_values
.get(&key)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn current_flag_values_mut(&mut self) -> &mut Vec<(String, FlagValue)> {
let key = self.command_path_key();
self.flag_values.entry(key).or_default()
}
fn sync_global_flag(&mut self, flag_name: &str, new_value: &FlagValue) {
let is_global = self
.spec
.cmd
.flags
.iter()
.any(|f| f.global && f.name == flag_name);
if !is_global {
return;
}
let keys: Vec<String> = self.flag_values.keys().cloned().collect();
for key in keys {
if let Some(flags) = self.flag_values.get_mut(&key) {
if let Some((_, val)) = flags.iter_mut().find(|(n, _)| n == flag_name) {
*val = new_value.clone();
}
}
}
}
#[allow(dead_code)]
pub fn total_visible_commands(&self) -> usize {
self.command_panel.total_visible()
}
#[cfg(test)]
pub fn compute_flag_match_scores(&self) -> std::collections::HashMap<String, MatchScores> {
let pattern = self.filter();
if pattern.is_empty() {
return std::collections::HashMap::new();
}
let flags = self.visible_flags();
let mut scores = std::collections::HashMap::new();
for flag in flags {
let mut temp_matcher = Matcher::new(Config::DEFAULT);
let name_score = fuzzy_match_score(&flag.name, pattern, &mut temp_matcher);
let long_score = flag
.long
.iter()
.map(|l| fuzzy_match_score(l, pattern, &mut temp_matcher))
.max()
.unwrap_or(0);
let short_score = flag
.short
.iter()
.map(|s| {
let s_str = s.to_string();
fuzzy_match_score(&s_str, pattern, &mut temp_matcher)
})
.max()
.unwrap_or(0);
let help_score = flag
.help
.as_ref()
.map(|h| fuzzy_match_score(h, pattern, &mut temp_matcher))
.unwrap_or(0);
let combined_name_score = name_score.max(long_score).max(short_score);
scores.insert(
flag.name.clone(),
MatchScores {
name_score: combined_name_score,
help_score,
},
);
}
scores
}
#[cfg(test)]
pub fn compute_arg_match_scores(&self) -> std::collections::HashMap<String, MatchScores> {
let pattern = self.filter();
if pattern.is_empty() {
return std::collections::HashMap::new();
}
let args = self.visible_args();
let mut scores = std::collections::HashMap::new();
for arg in args {
let mut temp_matcher = Matcher::new(Config::DEFAULT);
let name_score = fuzzy_match_score(&arg.name, pattern, &mut temp_matcher);
let help_score = arg
.help
.as_ref()
.map(|h| fuzzy_match_score(h, pattern, &mut temp_matcher))
.unwrap_or(0);
scores.insert(
arg.name.clone(),
MatchScores {
name_score,
help_score,
},
);
}
scores
}
pub fn sync_command_path_from_tree(&mut self) {
self.set_command_path(self.command_panel.path().to_vec());
}
fn apply_command_panel_key(&mut self, key: crossterm::event::KeyEvent) {
match self.command_panel.handle_key(key) {
EventResult::Action(FilterAction::Inner(action)) => {
self.process_command_action(action);
}
EventResult::Action(FilterAction::FocusNext) => {
self.focus_manager.next();
}
EventResult::Action(FilterAction::FocusPrev) => {
self.focus_manager.prev();
}
_ => {}
}
}
fn process_command_action(&mut self, action: CommandPanelAction) {
match action {
CommandPanelAction::PathChanged(new_path) => {
self.set_command_path(new_path);
}
}
}
#[allow(dead_code)]
pub fn navigate_to_command(&mut self, path: &[&str]) {
self.command_panel.navigate_to(path);
self.set_command_path(self.command_panel.path().to_vec());
}
#[allow(dead_code)]
pub fn navigate_into_selected(&mut self) {
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
);
self.apply_command_panel_key(enter);
}
fn area_for(&self, focus: Focus) -> Rect {
self.layout.area_for(focus)
}
pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Action {
use crossterm::event::{MouseButton, MouseEventKind};
let col = event.column;
let row = event.row;
self.mouse_position = Some((col, row));
match event.kind {
MouseEventKind::Down(MouseButton::Left | MouseButton::Right) => {
let is_left = matches!(event.kind, MouseEventKind::Down(MouseButton::Left));
if is_left {
if self.is_theme_picking() {
if let Some(action) =
self.theme_picker
.click_at(col, row, self.layout.theme_overlay_rect)
{
self.process_theme_picker_action(action);
}
return Action::None;
}
if let Some(rect) = self.layout.theme_indicator_rect {
if col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
{
self.open_theme_picker();
return Action::None;
}
}
if self.is_choosing() {
return self.delegate_mouse_to_choosing_panel(event);
}
}
if let Some((clicked_panel, area)) = self.layout.region_at(col, row) {
let switching_focus = self.focus() != clicked_panel;
if !switching_focus && self.is_editing() {
self.finish_editing();
}
self.set_focus(clicked_panel);
match clicked_panel {
Focus::Commands => {
let result = self.command_panel.handle_mouse(event, area);
self.dispatch_filter_result(result, |s, action| {
s.process_command_action(action);
Action::None
})
}
Focus::Flags => {
let result = self.flag_panel.handle_mouse(event, area);
self.dispatch_filter_result(result, |s, action| {
s.process_flag_action(action)
})
}
Focus::Args => {
let result = self.arg_panel.handle_mouse(event, area);
self.dispatch_filter_result(result, |s, action| {
s.process_arg_action(action)
})
}
Focus::Preview => {
if is_left && !switching_focus {
return self.handle_enter();
}
Action::None
}
}
} else {
Action::None
}
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let focus = self.focus();
let area = self.area_for(focus);
match focus {
Focus::Commands => {
let result = self.command_panel.handle_mouse(event, area);
self.dispatch_filter_result(result, |s, action| {
s.process_command_action(action);
Action::None
})
}
Focus::Flags => {
let result = self.flag_panel.handle_mouse(event, area);
self.dispatch_filter_result(result, |s, action| {
s.process_flag_action(action)
})
}
Focus::Args => {
let result = self.arg_panel.handle_mouse(event, area);
self.dispatch_filter_result(result, |s, action| {
s.process_arg_action(action)
})
}
Focus::Preview => Action::None,
}
}
_ => Action::None,
}
}
fn delegate_mouse_to_choosing_panel(
&mut self,
event: crossterm::event::MouseEvent,
) -> Action {
if self.flag_panel.is_choosing() {
let result = self
.flag_panel
.handle_choice_click(event.column, event.row, self.layout.flag_overlay_rect)
.map(FilterAction::Inner);
return self.dispatch_filter_result(result, |s, action| {
s.process_flag_action(action)
});
}
if self.arg_panel.is_choosing() {
let result = self
.arg_panel
.handle_choice_click(event.column, event.row, self.layout.arg_overlay_rect)
.map(FilterAction::Inner);
return self.dispatch_filter_result(result, |s, action| {
s.process_arg_action(action)
});
}
Action::None
}
#[cfg(test)]
pub fn ensure_visible(&mut self, panel: Focus, viewport_height: usize) {
if viewport_height == 0 {
return;
}
match panel {
Focus::Commands => {
self.command_panel.ensure_visible(viewport_height);
}
Focus::Flags => {
self.flag_panel.ensure_visible(viewport_height);
}
Focus::Args => {
self.arg_panel.ensure_visible(viewport_height);
}
Focus::Preview => {}
}
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
if self.is_executing() {
if let Some(ref mut exec) = self.execution {
if let EventResult::Action(ExecutionAction::Close) = exec.handle_key(key) {
self.close_execution();
}
}
return Action::None;
}
if key.code == KeyCode::Char('r')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
return Action::Execute;
}
if self.is_theme_picking() {
return self.handle_theme_picker_key(key);
}
let focused_panel_is_handling_input = self.focused_panel_is_handling_input();
if let Some(action) = self.handle_focused_panel_key(key) {
return action;
}
if focused_panel_is_handling_input {
return Action::None;
}
match key.code {
KeyCode::Char('q') => Action::Quit,
KeyCode::Char('T') => {
self.open_theme_picker();
Action::None
}
KeyCode::Char(']') => {
self.next_theme();
Action::None
}
KeyCode::Char('[') => {
self.prev_theme();
Action::None
}
KeyCode::Char('p') => Action::None,
KeyCode::Tab => {
self.focus_next();
Action::None
}
KeyCode::BackTab => {
self.focus_prev();
Action::None
}
KeyCode::Enter if self.focus() == Focus::Preview => self.handle_enter(),
_ => Action::None,
}
}
#[allow(dead_code)] pub fn start_editing(&mut self) {
let current_text = match self.focus() {
Focus::Flags => {
let flag_idx = self.flag_index();
self.current_flag_values()
.get(flag_idx)
.and_then(|(_, v)| match v {
FlagValue::String(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_default()
}
Focus::Args => {
let arg_idx = self.arg_index();
self.arg_values
.get(arg_idx)
.map(|a| a.value.clone())
.unwrap_or_default()
}
_ => return,
};
match self.focus() {
Focus::Flags => self.flag_panel.start_editing(¤t_text),
Focus::Args => self.arg_panel.start_editing(¤t_text),
_ => {}
}
}
#[cfg(test)]
fn move_up(&mut self) {
match self.focus() {
Focus::Commands => {
if self.command_panel.move_up() {
self.set_command_path(self.command_panel.path().to_vec());
}
}
Focus::Flags => {
self.flag_panel.move_up();
}
Focus::Args => {
self.arg_panel.move_up();
}
_ => {}
}
}
fn handle_enter(&mut self) -> Action {
match self.focus() {
Focus::Commands => {
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
self.handle_focused_panel_key(enter).unwrap_or(Action::None)
}
Focus::Flags | Focus::Args => {
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
self.handle_focused_panel_key(enter).unwrap_or(Action::None)
}
Focus::Preview => Action::Execute,
}
}
fn process_flag_enter_request(&mut self, request: FlagPanelEnterRequest) {
match request {
FlagPanelEnterRequest::Toggle => {
self.toggle_simple_flag();
}
FlagPanelEnterRequest::ChoiceSelect {
index,
choices,
current_value,
value_column,
} => {
let current_value = self
.current_flag_values()
.get(index)
.and_then(|(_, value)| match value {
FlagValue::String(text) => Some(text.clone()),
_ => None,
})
.unwrap_or(current_value);
self.flag_panel
.open_choice_select(index, choices, ¤t_value, value_column);
}
FlagPanelEnterRequest::EditOrComplete {
index,
arg_name,
current_value,
value_column,
} => {
let current_value = self
.current_flag_values()
.get(index)
.and_then(|(_, value)| match value {
FlagValue::String(text) => Some(text.clone()),
_ => None,
})
.unwrap_or(current_value);
if let Some(ref arg_name) = arg_name {
if let Some((choices, descriptions)) =
self.run_flag_completion(arg_name, ¤t_value)
{
self.flag_panel.open_completion_select(
index,
choices,
descriptions,
¤t_value,
value_column,
);
return;
}
}
self.flag_panel.start_editing(¤t_value);
}
}
}
fn run_flag_completion(
&self,
arg_name: &str,
_current_value: &str,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let complete = self.find_completion(arg_name)?.clone();
let run_cmd = complete.run.as_ref()?;
Self::run_completion(run_cmd, complete.descriptions)
}
fn process_arg_enter_request(&mut self, request: ArgPanelEnterRequest) {
match request {
ArgPanelEnterRequest::ChoiceSelect {
index,
choices,
current_value,
value_column,
} => {
let current_value = self
.arg_values
.get(index)
.map(|arg| arg.value.clone())
.unwrap_or(current_value);
self.arg_panel
.open_choice_select(index, choices, ¤t_value, value_column);
}
ArgPanelEnterRequest::EditOrComplete {
index,
arg_name,
current_value,
value_column,
} => {
let current_value = self
.arg_values
.get(index)
.map(|arg| arg.value.clone())
.unwrap_or(current_value);
if let Some((choices, descriptions)) =
self.run_arg_completion(&arg_name, ¤t_value)
{
self.arg_panel.open_completion_select(
index,
choices,
descriptions,
¤t_value,
value_column,
);
} else {
self.arg_panel.start_editing(¤t_value);
}
}
}
}
fn run_arg_completion(
&self,
arg_name: &str,
_current_value: &str,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let complete = self.find_completion(arg_name)?.clone();
let run_cmd = complete.run.as_ref()?;
Self::run_completion(run_cmd, complete.descriptions)
}
fn toggle_simple_flag(&mut self) {
let flag_idx = self.flag_index();
let mut changed = false;
{
let values = self.current_flag_values_mut();
if let Some((name, value)) = values.get_mut(flag_idx) {
let flag_name = name.clone();
match value {
FlagValue::Bool(b) => {
*b = !*b;
let new_val = FlagValue::Bool(*b);
self.sync_global_flag(&flag_name, &new_val);
changed = true;
}
FlagValue::NegBool(state) => {
*state = match *state {
None => Some(true),
Some(true) => Some(false),
Some(false) => None,
};
let new_val = FlagValue::NegBool(*state);
self.sync_global_flag(&flag_name, &new_val);
changed = true;
}
FlagValue::Count(c) => {
*c += 1;
let new_val = FlagValue::Count(*c);
self.sync_global_flag(&flag_name, &new_val);
changed = true;
}
_ => {}
}
}
}
if changed {
self.refresh_flag_panel_inputs();
}
}
pub fn build_command(&self) -> String {
let preview = crate::command_builder::LiveArgPreview {
choice_select_index: self.arg_panel.choice_select_index(),
choice_select_text: self.arg_panel.choice_select_text(),
is_editing: self.arg_panel.is_editing(),
editing_index: self.arg_panel.selected_index(),
editing_text: self.arg_panel.editing_text(),
};
crate::command_builder::build_command(
&self.spec,
&self.flag_values,
&self.command_path,
&self.arg_values,
&preview,
)
}
pub fn build_command_parts(&self) -> Vec<String> {
crate::command_builder::build_command_parts(
&self.spec,
&self.flag_values,
&self.command_path,
&self.arg_values,
)
}
}
pub fn build_command_tree(spec: &Spec) -> Vec<TreeNode<CmdData>> {
build_cmd_nodes(&spec.cmd, &[])
}
fn build_cmd_nodes(cmd: &SpecCommand, parent_path: &[String]) -> Vec<TreeNode<CmdData>> {
cmd.subcommands
.iter()
.filter(|(_, c)| !c.hide)
.map(|(name, c)| {
let mut path = parent_path.to_vec();
path.push(name.clone());
let id = path.join(" ");
TreeNode::new(
&id,
CmdData {
name: name.clone(),
help: c.help.clone(),
aliases: c.aliases.clone(),
},
)
.with_children(build_cmd_nodes(c, &path))
})
.collect()
}
pub fn compute_tree_scores(
nodes: &[TreeNode<CmdData>],
pattern: &str,
) -> std::collections::HashMap<String, MatchScores> {
let flat = flatten_command_tree(nodes);
let mut scores = std::collections::HashMap::new();
let mut matcher = Matcher::new(Config::DEFAULT);
for cmd in &flat {
let name_score = fuzzy_match_score(&cmd.name, pattern, &mut matcher);
let alias_score = cmd
.aliases
.iter()
.map(|a| fuzzy_match_score(a, pattern, &mut matcher))
.max()
.unwrap_or(0);
let help_score = cmd
.help
.as_ref()
.map(|h| fuzzy_match_score(h, pattern, &mut matcher))
.unwrap_or(0);
let path_score = fuzzy_match_score(&cmd.full_path, pattern, &mut matcher);
let combined_name_score = name_score.max(alias_score).max(path_score);
scores.insert(
cmd.id.clone(),
MatchScores {
name_score: combined_name_score,
help_score,
},
);
}
scores
}
#[cfg(test)]
fn parent_id(id: &str) -> Option<String> {
if id.is_empty() {
None } else if let Some(pos) = id.rfind(' ') {
Some(id[..pos].to_string())
} else {
Some(String::new()) }
}
pub fn flatten_command_tree(nodes: &[TreeNode<CmdData>]) -> Vec<FlatCommand> {
fn flatten_recursive(
nodes: &[TreeNode<CmdData>],
depth: usize,
parent_names: &[String],
result: &mut Vec<FlatCommand>,
) {
for node in nodes {
let mut path_parts = parent_names.to_vec();
path_parts.push(node.data.name.clone());
let full_path = path_parts.join(" ");
result.push(FlatCommand {
id: node.id.clone(),
name: node.data.name.clone(),
help: node.data.help.clone(),
aliases: node.data.aliases.clone(),
depth,
full_path,
});
if !node.children.is_empty() {
flatten_recursive(&node.children, depth + 1, &path_parts, result);
}
}
}
let mut result = Vec::new();
flatten_recursive(nodes, 0, &[], &mut result);
result
}
pub fn fuzzy_match_score(text: &str, pattern: &str, matcher: &mut Matcher) -> u32 {
use nucleo_matcher::Utf32Str;
let pattern = Pattern::parse(pattern, CaseMatching::Smart, Normalization::Smart);
let mut haystack_buf = Vec::new();
let haystack = Utf32Str::new(text, &mut haystack_buf);
pattern.score(haystack, matcher).unwrap_or(0)
}
pub fn fuzzy_match_indices(
text: &str,
pattern_str: &str,
matcher: &mut Matcher,
) -> (u32, Vec<u32>) {
use nucleo_matcher::Utf32Str;
let pattern = Pattern::parse(pattern_str, CaseMatching::Smart, Normalization::Smart);
let mut haystack_buf = Vec::new();
let haystack = Utf32Str::new(text, &mut haystack_buf);
let mut indices = Vec::new();
if let Some(score) = pattern.indices(haystack, matcher, &mut indices) {
indices.sort_unstable();
indices.dedup();
(score, indices)
} else {
(0, Vec::new())
}
}
#[cfg(test)]
pub fn fuzzy_match(text: &str, pattern: &str) -> bool {
let mut text_chars = text.chars();
for pc in pattern.chars() {
loop {
match text_chars.next() {
Some(tc) if tc == pc => break,
Some(_) => continue,
None => return false,
}
}
}
true
}
fn parse_completion_line(line: &str) -> Option<(String, String)> {
let mut value = String::new();
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' && chars.peek() == Some(&':') {
value.push(':');
chars.next();
} else if ch == ':' {
let desc: String = chars.collect();
return Some((value, desc.to_string()));
} else {
value.push(ch);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use crate::components::execution::ExecutionState;
fn sample_spec() -> Spec {
let input = include_str!("../fixtures/sample.usage.kdl");
input.parse::<Spec>().expect("Failed to parse sample spec")
}
#[test]
fn test_app_creation() {
let app = App::new(sample_spec());
assert_eq!(app.spec.bin, "mycli");
assert_eq!(app.spec.name, "My CLI");
assert_eq!(app.command_path, vec!["init"]);
assert_eq!(app.focus(), Focus::Commands);
}
#[test]
fn test_tree_built_from_spec() {
let app = App::new(sample_spec());
assert!(app.command_panel.tree_nodes().len() > 1);
let names: Vec<&str> = app
.command_panel
.tree_nodes()
.iter()
.map(|n| n.data.name.as_str())
.collect();
assert!(names.contains(&"init"));
assert!(names.contains(&"config"));
assert!(names.contains(&"run"));
}
#[test]
fn test_flat_list_all_visible() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(app.command_panel.tree_nodes());
assert_eq!(flat.len(), 15);
assert!(flat.iter().any(|c| c.id == "config set"));
assert!(flat.iter().any(|c| c.id == "plugin install"));
}
#[test]
fn test_visible_subcommands_at_root() {
let mut app = App::new(sample_spec());
app.command_path.clear();
app.sync_state();
let subs = app.visible_subcommands();
let names: Vec<&str> = subs.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"init"));
assert!(names.contains(&"config"));
assert!(names.contains(&"run"));
assert!(names.contains(&"deploy"));
assert!(names.contains(&"plugin"));
assert!(names.contains(&"version"));
assert!(names.contains(&"help"));
}
#[test]
fn test_navigate_to_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
assert_eq!(app.command_path, vec!["config"]);
let subs = app.visible_subcommands();
let names: Vec<&str> = subs.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"set"));
assert!(names.contains(&"get"));
assert!(names.contains(&"list"));
assert!(names.contains(&"remove"));
}
#[test]
fn test_navigate_to_deep_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config", "set"]);
assert_eq!(app.command_path, vec!["config", "set"]);
}
#[test]
fn test_navigate_into_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
assert_eq!(app.command_path, vec!["config"]);
app.navigate_into_selected();
assert!(!app.command_path.is_empty());
assert!(
app.command_path.len() == 2 && app.command_path[0] == "config",
"Should be in config's subtree: {:?}",
app.command_path
);
}
#[test]
fn test_build_command_basic() {
let app = App::new(sample_spec());
let cmd = app.build_command();
assert_eq!(cmd, "mycli init");
}
#[test]
fn test_build_command_with_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
let cmd = app.build_command();
assert!(cmd.starts_with("mycli init"));
}
#[test]
fn test_build_command_with_flags_and_args() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
if let Some(arg) = app.arg_values.get_mut(0) {
arg.value = "myproject".to_string();
}
let values = app.current_flag_values_mut();
for (name, value) in values.iter_mut() {
if name == "force" {
*value = FlagValue::Bool(true);
}
}
let cmd = app.build_command();
assert!(cmd.contains("mycli"));
assert!(cmd.contains("init"));
assert!(cmd.contains("--force"));
assert!(cmd.contains("myproject"));
}
#[test]
fn test_build_command_with_count_flag() {
let mut app = App::new(sample_spec());
let root_key = String::new();
if let Some(flags) = app.flag_values.get_mut(&root_key) {
for (name, value) in flags.iter_mut() {
if name == "verbose" {
*value = FlagValue::Count(3);
}
}
}
app.sync_global_flag("verbose", &FlagValue::Count(3));
let cmd = app.build_command();
assert!(cmd.contains("-vvv"));
}
#[test]
fn test_negated_flag_init_as_negbool_none() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let values = app.current_flag_values();
let color = values.iter().find(|(n, _)| n == "color");
assert_eq!(
color,
Some(&("color".to_string(), FlagValue::NegBool(None))),
"--color flag with negate should initialize to NegBool(None)"
);
}
#[test]
fn test_negated_flag_omitted_emits_nothing() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let cmd = app.build_command();
assert!(
!cmd.contains("--color"),
"omitted negatable flag should not emit --color: {cmd}"
);
assert!(
!cmd.contains("--no-color"),
"omitted negatable flag should not emit --no-color: {cmd}"
);
}
#[test]
fn test_negated_flag_explicit_on_emits_flag() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let path_key = "run".to_string();
if let Some(flags) = app.flag_values.get_mut(&path_key) {
for (name, value) in flags.iter_mut() {
if name == "color" {
*value = FlagValue::NegBool(Some(true));
}
}
}
let cmd = app.build_command();
assert!(
cmd.contains("--color"),
"explicit on should emit --color: {cmd}"
);
}
#[test]
fn test_negated_flag_explicit_off_emits_negate() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let path_key = "run".to_string();
if let Some(flags) = app.flag_values.get_mut(&path_key) {
for (name, value) in flags.iter_mut() {
if name == "color" {
*value = FlagValue::NegBool(Some(false));
}
}
}
let cmd = app.build_command();
assert!(
cmd.contains("--no-color"),
"explicit off should emit --no-color: {cmd}"
);
let without_negate = cmd.replace("--no-color", "");
assert!(
!without_negate.contains("--color"),
"should not include positive flag when negated: {cmd}"
);
}
#[test]
fn test_negated_flag_toggle_cycles() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Flags);
let color_idx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "color")
.expect("color flag should exist");
app.flag_panel.select(color_idx);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(None)
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(Some(true))
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(Some(false))
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(None)
);
}
#[test]
fn test_negated_flag_backspace_resets() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Flags);
let color_idx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "color")
.expect("color flag should exist");
app.flag_panel.select(color_idx);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(Some(true))
);
let backspace = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(backspace);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(None)
);
}
#[test]
fn test_fuzzy_match() {
assert!(fuzzy_match("config", "cfg"));
assert!(fuzzy_match("config", "con"));
assert!(fuzzy_match("config", "config"));
assert!(!fuzzy_match("config", "xyz"));
assert!(fuzzy_match("deploy", "dpl"));
assert!(!fuzzy_match("deploy", "dpx"));
assert!(fuzzy_match("hello world", "hwd"));
}
#[test]
fn test_full_path_matching() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(app.command_panel.tree_nodes(), "cfgset");
let set_score = scores.get("config set").map(|s| s.overall()).unwrap_or(0);
assert!(
set_score > 0,
"cfgset should match config set, got score {set_score}"
);
let init_score = scores.get("init").map(|s| s.overall()).unwrap_or(0);
assert_eq!(init_score, 0, "cfgset should not match init");
let run_score = scores.get("run").map(|s| s.overall()).unwrap_or(0);
assert_eq!(run_score, 0, "cfgset should not match run");
let scores2 = compute_tree_scores(app.command_panel.tree_nodes(), "plinstall");
let install_score = scores2
.get("plugin install")
.map(|s| s.overall())
.unwrap_or(0);
assert!(
install_score > 0,
"plinstall should match plugin install, got score {install_score}"
);
}
#[test]
fn test_fuzzy_match_with_pattern_indices() {
use nucleo_matcher::{Config, Matcher};
let mut matcher = Matcher::new(Config::DEFAULT);
let (score, indices) = fuzzy_match_indices("config", "cfg", &mut matcher);
assert!(score > 0, "Should match 'cfg' in 'config'");
assert_eq!(indices, vec![0, 3, 5], "Should match c, f, g");
let (score, indices) = fuzzy_match_indices("foo bar baz", "foo baz", &mut matcher);
assert!(score > 0, "Should match multi-word pattern");
assert!(indices.contains(&0)); assert!(indices.len() >= 6);
let (score, indices) = fuzzy_match_indices("config", "xyz", &mut matcher);
assert_eq!(score, 0, "Should not match 'xyz'");
assert!(indices.is_empty(), "No indices for non-match");
let (score, indices) = fuzzy_match_indices("MyConfig", "myconf", &mut matcher);
assert!(score > 0, "Should match case-insensitively");
assert_eq!(indices.len(), 6, "Should match all 6 characters");
}
#[test]
fn test_command_path_navigation() {
let mut app = App::new(sample_spec());
assert_eq!(app.command_path, vec!["init"]);
app.navigate_to_command(&["config"]);
assert_eq!(app.command_path, vec!["config"]);
app.navigate_to_command(&["config", "set"]);
assert_eq!(app.command_path, vec!["config", "set"]);
}
#[test]
fn test_visible_flags_includes_global() {
let app = App::new(sample_spec());
let flags = app.visible_flags();
let names: Vec<&str> = flags.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"verbose"));
assert!(names.contains(&"quiet"));
}
#[test]
fn test_arg_values_initialized() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
assert!(!app.arg_values.is_empty());
assert_eq!(app.arg_values[0].name, "name");
assert!(app.arg_values[0].required);
}
#[test]
fn test_deploy_has_choices() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
assert!(!app.arg_values.is_empty());
assert_eq!(app.arg_values[0].name, "environment");
assert!(app.arg_values[0].choices.contains(&"dev".to_string()));
assert!(app.arg_values[0].choices.contains(&"staging".to_string()));
assert!(app.arg_values[0].choices.contains(&"prod".to_string()));
}
#[test]
fn test_arg_values_persist_per_command_path() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.arg_values[0].value = "prod".to_string();
app.navigate_to_command(&["init"]);
app.arg_values[0].value = "demo-app".to_string();
app.navigate_to_command(&["deploy"]);
assert_eq!(app.arg_values[0].value, "prod");
app.navigate_to_command(&["init"]);
assert_eq!(app.arg_values[0].value, "demo-app");
}
#[test]
fn test_arg_defaults_only_apply_on_first_visit() {
let spec = r#"
name "Defaults CLI"
bin "defaults"
cmd "deploy" {
arg "<environment>" default="staging"
}
cmd "other" {
arg "<name>"
}
"#
.parse::<Spec>()
.expect("Failed to parse default-arg test spec");
let mut app = App::new(spec);
app.navigate_to_command(&["deploy"]);
assert_eq!(app.arg_values[0].value, "staging");
app.arg_values[0].value = "prod".to_string();
app.navigate_to_command(&["other"]);
app.navigate_to_command(&["deploy"]);
assert_eq!(app.arg_values[0].value, "prod");
}
#[test]
fn test_flag_with_default_value() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let flag_values = app.current_flag_values();
let jobs = flag_values.iter().find(|(n, _)| n == "jobs");
assert!(jobs.is_some());
if let Some((_, FlagValue::String(s))) = jobs {
assert_eq!(s, "4");
} else {
panic!("Expected string flag value for jobs");
}
}
#[test]
fn test_key_handling_quit() {
let mut app = App::new(sample_spec());
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.handle_key(key), Action::Quit);
}
#[test]
fn test_key_handling_navigation() {
let mut app = App::new(sample_spec());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert!(app.command_index() > 0);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.command_index(), 0);
}
#[test]
fn test_tab_cycles_focus() {
let mut app = App::new(sample_spec());
assert_eq!(app.focus(), Focus::Commands);
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert_eq!(app.focus(), Focus::Flags);
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert_ne!(app.focus(), Focus::Commands);
}
#[test]
fn test_filter_mode() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
for c in "cfg".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
assert_eq!(app.filter(), "cfg");
assert!(app.is_filtering());
assert_eq!(app.filter(), "cfg");
}
#[test]
fn test_filter_flags() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
for c in "roll".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
assert_eq!(app.filter(), "roll");
let flags = app.visible_flags();
let scores = app.compute_flag_match_scores();
let rollback_score = scores.get("rollback").map(|s| s.overall()).unwrap_or(0);
assert!(rollback_score > 0, "rollback should match 'roll'");
let tag_score = scores.get("tag").map(|s| s.overall()).unwrap_or(0);
let yes_score = scores.get("yes").map(|s| s.overall()).unwrap_or(0);
assert_eq!(tag_score, 0, "tag should not match 'roll'");
assert_eq!(yes_score, 0, "yes should not match 'roll'");
let names: Vec<&str> = flags.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"rollback"));
assert!(names.contains(&"tag"));
assert!(names.contains(&"yes"));
}
#[test]
fn test_filter_tab_switches_focus_and_clears() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
assert_eq!(app.filter(), "x");
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert!(!app.is_filtering());
assert!(app.filter().is_empty());
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_slash_does_not_activate_filter_in_preview_pane() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Preview);
assert_eq!(app.focus(), Focus::Preview);
assert!(!app.is_filtering());
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(
!app.is_filtering(),
"Filter mode should not activate in Preview pane"
);
assert!(app.filter().is_empty());
}
#[test]
fn test_filtered_navigation_works_after_enter_applies_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
assert_eq!(app.filter(), "p");
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.is_filtering(), "Should exit typing mode after Enter");
assert_eq!(app.filter(), "p", "Query should remain after Enter");
assert!(app.filter_active(), "Filter should still be active");
let j_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(j_key);
assert_eq!(app.filter(), "p", "j should navigate, not append to filter");
assert!(app.filter_active(), "Filter should remain active after j");
}
#[test]
fn test_esc_clears_applied_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.is_filtering());
assert!(app.filter_active(), "Filter should be active after Enter");
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_filtering());
assert!(!app.filter_active(), "Esc should clear the applied filter");
assert!(app.filter().is_empty());
}
#[test]
fn test_args_filter_matches_name() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
let e_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('e'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(e_key);
let n_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('n'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(n_key);
let v_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('v'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(v_key);
assert_eq!(app.filter(), "env");
let scores = app.compute_arg_match_scores();
let env_score = scores.get("environment").map(|s| s.overall()).unwrap_or(0);
assert!(env_score > 0, "environment should match 'env'");
}
#[test]
fn test_args_filtered_navigation() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Args);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let n_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('n'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(n_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.is_filtering(), "Should exit typing mode");
assert!(app.filter_active(), "Filter should still be active");
let _initial_index = app.arg_panel.selected_index();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let _new_index = app.arg_panel.selected_index();
assert!(app.filter_active());
}
#[test]
fn test_tab_clears_applied_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.filter_active(), "Filter should be active after Enter");
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert!(!app.filter_active(), "Tab should clear the applied filter");
assert!(app.filter().is_empty());
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_set_focus_clears_applied_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.filter_active(), "Filter should be active after Enter");
app.set_focus(Focus::Flags);
assert!(!app.filter_active(), "Focus loss should clear the applied filter");
assert!(app.filter().is_empty());
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_scroll_offset_ensure_visible() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.command_panel.select_item(5);
app.command_panel.set_scroll(0);
app.ensure_visible(Focus::Commands, 3);
assert_eq!(app.command_scroll(), 3);
app.command_panel.select_item(2);
app.ensure_visible(Focus::Commands, 3);
assert_eq!(app.command_scroll(), 2);
app.command_panel.select_item(3);
app.ensure_visible(Focus::Commands, 3);
assert_eq!(app.command_scroll(), 2);
}
#[test]
fn test_scroll_offset_on_move_up() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.command_panel.set_scroll(2);
app.command_panel.select_item(2);
app.move_up();
assert_eq!(app.command_index(), 1);
}
#[test]
fn test_mouse_click_focuses_panel() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.layout = UiLayout::new();
app.layout.click_regions
.register(ratatui::layout::Rect::new(0, 1, 40, 18), Focus::Commands);
app.layout.click_regions
.register(ratatui::layout::Rect::new(40, 1, 60, 18), Focus::Flags);
app.layout.click_regions
.register(ratatui::layout::Rect::new(0, 21, 100, 3), Focus::Preview);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 50,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.focus(), Focus::Flags);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 22,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.focus(), Focus::Preview);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.focus(), Focus::Commands);
}
#[test]
fn test_mouse_click_selects_item() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let area = ratatui::layout::Rect::new(0, 1, 40, 18);
app.layout = UiLayout::new();
app.layout.click_regions.register(area, Focus::Commands);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.command_index(), 2);
}
#[test]
fn test_mouse_scroll_moves_selection() {
use crossterm::event::{MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let mouse = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.command_index(), 1);
app.handle_mouse(mouse);
assert_eq!(app.command_index(), 2);
let mouse_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 10,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse_up);
assert_eq!(app.command_index(), 1);
}
#[test]
fn test_focus_manager_integration() {
let mut app = App::new(sample_spec());
assert_eq!(app.focus(), Focus::Commands);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Flags);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Args);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Preview);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Commands);
app.focus_manager.prev();
assert_eq!(app.focus(), Focus::Preview);
}
#[test]
fn test_tree_view_state_integration() {
let mut app = App::new(sample_spec());
let total = app.total_visible_commands();
assert_eq!(total, 15);
assert_eq!(app.command_index(), 0);
app.command_panel.move_down();
assert_eq!(app.command_index(), 1);
app.command_panel.move_up();
assert_eq!(app.command_index(), 0);
app.command_panel.move_up();
assert_eq!(app.command_index(), 0);
}
#[test]
fn test_input_state_editing() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
assert!(app.arg_panel.is_editing());
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
for ch in "hello".chars() {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
}
assert_eq!(app.arg_values[0].value, "hello");
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
assert_eq!(app.arg_values[0].value, "hell");
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
assert!(!app.arg_panel.is_editing());
}
#[test]
fn test_click_region_registry() {
let mut app = App::new(sample_spec());
app.layout = UiLayout::new();
app.layout.click_regions
.register(Rect::new(0, 0, 40, 20), Focus::Commands);
app.layout.click_regions
.register(Rect::new(40, 0, 60, 20), Focus::Flags);
app.layout.click_regions
.register(Rect::new(0, 20, 100, 3), Focus::Preview);
assert_eq!(
app.layout.click_regions.handle_click(10, 5),
Some(&Focus::Commands)
);
assert_eq!(app.layout.click_regions.handle_click(50, 5), Some(&Focus::Flags));
assert_eq!(
app.layout.click_regions.handle_click(50, 21),
Some(&Focus::Preview)
);
}
#[test]
fn test_filter_input_state() {
let mut app = App::new(sample_spec());
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
assert!(app.filter().is_empty());
let key_d = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key_d);
assert_eq!(app.filter(), "d");
let bs = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(bs);
assert!(app.filter().is_empty());
let key_x = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key_x);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_filtering());
assert!(app.filter().is_empty());
}
#[test]
fn test_flat_list_total_commands() {
let app = App::new(sample_spec());
assert_eq!(app.total_visible_commands(), 15);
}
#[test]
fn test_right_moves_to_first_child() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.set_focus(Focus::Commands);
let right = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(right);
assert_eq!(
app.command_path,
vec!["config", "set"],
"Should have moved to first child of config"
);
}
#[test]
fn test_left_moves_to_parent() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config", "set"]);
assert_eq!(app.command_path, vec!["config", "set"]);
app.set_focus(Focus::Commands);
let left = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Left,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(left);
assert_eq!(
app.command_path,
vec!["config"],
"Should have moved to parent of set"
);
}
#[test]
fn test_left_on_top_level_does_nothing() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.set_focus(Focus::Commands);
let left = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Left,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(left);
assert_eq!(
app.command_path,
vec!["config"],
"Should stay on config since it has no parent"
);
}
#[test]
fn test_tree_selected_command_determines_flags() {
let mut app = App::new(sample_spec());
let root_flags = app.visible_flags();
let root_flag_names: Vec<&str> = root_flags.iter().map(|f| f.name.as_str()).collect();
assert!(root_flag_names.contains(&"verbose"));
app.navigate_to_command(&["deploy"]);
let deploy_flags = app.visible_flags();
let deploy_flag_names: Vec<&str> = deploy_flags.iter().map(|f| f.name.as_str()).collect();
assert!(deploy_flag_names.contains(&"tag"));
assert!(deploy_flag_names.contains(&"rollback"));
}
#[test]
fn test_parent_id() {
assert_eq!(parent_id(""), None);
assert_eq!(parent_id("init"), Some("".to_string()));
assert_eq!(parent_id("config"), Some("".to_string()));
assert_eq!(parent_id("config set"), Some("config".to_string()));
assert_eq!(parent_id("plugin install"), Some("plugin".to_string()));
}
#[test]
fn test_count_flag_decrement_via_backspace() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "verbose")
.unwrap();
app.set_flag_index(fidx);
for _ in 0..3 {
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
}
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(3),
"Space should increment count to 3"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(2),
"Backspace should decrement count to 2"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(0),
"Count should be 0 after decrementing fully"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(0),
"Count should not go below 0"
);
}
#[test]
fn test_backspace_clears_bool_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "quiet")
.unwrap();
app.set_flag_index(fidx);
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Bool(true));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Bool(false),
"Backspace should turn off boolean flags"
);
}
#[test]
fn test_backspace_clears_string_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "tag")
.unwrap();
app.set_flag_index(fidx);
app.start_editing();
for ch in "v1".chars() {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
let result = app.flag_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_flag_action(action);
}
}
app.finish_editing();
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::String("v1".to_string())
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::String(String::new()),
"Backspace should clear string flag values"
);
}
#[test]
fn test_backspace_clears_arg_value() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
for ch in "test".chars() {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
}
app.finish_editing();
assert_eq!(app.arg_values[0].value, "test");
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.arg_values[0].value, "",
"Backspace should clear argument values"
);
}
#[test]
fn test_backspace_decrements_count_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "verbose")
.unwrap();
app.set_flag_index(fidx);
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Count(3));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Count(2));
}
#[test]
fn test_right_click_decrements_count_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "verbose")
.unwrap();
app.set_flag_index(fidx);
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Count(2));
let flags_area = ratatui::layout::Rect::new(40, 1, 60, 18);
app.layout = UiLayout::new();
app.layout
.click_regions
.register(flags_area, Focus::Flags);
let flag_row = flags_area.y + 1 + fidx as u16;
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: flags_area.x + 5,
row: flag_row,
modifiers: KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(1),
"Right-click should decrement count flag"
);
}
#[test]
fn test_editing_finished_on_mouse_click_different_arg() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
assert!(app.is_editing());
for ch in "hi".chars() {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
}
assert_eq!(app.arg_values[0].value, "hi");
let args_area = ratatui::layout::Rect::new(40, 5, 60, 10);
app.layout = UiLayout::new();
app.layout.click_regions.register(args_area, Focus::Args);
let mouse_event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 50,
row: 7, modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse_event);
assert!(
!app.is_editing(),
"Editing should be finished after clicking a different item"
);
assert_eq!(
app.arg_values[0].value, "hi",
"First arg value should be preserved"
);
assert_eq!(
app.arg_values[1].value, "",
"Second arg value should be empty, not copied from first"
);
}
#[test]
fn test_editing_finished_on_mouse_click_different_panel() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
for ch in "test".chars() {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
}
assert_eq!(app.arg_values[0].value, "test");
assert!(app.is_editing());
app.layout = UiLayout::new();
app.layout.click_regions
.register(ratatui::layout::Rect::new(0, 0, 40, 15), Focus::Flags);
app.layout.click_regions
.register(ratatui::layout::Rect::new(40, 0, 60, 15), Focus::Args);
let mouse_event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse_event);
assert!(
!app.is_editing(),
"Editing should be finished when clicking another panel"
);
assert_eq!(app.arg_values[0].value, "test");
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_set_focus_finishes_flag_editing() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let flag_index = app
.current_flag_values()
.iter()
.position(|(name, _)| name == "tag")
.unwrap();
app.set_flag_index(flag_index);
app.start_editing();
for ch in "v2".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(app.is_editing());
assert_eq!(
app.current_flag_values()[flag_index].1,
FlagValue::String("v2".to_string())
);
app.set_focus(Focus::Args);
assert!(!app.is_editing(), "Focus loss should finish editing");
assert_eq!(app.focus(), Focus::Args);
assert_eq!(
app.current_flag_values()[flag_index].1,
FlagValue::String("v2".to_string())
);
}
#[test]
fn test_edit_input_not_shared_between_args() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
for ch in "build".chars() {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
let result = app.arg_panel.handle_key(key);
if let EventResult::Action(FilterAction::Inner(action)) = result {
app.process_arg_action(action);
}
}
assert_eq!(app.arg_values[0].value, "build");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!app.is_editing());
app.set_arg_index(1);
app.start_editing();
assert_eq!(
app.arg_panel.editing_text(),
"",
"editing text should be initialized from the new arg's value, not the old one"
);
assert_eq!(app.arg_values[0].value, "build");
assert_eq!(app.arg_values[1].value, "");
}
#[test]
fn test_backspace_noop_on_non_flag_arg_focus() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(app.focus(), Focus::Commands);
}
#[test]
fn test_flatten_command_tree_ids() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(app.command_panel.tree_nodes());
assert_eq!(flat.len(), 15);
assert_eq!(flat[0].id, "init");
assert_eq!(flat[1].id, "config");
assert_eq!(flat[2].id, "config set");
assert_eq!(flat[3].id, "config get");
assert_eq!(flat[4].id, "config list");
assert_eq!(flat[5].id, "config remove");
assert_eq!(flat[6].id, "run");
assert_eq!(flat[7].id, "deploy");
assert_eq!(flat[8].id, "plugin");
assert_eq!(flat[9].id, "plugin install");
assert_eq!(flat[10].id, "plugin uninstall");
assert_eq!(flat[11].id, "plugin list");
assert_eq!(flat[12].id, "plugin update");
assert_eq!(flat[13].id, "version");
assert_eq!(flat[14].id, "help");
}
#[test]
fn test_flatten_command_tree_full_path() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(app.command_panel.tree_nodes());
assert_eq!(flat[0].full_path, "init");
assert_eq!(flat[1].full_path, "config");
assert_eq!(flat[2].full_path, "config set");
assert_eq!(flat[9].full_path, "plugin install");
}
#[test]
fn test_flatten_command_tree_depth() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(app.command_panel.tree_nodes());
assert_eq!(flat[0].depth, 0); assert_eq!(flat[1].depth, 0); assert_eq!(flat[2].depth, 1); assert_eq!(flat[6].depth, 0); assert_eq!(flat[9].depth, 1); }
#[test]
fn test_ctrl_r_executes_from_any_panel() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Commands);
let ctrl_r = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('r'),
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
app.set_focus(Focus::Flags);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
app.set_focus(Focus::Args);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
app.set_focus(Focus::Preview);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
}
#[test]
fn test_build_command_parts_basic() {
let app = App::new(sample_spec());
let parts = app.build_command_parts();
assert_eq!(parts, vec!["mycli", "init"]);
}
#[test]
fn test_build_command_parts_with_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let parts = app.build_command_parts();
assert_eq!(parts, vec!["mycli", "deploy"]);
}
#[test]
fn test_build_command_parts_with_flags_and_args() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let values = app.current_flag_values_mut();
if let Some((_, val)) = values.iter_mut().find(|(n, _)| n == "rollback") {
*val = FlagValue::Bool(true);
}
if let Some((_, val)) = values.iter_mut().find(|(n, _)| n == "tag") {
*val = FlagValue::String("v1.0".to_string());
}
app.arg_values[0].value = "prod".to_string();
let parts = app.build_command_parts();
assert!(parts.contains(&"mycli".to_string()));
assert!(parts.contains(&"deploy".to_string()));
assert!(parts.contains(&"--rollback".to_string()));
assert!(parts.contains(&"--tag".to_string()));
assert!(parts.contains(&"v1.0".to_string()));
assert!(parts.contains(&"prod".to_string()));
let tag_idx = parts.iter().position(|p| p == "--tag").unwrap();
assert_eq!(parts[tag_idx + 1], "v1.0");
}
#[test]
fn test_build_command_parts_splits_bin_with_spaces() {
let mut spec = sample_spec();
spec.bin = "mise run".to_string();
let app = App::new(spec);
let parts = app.build_command_parts();
assert_eq!(parts[0], "mise");
assert_eq!(parts[1], "run");
}
#[test]
fn test_build_command_parts_arg_not_quoted() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.arg_values[0].value = "hello world".to_string();
let parts = app.build_command_parts();
assert!(parts.contains(&"hello world".to_string()));
assert!(!parts.iter().any(|p| p.contains('"')));
}
#[test]
fn test_build_command_parts_count_flag() {
let mut app = App::new(sample_spec());
let root_key = String::new();
if let Some(flags) = app.flag_values.get_mut(&root_key) {
if let Some((_, val)) = flags.iter_mut().find(|(n, _)| n == "verbose") {
*val = FlagValue::Count(3);
}
}
let parts = app.build_command_parts();
assert!(parts.contains(&"-vvv".to_string()));
}
#[test]
fn test_app_mode_default_is_builder() {
let app = App::new(sample_spec());
assert_eq!(app.mode, AppMode::Builder);
assert!(!app.is_executing());
assert!(app.execution.is_none());
}
#[test]
fn test_start_and_close_execution() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(false));
let exit_status = Arc::new(Mutex::new(None));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli deploy".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited: exited.clone(),
exit_status,
};
app.start_execution(ExecutionComponent::new(state));
assert_eq!(app.mode, AppMode::Executing);
assert!(app.is_executing());
assert!(app.execution.is_some());
assert!(!app.execution.as_ref().unwrap().exited());
exited.store(true, Ordering::Relaxed);
assert!(app.execution.as_ref().unwrap().exited());
app.close_execution();
assert_eq!(app.mode, AppMode::Builder);
assert!(!app.is_executing());
assert!(app.execution.is_none());
}
#[test]
fn test_execution_exit_status() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(true));
let exit_status = Arc::new(Mutex::new(Some("0".to_string())));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli deploy".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(ExecutionComponent::new(state));
assert_eq!(app.execution.as_ref().unwrap().exit_status(), Some("0".to_string()));
}
#[test]
fn test_execution_key_closes_on_exit() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(true));
let exit_status = Arc::new(Mutex::new(Some("0".to_string())));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(ExecutionComponent::new(state));
assert!(app.is_executing());
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(esc);
assert_eq!(result, Action::None);
assert!(!app.is_executing());
assert_eq!(app.mode, AppMode::Builder);
}
#[test]
fn test_execution_key_enter_closes_on_exit() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(true));
let exit_status = Arc::new(Mutex::new(None));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(ExecutionComponent::new(state));
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::None);
assert!(!app.is_executing());
}
#[test]
fn test_preview_enter_returns_execute_action() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Preview);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::Execute);
}
#[test]
fn test_execution_key_does_not_close_while_running() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(false)); let exit_status = Arc::new(Mutex::new(None));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(ExecutionComponent::new(state));
let q = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(q);
assert_eq!(result, Action::None);
assert!(app.is_executing(), "Should still be executing");
}
#[test]
fn test_resize_pty() {
use portable_pty::{NativePtySystem, PtySize, PtySystem};
let mut app = App::new(sample_spec());
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("Failed to open PTY");
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let pty_master: Arc<Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>> =
Arc::new(Mutex::new(Some(pair.master)));
let state = ExecutionState {
command_display: "test command".to_string(),
parser,
pty_writer: Arc::new(Mutex::new(None)),
pty_master,
exited: Arc::new(AtomicBool::new(false)),
exit_status: Arc::new(Mutex::new(None)),
};
app.start_execution(ExecutionComponent::new(state));
app.resize_pty(40, 120);
assert!(app.execution.is_some());
}
#[test]
fn test_bracket_right_cycles_theme_forward() {
let mut app = App::new(sample_spec());
let initial_theme = app.theme_name;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(key);
assert_eq!(result, Action::None);
assert_ne!(app.theme_name, initial_theme, "Theme should have changed");
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let mut app2 = App::new(sample_spec());
let t_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::NONE,
);
app2.handle_key(t_key);
assert!(app2.is_theme_picking(), "T should open theme picker");
assert_eq!(app2.theme_name, ThemeName::Dracula, "Theme shouldn't change until navigated");
}
#[test]
fn test_bracket_left_cycles_theme_backward() {
let mut app = App::new(sample_spec());
let initial_theme = app.theme_name;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(key);
assert_eq!(result, Action::None);
assert_ne!(
app.theme_name, initial_theme,
"Theme should have changed backward"
);
}
#[test]
fn test_bracket_left_right_are_inverses() {
let mut app = App::new(sample_spec());
let initial_theme = app.theme_name;
let right = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(right);
let after_forward = app.theme_name;
assert_ne!(after_forward, initial_theme);
let left = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(left);
assert_eq!(app.theme_name, initial_theme, "[ should undo ]");
}
#[test]
fn test_enter_on_commands_navigates_into_child() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.navigate_to_command(&["config"]);
let config_idx = app.command_index();
assert_eq!(app.command_path, vec!["config"]);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::None);
assert!(
app.command_index() > config_idx,
"Enter should navigate into first child"
);
assert!(
app.command_path.len() >= 2,
"Should have navigated deeper: {:?}",
app.command_path
);
}
#[test]
fn test_enter_on_commands_same_as_right() {
let mut app_enter = App::new(sample_spec());
app_enter.set_focus(Focus::Commands);
app_enter.navigate_to_command(&["config"]);
let mut app_right = App::new(sample_spec());
app_right.set_focus(Focus::Commands);
app_right.navigate_to_command(&["config"]);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let right = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
);
app_enter.handle_key(enter);
app_right.handle_key(right);
assert_eq!(app_enter.command_index(), app_right.command_index());
assert_eq!(app_enter.command_path, app_right.command_path);
}
#[test]
fn test_enter_on_leaf_command_does_nothing() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.navigate_to_command(&["init"]); let init_idx = app.command_index();
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::None);
assert_eq!(
app.command_index(),
init_idx,
"Enter on leaf should not move"
);
}
#[test]
fn test_p_on_non_preview_does_nothing() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let p = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(p);
assert_eq!(
result,
Action::None,
"p should do nothing when not on Preview"
);
app.set_focus(Focus::Flags);
let result = app.handle_key(p);
assert_eq!(result, Action::None, "p should do nothing when on Flags");
}
#[test]
fn test_startup_sync_selects_first_command() {
let app = App::new(sample_spec());
assert_eq!(app.command_path, vec!["init"]);
let flags = app.visible_flags();
let names: Vec<&str> = flags.iter().map(|f| f.name.as_str()).collect();
assert!(
names.contains(&"template"),
"init should have template flag"
);
assert!(names.contains(&"force"), "init should have force flag");
assert!(
names.contains(&"verbose"),
"init should have global verbose"
);
assert!(!app.arg_values.is_empty(), "init has a <name> arg");
assert_eq!(app.arg_values[0].name, "name");
}
#[test]
fn test_global_flag_sync_from_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let flags = app.visible_flags();
let verbose_idx = flags.iter().position(|f| f.name == "verbose").unwrap();
app.set_flag_index(verbose_idx);
let space = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(space);
let deploy_flags = app.current_flag_values();
let verbose_val = deploy_flags.iter().find(|(n, _)| n == "verbose");
assert!(
matches!(verbose_val, Some((_, FlagValue::Count(1)))),
"verbose should be Count(1) at deploy level"
);
let root_flags = app.flag_values.get("").unwrap();
let root_verbose = root_flags.iter().find(|(n, _)| n == "verbose");
assert!(
matches!(root_verbose, Some((_, FlagValue::Count(1)))),
"verbose should be synced to root level"
);
let cmd = app.build_command();
assert!(
cmd.contains("-v"),
"global flag toggled from subcommand should appear in command: {cmd}"
);
}
#[test]
fn test_global_flag_sync_across_multiple_levels() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Flags);
let flags = app.visible_flags();
let verbose_idx = flags.iter().position(|f| f.name == "verbose").unwrap();
app.set_flag_index(verbose_idx);
let space = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(space);
app.handle_key(space);
app.handle_key(space);
app.navigate_to_command(&["deploy"]);
let deploy_flags = app.current_flag_values();
let verbose_val = deploy_flags.iter().find(|(n, _)| n == "verbose");
assert!(
matches!(verbose_val, Some((_, FlagValue::Count(3)))),
"verbose should be Count(3) at deploy level after sync"
);
let cmd = app.build_command();
assert!(cmd.contains("-vvv"), "command should contain -vvv: {cmd}");
}
#[test]
fn test_filtered_navigation_skips_non_matches() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "deploy".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let flat = flatten_command_tree(app.command_panel.tree_nodes());
let selected = &flat[app.command_index()];
assert_eq!(selected.name, "deploy", "should auto-select deploy");
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let selected = &flat[app.command_index()];
assert_eq!(
selected.name, "deploy",
"down should stay on deploy (only match)"
);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
let selected = &flat[app.command_index()];
assert_eq!(
selected.name, "deploy",
"up should stay on deploy (only match)"
);
}
#[test]
fn test_filtered_navigation_cycles_through_matches() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "'list".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let flat = flatten_command_tree(app.command_panel.tree_nodes());
let first_selected = app.command_index();
let first_name = flat[first_selected].name.clone();
assert_eq!(first_name, "list", "should auto-select first 'list'");
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let second_selected = app.command_index();
assert_ne!(
second_selected, first_selected,
"down should move to a different list match"
);
assert_eq!(flat[second_selected].name, "list");
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.command_index(), first_selected);
}
#[test]
fn test_filtered_flag_navigation() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "skip".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let flags = app.visible_flags();
let selected_flag = &flags[app.flag_index()];
assert_eq!(
selected_flag.name, "skip-tests",
"should auto-select skip-tests"
);
}
#[test]
fn test_match_scores_separate_name_and_help() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(app.command_panel.tree_nodes(), "verbose");
if let Some(init_scores) = scores.get("init") {
assert_eq!(
init_scores.name_score, 0,
"init name should not match 'verbose'"
);
}
}
#[test]
fn test_match_scores_help_only_match() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(app.command_panel.tree_nodes(), "project");
let init_scores = scores.get("init").expect("init should have scores");
assert_eq!(
init_scores.name_score, 0,
"init name should not match 'project'"
);
assert!(
init_scores.help_score > 0,
"init help should match 'project'"
);
assert!(
init_scores.overall() > 0,
"init should match overall via help"
);
}
#[test]
fn test_match_scores_name_only_match() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(app.command_panel.tree_nodes(), "cfg");
let config_scores = scores.get("config").expect("config should have scores");
assert!(
config_scores.name_score > 0,
"config name should match 'cfg'"
);
}
#[test]
fn test_flag_match_scores_separate_name_and_help() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.flag_panel.start_filtering();
app.flag_panel.set_filter_text("Docker");
let scores = app.compute_flag_match_scores();
if let Some(tag_scores) = scores.get("tag") {
assert_eq!(
tag_scores.name_score, 0,
"tag name should not match 'Docker'"
);
assert!(
tag_scores.help_score > 0,
"tag help 'Docker image tag' should match 'Docker'"
);
}
}
#[test]
fn test_args_filter_auto_selects_matching_arg() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config", "set"]);
app.set_focus(Focus::Args);
assert!(
app.arg_values.len() >= 2,
"config set should have at least 2 args"
);
app.set_arg_index(0);
assert_eq!(app.arg_index(), 0);
let first_arg_name = app.arg_values[0].name.clone();
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.is_filtering());
for c in "val".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let scores = app.compute_arg_match_scores();
let first_score = scores
.get(&first_arg_name)
.map(|s| s.overall())
.unwrap_or(0);
assert_eq!(
first_score, 0,
"'{}' should not match 'val'",
first_arg_name
);
let selected = app.arg_index();
let selected_name = &app.arg_values[selected].name;
let selected_score = scores
.get(selected_name)
.map(|s| s.overall())
.unwrap_or(0);
assert!(
selected_score > 0,
"selected arg '{}' should match 'val'",
selected_name
);
}
#[test]
fn test_enter_on_choice_flag_opens_select_box() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "template")
.unwrap();
app.set_flag_index(fidx);
assert!(!app.is_choosing(), "Should not be choosing before Enter");
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing(), "Enter on choice flag should open select box");
assert!(
app.flag_panel.is_choosing(),
"Flag panel should be choosing"
);
assert_eq!(app.flag_panel.choice_select_index(), Some(fidx));
}
#[test]
fn test_enter_on_choice_arg_opens_select_box() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
assert!(!app.is_choosing());
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing(), "Enter on choice arg should open select box");
assert!(app.arg_panel.is_choosing());
assert_eq!(app.arg_panel.choice_select_index(), Some(0));
}
#[test]
fn test_choice_select_enter_confirms() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(down);
app.handle_key(enter);
assert!(!app.is_choosing(), "Enter should close select box");
assert_eq!(app.arg_values[0].value, "staging", "Should have selected staging");
}
#[test]
fn test_choice_select_esc_cancels() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_choosing(), "Esc should close select box");
assert_eq!(app.arg_values[0].value, "", "Esc should not change value");
}
#[test]
fn test_choice_select_filter_narrows_choices() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
assert_eq!(app.arg_panel.filtered_choices().len(), 3);
let d_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(d_key);
let filtered = app.arg_panel.filtered_choices();
assert!(filtered.len() < 3, "Filter should narrow choices");
assert!(
filtered.iter().any(|(_, c)| c == "dev"),
"dev should match 'd'"
);
let e_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('e'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(e_key);
let v_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('v'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(v_key);
let filtered = app.arg_panel.filtered_choices();
assert_eq!(filtered.len(), 1, "Only 'dev' should match 'dev'");
assert_eq!(filtered[0].1, "dev");
app.handle_key(enter);
assert!(!app.is_choosing());
assert_eq!(app.arg_values[0].value, "dev");
}
#[test]
fn test_choice_select_preselects_current_value() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.arg_values[0].value = "staging".to_string();
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
assert_eq!(
app.arg_panel.selected_choice_index(),
Some(1),
"Should pre-select 'staging'"
);
}
#[test]
fn test_choice_select_flag_confirms_value() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "template")
.unwrap();
app.set_flag_index(fidx);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(down);
app.handle_key(enter);
assert!(!app.is_choosing());
let val = &app.current_flag_values()[fidx].1;
assert_eq!(
val,
&FlagValue::String("full".to_string()),
"Flag value should be 'full'"
);
}
#[test]
fn test_choice_select_mouse_click_closes() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_choosing(), "Mouse click should close select box");
}
#[test]
fn test_choice_select_navigation_bounds() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.arg_panel.selected_choice_index(), None);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.arg_panel.selected_choice_index(), Some(0));
app.handle_key(down);
app.handle_key(down);
assert_eq!(app.arg_panel.selected_choice_index(), Some(2));
app.handle_key(down);
assert_eq!(app.arg_panel.selected_choice_index(), Some(2));
}
#[test]
fn test_choice_select_set_focus_closes() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
app.set_focus(Focus::Flags);
assert!(!app.is_choosing(), "Switching focus should close select box");
}
#[test]
fn test_set_focus_from_completion_select_keeps_typed_text() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["plugin", "install"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
assert!(app.is_editing());
let a_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(a_key);
app.set_focus(Focus::Flags);
assert_eq!(app.focus(), Focus::Flags);
assert!(!app.is_choosing(), "Focus loss should close completion select");
assert!(!app.is_editing(), "Focus loss should finish completion editing");
assert_eq!(app.arg_values[0].value, "a", "Typed text should be preserved");
}
#[test]
fn test_t_opens_theme_picker() {
let mut app = App::new(sample_spec());
assert!(!app.is_theme_picking());
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
assert!(app.is_theme_picking());
assert_eq!(app.theme_picker.selected_index(), Some(0));
assert_eq!(app.theme_picker.original_theme(), Some(ThemeName::Dracula));
}
#[test]
fn test_theme_picker_esc_restores_original() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_enter_confirms() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
app.handle_key(down);
assert_eq!(app.theme_name, ThemeName::Nord);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::Nord);
}
#[test]
fn test_theme_picker_up_down_navigates() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.theme_picker.selected_index(), Some(1));
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.theme_picker.selected_index(), Some(0));
assert_eq!(app.theme_name, ThemeName::Dracula);
app.handle_key(up);
assert_eq!(
app.theme_picker.selected_index(),
Some(ThemeName::all().len() - 1)
);
assert_eq!(app.theme_name, ThemeName::Cyberpunk);
}
#[test]
fn test_theme_picker_jk_navigates() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let j = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(j);
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let k = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('k'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(k);
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_t_does_not_nest() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
assert!(app.is_theme_picking());
let t = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(t);
assert!(app.is_theme_picking());
assert_eq!(app.theme_picker.original_theme(), Some(ThemeName::Dracula));
}
#[test]
fn test_bracket_keys_still_cycle_without_picker() {
let mut app = App::new(sample_spec());
let rb = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(rb);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let lb = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(lb);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_mouse_click_outside_closes() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.open_theme_picker();
app.layout.theme_overlay_rect = Some(Rect::new(60, 5, 20, 17));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_theme_picking(), "Click outside should close picker");
assert_eq!(app.theme_name, ThemeName::Dracula, "Should restore original theme");
}
#[test]
fn test_theme_picker_mouse_click_on_indicator_opens() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.layout.theme_indicator_rect = Some(Rect::new(85, 23, 10, 1));
assert!(!app.is_theme_picking());
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 88,
row: 23,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(app.is_theme_picking(), "Click on theme indicator should open picker");
}
#[test]
fn test_theme_picker_down_wraps_at_end() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let all = ThemeName::all();
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.theme_picker.selected_index(), Some(all.len() - 1));
assert_eq!(app.theme_name, *all.last().unwrap());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.theme_picker.selected_index(), Some(0));
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_parse_completion_line_simple() {
let result = parse_completion_line("auth:Authentication plugin");
assert_eq!(
result,
Some(("auth".to_string(), "Authentication plugin".to_string()))
);
}
#[test]
fn test_parse_completion_line_escaped_colon() {
let result = parse_completion_line("host\\:port:A host:port pair");
assert_eq!(
result,
Some(("host:port".to_string(), "A host:port pair".to_string()))
);
}
#[test]
fn test_parse_completion_line_no_description() {
let result = parse_completion_line("simple-value");
assert_eq!(result, None);
}
#[test]
fn test_run_completion_simple() {
let result = App::run_completion("printf 'alpha\nbeta\ngamma\n'", false);
assert!(result.is_some());
let (choices, descs) = result.unwrap();
assert_eq!(choices, vec!["alpha", "beta", "gamma"]);
assert!(descs.iter().all(|d| d.is_none()));
}
#[test]
fn test_run_completion_with_descriptions() {
let result =
App::run_completion("printf 'auth:Auth plugin\ncache:Cache layer\n'", true);
assert!(result.is_some());
let (choices, descs) = result.unwrap();
assert_eq!(choices, vec!["auth", "cache"]);
assert_eq!(
descs,
vec![
Some("Auth plugin".to_string()),
Some("Cache layer".to_string())
]
);
}
#[test]
fn test_run_completion_failure() {
let result = App::run_completion("false", false);
assert!(result.is_none());
}
#[test]
fn test_run_completion_empty_output() {
let result = App::run_completion("echo ''", false);
assert!(result.is_none());
}
#[test]
fn test_find_completion_exists() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
let complete = app.find_completion("name");
assert!(complete.is_some(), "Should find completion for 'name'");
assert!(
complete.unwrap().run.is_some(),
"Completion should have a run command"
);
}
#[test]
fn test_find_completion_not_exists() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
let complete = app.find_completion("nonexistent");
assert!(complete.is_none());
}
#[test]
fn test_find_completion_with_descriptions_flag() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "update"]);
let complete = app.find_completion("name");
assert!(complete.is_some());
assert!(complete.unwrap().descriptions);
}
#[test]
fn test_completion_runs_each_time() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
let result = app.run_and_open_completion("name", "");
assert!(result.is_some());
let (choices, _) = result.unwrap();
assert!(!choices.is_empty());
let result2 = app.run_and_open_completion("name", "auth");
assert!(result2.is_some());
let (choices2, _) = result2.unwrap();
assert_eq!(choices2, choices, "Should get same results on re-run");
}
#[test]
fn test_enter_on_completion_arg_opens_select() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(
app.is_choosing(),
"Enter on arg with completion should open select"
);
assert!(
app.is_editing(),
"Editing should be active alongside choice select"
);
}
#[test]
fn test_esc_from_completion_select_keeps_text() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let a_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(a_key);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_choosing(), "Select should be closed");
assert!(!app.is_editing(), "Editing should be closed");
assert_eq!(app.arg_values[0].value, "a", "Typed text should be kept as value");
}
#[test]
fn test_completion_descriptions_in_select() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "update"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
assert!(app.arg_panel.is_choosing());
let filtered = app.arg_panel.filtered_choices();
assert!(!filtered.is_empty(), "Should have choices");
}
#[test]
fn test_completion_filter_matches_description() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "update"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
if !app.is_choosing() {
return;
}
for c in "redis".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let filtered = app.arg_panel.filtered_choices();
assert_eq!(filtered.len(), 1, "Only 'cache' should match 'redis' via its description");
assert_eq!(filtered[0].1, "cache");
}
#[test]
fn test_choice_select_typing_clears_selection() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.arg_panel.selected_choice_index(), Some(0));
let d_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(d_key);
assert_eq!(
app.arg_panel.selected_choice_index(),
None,
"Typing should clear selection"
);
}
#[test]
fn test_choice_select_enter_with_no_selection_accepts_text() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
for c in "custom".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
assert_eq!(app.arg_panel.selected_choice_index(), None);
app.handle_key(enter);
assert!(!app.is_choosing(), "Select should be closed");
assert!(!app.is_editing(), "Editing should be closed");
assert_eq!(app.arg_values[0].value, "custom", "Typed text should be the value");
}
#[test]
fn test_choice_select_up_from_first_deselects() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.arg_panel.selected_choice_index(), Some(0));
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(
app.arg_panel.selected_choice_index(),
None,
"Up from first item should deselect"
);
}
#[test]
fn test_choice_select_reopen_after_selection() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(enter); assert!(!app.is_choosing());
assert_eq!(app.arg_values[0].value, "dev");
app.handle_key(enter);
assert!(app.is_choosing(), "Should be able to reopen select after choosing");
assert_eq!(
app.arg_panel.selected_choice_index(),
Some(0),
"Should pre-select current value"
);
}
#[test]
fn test_choice_select_shows_all_choices_on_reopen() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
app.handle_key(down);
app.handle_key(enter);
assert_eq!(app.arg_values[0].value, "dev");
app.handle_key(enter);
assert!(app.is_choosing());
assert_eq!(app.arg_panel.filtered_choices().len(), 3);
let s_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('s'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(s_key);
assert!(app.arg_panel.filtered_choices().len() < 3);
}
#[test]
fn test_mouse_click_on_choice_select_overlay() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let overlay_rect = ratatui::layout::Rect::new(30, 5, 20, 4);
app.layout.arg_overlay_rect = Some(overlay_rect);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 35,
row: 7,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_choosing(), "Click should close choice select");
assert_eq!(
app.arg_values[0].value, "prod",
"Clicking on 3rd item should select 'prod'"
);
}
#[test]
fn test_mouse_click_on_scrolled_choice_select() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["plugin", "install"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing(), "Should open choice select for completions");
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
for _ in 0..13 {
app.handle_key(down);
}
let overlay_rect = ratatui::layout::Rect::new(30, 5, 25, 11);
app.layout.arg_overlay_rect = Some(overlay_rect);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 35,
row: 10,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_choosing(), "Click should close choice select");
let value = &app.arg_values[0].value;
assert!(
!value.is_empty(),
"Click on scrolled choice should select a value"
);
}
#[test]
fn test_mouse_click_on_completion_clears_editing() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.navigate_to_command(&["plugin", "install"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing(), "Should open choice select for completions");
assert!(
app.arg_panel.is_editing(),
"Completion select opens with editing=true"
);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(down); app.handle_key(down);
let overlay_rect = ratatui::layout::Rect::new(30, 5, 25, 11);
app.layout.arg_overlay_rect = Some(overlay_rect);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 35,
row: 7,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_choosing(), "Click should close choice select");
assert!(
!app.arg_panel.is_editing(),
"Click-select on completion should clear editing state"
);
assert!(
!app.arg_values[0].value.is_empty(),
"Should have selected a value"
);
}
}