Skip to main content

clap_tui/
app.rs

1use std::error::Error as StdError;
2use std::ffi::OsString;
3use std::marker::PhantomData;
4use std::time::Duration;
5
6use clap::{Command, CommandFactory, Parser};
7use ratatui::Frame;
8
9use crate::config::TuiConfig;
10use crate::controller;
11use crate::error::TuiError;
12use crate::frame_snapshot::FrameSnapshot;
13use crate::input::AppState;
14use crate::runtime::{AppEvent, CrosstermRuntime, Runtime};
15use crate::ui;
16use crate::update::{self, Effect};
17
18/// Build and run a TUI from a hand-built [`clap::Command`].
19///
20/// Use [`crate::Tui`] for derive-based CLIs that want typed results.
21/// Use `TuiApp` when you are already building a [`clap::Command`] by hand, or when you want
22/// the untyped surface that returns argv or `ArgMatches`.
23pub struct TuiApp<R: Runtime = CrosstermRuntime> {
24    command: Command,
25    config: TuiConfig,
26    runtime: R,
27}
28
29/// Direct typed TUI execution for a derive-based `clap` parser.
30///
31/// `Tui::<T>::run()` is the primary explicit integration surface for applications that define
32/// their own `Command::Tui` dispatch branch and want the selected command value back as `T`.
33pub struct Tui<T, R: Runtime = CrosstermRuntime> {
34    inner: TuiApp<R>,
35    _parser: PhantomData<fn() -> T>,
36}
37
38impl TuiApp<CrosstermRuntime> {
39    /// Create a TUI from a hand-built [`clap::Command`].
40    #[must_use]
41    pub fn from_command(command: Command) -> Self {
42        Self {
43            command,
44            config: TuiConfig::default(),
45            runtime: CrosstermRuntime,
46        }
47    }
48}
49
50impl<R: Runtime> TuiApp<R> {
51    /// Apply configuration before the TUI starts.
52    #[must_use]
53    pub fn with_config(mut self, config: TuiConfig) -> Self {
54        self.config = config;
55        self
56    }
57
58    /// Replace the default runtime.
59    #[must_use]
60    pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> TuiApp<NR> {
61        TuiApp {
62            command: self.command,
63            config: self.config,
64            runtime,
65        }
66    }
67
68    /// Run the TUI and return the selected canonical argv.
69    ///
70    /// The returned argv is the executable token sequence. Preview and clipboard text are
71    /// rendered separately from these tokens, using POSIX shell quoting on Unix platforms and
72    /// `PowerShell` quoting on Windows.
73    ///
74    /// Returns `Ok(Some(argv))` when the user runs a valid command and `Ok(None)` when the
75    /// user exits without running. Validation stays inside the TUI flow, so invalid form
76    /// state is surfaced in-app rather than returned as a clap error from this method.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error when terminal setup or event handling fails.
81    pub fn run(self) -> Result<Option<Vec<OsString>>, TuiError> {
82        match self.run_inner() {
83            Ok(argv) => Ok(Some(argv)),
84            Err(TuiError::Cancelled) => Ok(None),
85            Err(err) => Err(err),
86        }
87    }
88
89    /// Run the TUI and execute a custom handler with `ArgMatches`.
90    ///
91    /// Returns `Ok(())` when the user exits without running. When the user does run, this
92    /// method reparses the selected argv with the original [`clap::Command`] before calling
93    /// the handler.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error when terminal setup or event handling fails, when reparsing the
98    /// selected argv with clap fails, or when the runner callback fails.
99    pub fn run_with_matches<F, E>(self, runner: F) -> Result<(), TuiError>
100    where
101        F: FnOnce(clap::ArgMatches) -> Result<(), E>,
102        E: StdError + Send + Sync + 'static,
103    {
104        let command = self.command.clone();
105        let Some(argv) = self.run()? else {
106            return Ok(());
107        };
108        run_matches_handler(command, argv, runner)
109    }
110
111    fn run_inner(self) -> Result<Vec<OsString>, TuiError> {
112        let Self {
113            command,
114            config,
115            mut runtime,
116        } = self;
117        let terminal = runtime.init_terminal()?;
118        let mut session = TerminalSession::new(&mut runtime, terminal);
119        event_loop(&command, &config, &mut session)
120    }
121}
122
123impl<T> Tui<T, CrosstermRuntime>
124where
125    T: Parser + CommandFactory,
126{
127    /// Create a typed app from a derive-based parser.
128    #[must_use]
129    pub fn new() -> Self {
130        Self {
131            inner: TuiApp::from_command(T::command()),
132            _parser: PhantomData,
133        }
134    }
135}
136
137impl<T> Default for Tui<T, CrosstermRuntime>
138where
139    T: Parser + CommandFactory,
140{
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl<T, R: Runtime> Tui<T, R>
147where
148    T: Parser + CommandFactory,
149{
150    /// Hide a matching top-level entrypoint subcommand from the rendered TUI.
151    ///
152    /// This only changes the command tree used to build the TUI. Typed reparsing after submit
153    /// still uses the original clap parser type `T`.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`TuiError::UnknownEntrypoint`] when `name` does not match a canonical top-level
158    /// subcommand name on the render command.
159    pub fn hide_entrypoint(mut self, name: impl Into<String>) -> Result<Self, TuiError> {
160        let name = name.into();
161        hide_top_level_entrypoint(&mut self.inner.command, &name)?;
162        Ok(self)
163    }
164
165    /// Apply configuration before the TUI starts.
166    #[must_use]
167    pub fn with_config(self, config: TuiConfig) -> Self {
168        Self {
169            inner: self.inner.with_config(config),
170            _parser: PhantomData,
171        }
172    }
173
174    /// Replace the default runtime.
175    #[must_use]
176    pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> Tui<T, NR> {
177        Tui {
178            inner: self.inner.with_runtime(runtime),
179            _parser: PhantomData,
180        }
181    }
182
183    /// Run the TUI and parse the submitted command into the bound parser type.
184    ///
185    /// Returns `Ok(Some(parsed))` when the user submits a valid command and `Ok(None)` when the
186    /// user exits without submitting. If clap reparsing produces help, version, or parse-display
187    /// behavior, this method returns `Err(TuiError::Clap(_))` without printing automatically or
188    /// calling `std::process::exit`.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error when terminal setup, rendering, runtime integration, or clap reparsing
193    /// fails.
194    pub fn run(self) -> Result<Option<T>, TuiError> {
195        let Some(argv) = self.inner.run()? else {
196            return Ok(None);
197        };
198        parse_result(T::try_parse_from(argv)).map(Some)
199    }
200
201    /// Drop down to the untyped app surface when only argv or `ArgMatches` execution is needed.
202    #[must_use]
203    pub fn into_untyped(self) -> TuiApp<R> {
204        self.inner
205    }
206}
207
208fn parse_result<T>(result: Result<T, clap::Error>) -> Result<T, TuiError> {
209    result.map_err(TuiError::from)
210}
211
212fn hide_top_level_entrypoint(command: &mut Command, name: &str) -> Result<(), TuiError> {
213    let candidates = top_level_entrypoint_candidates(command);
214
215    for subcommand in command.get_subcommands_mut() {
216        if subcommand.get_name() == name {
217            *subcommand = subcommand.clone().hide(true);
218            return Ok(());
219        }
220    }
221
222    Err(TuiError::UnknownEntrypoint {
223        name: name.to_string(),
224        candidates,
225    })
226}
227
228fn top_level_entrypoint_candidates(command: &Command) -> Vec<String> {
229    command
230        .get_subcommands()
231        .map(|subcommand| subcommand.get_name().to_string())
232        .collect()
233}
234
235fn run_matches_handler<F, E>(
236    command: Command,
237    argv: Vec<OsString>,
238    runner: F,
239) -> Result<(), TuiError>
240where
241    F: FnOnce(clap::ArgMatches) -> Result<(), E>,
242    E: StdError + Send + Sync + 'static,
243{
244    let matches = parse_result(command.try_get_matches_from(argv))?;
245    runner(matches).map_err(|err| TuiError::Runner(Box::new(err)))
246}
247
248fn event_loop<R: Runtime>(
249    command: &Command,
250    config: &TuiConfig,
251    session: &mut TerminalSession<'_, R>,
252) -> Result<Vec<OsString>, TuiError> {
253    let mut observer = NoopDrawObserver;
254    event_loop_with_observer(command, config, session, &mut observer)
255}
256
257fn event_loop_with_observer<R, O>(
258    command: &Command,
259    config: &TuiConfig,
260    session: &mut TerminalSession<'_, R>,
261    observer: &mut O,
262) -> Result<Vec<OsString>, TuiError>
263where
264    R: Runtime,
265    O: DrawObserver<R::Backend>,
266{
267    let mut state = AppState::from_command(command);
268    if let Some(start) = config.start_command.clone() {
269        controller::navigation::apply_start_command(&mut state, &start);
270    }
271    let mut frame_snapshot = FrameSnapshot::default();
272    let mut needs_redraw = true;
273
274    loop {
275        if needs_redraw {
276            session.draw(|frame| {
277                frame_snapshot = render_frame(frame, &mut state, config);
278            })?;
279            observer.observe(session.backend(), &frame_snapshot)?;
280            needs_redraw = false;
281        }
282
283        if !session.poll_event(redraw_timeout(&state))? {
284            needs_redraw |= clear_expired_toast_and_request_redraw(&mut state);
285            continue;
286        }
287
288        match handle_app_event(
289            &session.read_event()?,
290            &mut state,
291            &frame_snapshot,
292            config,
293            session,
294        ) {
295            EventOutcome::Continue {
296                needs_redraw: redraw,
297            } => {
298                needs_redraw |= redraw;
299            }
300            EventOutcome::Exit => return Err(TuiError::Cancelled),
301            EventOutcome::Run(argv) => return Ok(argv),
302        }
303    }
304}
305
306fn redraw_timeout(state: &AppState) -> Duration {
307    state
308        .notifications
309        .toast
310        .as_ref()
311        .map_or(Duration::from_secs(60 * 60), |toast| {
312            toast
313                .expires_at
314                .saturating_duration_since(std::time::Instant::now())
315        })
316}
317
318fn clear_expired_toast_and_request_redraw(state: &mut AppState) -> bool {
319    let had_toast = state.notifications.toast.is_some();
320    state.notifications.clear_expired_toast();
321    had_toast && state.notifications.toast.is_none()
322}
323
324fn handle_effect<R: Runtime>(
325    effect: Effect,
326    state: &mut AppState,
327    session: &mut TerminalSession<'_, R>,
328) -> ActionOutcome {
329    match effect {
330        Effect::None => ActionOutcome::Continue,
331        Effect::Run(argv) => {
332            let validation = state.derived_validation();
333            if validation.is_valid {
334                ActionOutcome::Run(argv)
335            } else {
336                state.notifications.show_toast(
337                    validation
338                        .summary
339                        .unwrap_or_else(|| "Command is invalid".to_string()),
340                    Duration::from_secs(3),
341                    true,
342                );
343                ActionOutcome::Continue
344            }
345        }
346        Effect::CopyToClipboard(command) => {
347            let result = session.copy_to_clipboard(&command);
348            match result {
349                Ok(()) => {
350                    state.notifications.show_toast(
351                        "Copied command to clipboard",
352                        Duration::from_secs(2),
353                        false,
354                    );
355                }
356                Err(_) => state.notifications.show_toast(
357                    "Clipboard unavailable",
358                    Duration::from_secs(2),
359                    true,
360                ),
361            }
362            ActionOutcome::Continue
363        }
364        Effect::Exit => ActionOutcome::Exit,
365    }
366}
367
368fn handle_app_event<R: Runtime>(
369    event: &AppEvent,
370    state: &mut AppState,
371    frame_snapshot: &FrameSnapshot,
372    config: &TuiConfig,
373    session: &mut TerminalSession<'_, R>,
374) -> EventOutcome {
375    let mut needs_redraw = clear_expired_toast_and_request_redraw(state);
376
377    match event {
378        AppEvent::Key(key) => {
379            if let Some(action) = controller::handle_key_event(*key, state, frame_snapshot, config)
380            {
381                let effect = update::apply_action(&action, state, frame_snapshot);
382                match handle_effect(effect, state, session) {
383                    ActionOutcome::Continue => {
384                        needs_redraw |= true;
385                        needs_redraw |= clear_expired_toast_and_request_redraw(state);
386                        EventOutcome::Continue { needs_redraw }
387                    }
388                    ActionOutcome::Exit => EventOutcome::Exit,
389                    ActionOutcome::Run(argv) => EventOutcome::Run(argv),
390                }
391            } else {
392                needs_redraw |= clear_expired_toast_and_request_redraw(state);
393                EventOutcome::Continue { needs_redraw }
394            }
395        }
396        AppEvent::Mouse(mouse) => {
397            if let Some(action) =
398                controller::handle_mouse_event(*mouse, state, frame_snapshot, config)
399            {
400                let effect = update::apply_action(&action, state, frame_snapshot);
401                match handle_effect(effect, state, session) {
402                    ActionOutcome::Continue => {
403                        needs_redraw |= true;
404                        needs_redraw |= clear_expired_toast_and_request_redraw(state);
405                        EventOutcome::Continue { needs_redraw }
406                    }
407                    ActionOutcome::Exit => EventOutcome::Exit,
408                    ActionOutcome::Run(argv) => EventOutcome::Run(argv),
409                }
410            } else {
411                needs_redraw |= clear_expired_toast_and_request_redraw(state);
412                EventOutcome::Continue { needs_redraw }
413            }
414        }
415        AppEvent::Resize { .. } => {
416            needs_redraw = true;
417            needs_redraw |= clear_expired_toast_and_request_redraw(state);
418            EventOutcome::Continue { needs_redraw }
419        }
420        AppEvent::Paste(text) => {
421            let effect =
422                update::apply_action(&update::Action::Paste(text.clone()), state, frame_snapshot);
423            match handle_effect(effect, state, session) {
424                ActionOutcome::Continue => {
425                    needs_redraw |= true;
426                    needs_redraw |= clear_expired_toast_and_request_redraw(state);
427                    EventOutcome::Continue { needs_redraw }
428                }
429                ActionOutcome::Exit => EventOutcome::Exit,
430                ActionOutcome::Run(argv) => EventOutcome::Run(argv),
431            }
432        }
433        AppEvent::FocusGained | AppEvent::FocusLost | AppEvent::Unsupported => {
434            needs_redraw |= clear_expired_toast_and_request_redraw(state);
435            EventOutcome::Continue { needs_redraw }
436        }
437    }
438}
439
440fn render_frame(frame: &mut Frame<'_>, state: &mut AppState, config: &TuiConfig) -> FrameSnapshot {
441    ui::render(frame, state, config)
442}
443
444trait DrawObserver<B: ratatui::backend::Backend> {
445    fn observe(&mut self, _backend: &B, _frame_snapshot: &FrameSnapshot) -> Result<(), TuiError> {
446        Ok(())
447    }
448}
449
450struct NoopDrawObserver;
451
452impl<B: ratatui::backend::Backend> DrawObserver<B> for NoopDrawObserver {}
453
454enum ActionOutcome {
455    Continue,
456    Exit,
457    Run(Vec<OsString>),
458}
459
460enum EventOutcome {
461    Continue { needs_redraw: bool },
462    Exit,
463    Run(Vec<OsString>),
464}
465
466struct TerminalSession<'a, R: Runtime> {
467    runtime: &'a mut R,
468    terminal: Option<ratatui::Terminal<R::Backend>>,
469}
470
471impl<'a, R: Runtime> TerminalSession<'a, R> {
472    fn new(runtime: &'a mut R, terminal: ratatui::Terminal<R::Backend>) -> Self {
473        Self {
474            runtime,
475            terminal: Some(terminal),
476        }
477    }
478
479    fn draw<F>(&mut self, draw_fn: F) -> Result<(), TuiError>
480    where
481        F: FnOnce(&mut Frame<'_>),
482    {
483        self.terminal
484            .as_mut()
485            .expect("terminal session is active")
486            .draw(draw_fn)
487            .map(|_| ())
488            .map_err(|e| TuiError::Terminal(std::io::Error::other(e.to_string())))
489    }
490
491    fn backend(&self) -> &R::Backend {
492        self.terminal
493            .as_ref()
494            .expect("terminal session is active")
495            .backend()
496    }
497
498    fn poll_event(&mut self, timeout: Duration) -> Result<bool, TuiError> {
499        self.runtime.poll_event(timeout)
500    }
501
502    fn read_event(&mut self) -> Result<AppEvent, TuiError> {
503        self.runtime.read_event()
504    }
505
506    fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
507        self.runtime.copy_to_clipboard(text)
508    }
509}
510
511impl<R: Runtime> Drop for TerminalSession<'_, R> {
512    fn drop(&mut self) {
513        if let Some(mut terminal) = self.terminal.take() {
514            self.runtime.restore_terminal(&mut terminal);
515        }
516    }
517}
518
519#[cfg(test)]
520mod scripted;
521#[cfg(test)]
522mod scripted_tests;
523
524#[cfg(test)]
525mod tests {
526    use std::collections::VecDeque;
527    use std::ffi::OsString;
528    use std::time::{Duration, Instant};
529
530    use clap::error::ErrorKind;
531    use clap::{Arg, ArgAction, Command, Parser};
532    use ratatui::Terminal;
533    use ratatui::backend::TestBackend;
534
535    use super::{
536        ActionOutcome, EventOutcome, TerminalSession, event_loop, handle_app_event, handle_effect,
537        redraw_timeout,
538    };
539    use crate::frame_snapshot::FrameSnapshot;
540    use crate::input::{AppState, Toast};
541    use crate::pipeline;
542    use crate::runtime::{AppEvent, AppKeyCode, AppKeyEvent, AppKeyModifiers, Runtime};
543    use crate::spec::CommandSpec;
544    use crate::update::Effect;
545    use crate::{TuiConfig, TuiError};
546
547    fn os_vec(values: &[&str]) -> Vec<OsString> {
548        values.iter().map(OsString::from).collect()
549    }
550
551    #[derive(Debug)]
552    struct TestRuntime {
553        events: VecDeque<AppEvent>,
554        clipboard_result: Result<(), String>,
555        copied_text: Option<String>,
556    }
557
558    impl TestRuntime {
559        fn with_events(events: impl IntoIterator<Item = AppEvent>) -> Self {
560            Self {
561                events: events.into_iter().collect(),
562                clipboard_result: Ok(()),
563                copied_text: None,
564            }
565        }
566    }
567
568    impl Runtime for TestRuntime {
569        type Backend = TestBackend;
570
571        fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
572            let Ok(terminal) = Terminal::new(TestBackend::new(80, 24));
573            Ok(terminal)
574        }
575
576        fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
577
578        fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
579            Ok(!self.events.is_empty())
580        }
581
582        fn read_event(&mut self) -> Result<AppEvent, TuiError> {
583            Ok(self.events.pop_front().expect("queued event"))
584        }
585
586        fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
587            self.copied_text = Some(text.to_string());
588            self.clipboard_result.clone()
589        }
590    }
591
592    fn terminal_session(runtime: &mut TestRuntime) -> TerminalSession<'_, TestRuntime> {
593        let terminal = runtime.init_terminal().expect("terminal");
594        TerminalSession::new(runtime, terminal)
595    }
596
597    fn app_state() -> AppState {
598        AppState::new(CommandSpec::from_command(&Command::new("tool")))
599    }
600
601    fn app_state_from_command(command: &Command) -> AppState {
602        AppState::from_command(command)
603    }
604
605    #[test]
606    fn event_loop_returns_cancelled_on_ctrl_c() {
607        let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
608            AppKeyCode::Char('c'),
609            AppKeyModifiers {
610                control: true,
611                alt: false,
612                shift: false,
613            },
614        ))]);
615        let terminal = runtime.init_terminal().expect("terminal");
616        let mut session = TerminalSession::new(&mut runtime, terminal);
617
618        let result = event_loop(&Command::new("tool"), &TuiConfig::default(), &mut session);
619
620        assert!(matches!(result, Err(TuiError::Cancelled)));
621    }
622
623    #[test]
624    fn event_loop_returns_built_argv_on_ctrl_enter() {
625        let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
626            AppKeyCode::Enter,
627            AppKeyModifiers {
628                control: true,
629                alt: false,
630                shift: false,
631            },
632        ))]);
633        let terminal = runtime.init_terminal().expect("terminal");
634        let mut session = TerminalSession::new(&mut runtime, terminal);
635        let command = Command::new("tool").arg(
636            Arg::new("verbose")
637                .long("verbose")
638                .action(ArgAction::SetTrue),
639        );
640
641        let result = event_loop(&command, &TuiConfig::default(), &mut session);
642
643        assert_eq!(result.expect("run result"), os_vec(&["tool"]));
644    }
645
646    #[test]
647    fn event_loop_returns_built_argv_on_ctrl_r() {
648        let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
649            AppKeyCode::Char('r'),
650            AppKeyModifiers {
651                control: true,
652                alt: false,
653                shift: false,
654            },
655        ))]);
656        let terminal = runtime.init_terminal().expect("terminal");
657        let mut session = TerminalSession::new(&mut runtime, terminal);
658        let command = Command::new("tool").arg(
659            Arg::new("verbose")
660                .long("verbose")
661                .action(ArgAction::SetTrue),
662        );
663
664        let result = event_loop(&command, &TuiConfig::default(), &mut session);
665
666        assert_eq!(result.expect("run result"), os_vec(&["tool"]));
667    }
668
669    #[test]
670    fn copy_effect_success_shows_success_toast() {
671        let mut runtime = TestRuntime::with_events([]);
672        let mut session = terminal_session(&mut runtime);
673        let mut state = app_state();
674
675        let outcome = handle_effect(
676            Effect::CopyToClipboard("tool --verbose".to_string()),
677            &mut state,
678            &mut session,
679        );
680        drop(session);
681
682        assert!(matches!(outcome, ActionOutcome::Continue));
683        assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
684        let toast = state.notifications.toast.as_ref().expect("toast");
685        assert_eq!(toast.message, "Copied command to clipboard");
686        assert!(!toast.is_error);
687    }
688
689    #[test]
690    fn copy_effect_failure_shows_error_toast() {
691        let mut runtime = TestRuntime::with_events([]);
692        runtime.clipboard_result = Err("clipboard unavailable".to_string());
693        let mut session = terminal_session(&mut runtime);
694        let mut state = app_state();
695
696        let outcome = handle_effect(
697            Effect::CopyToClipboard("tool --verbose".to_string()),
698            &mut state,
699            &mut session,
700        );
701        drop(session);
702
703        assert!(matches!(outcome, ActionOutcome::Continue));
704        assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
705        let toast = state.notifications.toast.as_ref().expect("toast");
706        assert_eq!(toast.message, "Clipboard unavailable");
707        assert!(toast.is_error);
708    }
709
710    #[test]
711    fn invalid_run_effect_is_blocked_and_surfaces_validation_summary() {
712        let command = Command::new("tool").arg(
713            Arg::new("name")
714                .long("name")
715                .required(true)
716                .action(ArgAction::Set),
717        );
718        let mut runtime = TestRuntime::with_events([]);
719        let mut session = terminal_session(&mut runtime);
720        let mut state = app_state_from_command(&command);
721
722        let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
723
724        assert!(matches!(outcome, ActionOutcome::Continue));
725        let toast = state.notifications.toast.as_ref().expect("toast");
726        assert!(toast.is_error);
727        assert_eq!(toast.message, "Missing required argument: --name");
728    }
729
730    #[test]
731    fn run_uses_cached_validation_state_without_revalidating() {
732        pipeline::reset_validation_call_count();
733
734        let command = Command::new("tool").arg(
735            Arg::new("name")
736                .long("name")
737                .required(true)
738                .action(ArgAction::Set),
739        );
740        let mut runtime = TestRuntime::with_events([]);
741        let mut session = terminal_session(&mut runtime);
742        let mut state = app_state_from_command(&command);
743        let argv = state.authoritative_argv();
744
745        assert_eq!(pipeline::validation_call_count(), 1);
746
747        let outcome = handle_effect(Effect::Run(argv), &mut state, &mut session);
748
749        assert!(matches!(outcome, ActionOutcome::Continue));
750        assert_eq!(pipeline::validation_call_count(), 1);
751        let toast = state.notifications.toast.as_ref().expect("toast");
752        assert!(toast.is_error);
753        assert_eq!(toast.message, "Missing required argument: --name");
754    }
755
756    #[test]
757    fn run_matches_handler_returns_clap_display_errors_without_running_callback() {
758        let mut called = false;
759
760        let result = super::run_matches_handler(
761            Command::new("tool").version("1.2.3"),
762            os_vec(&["tool", "--version"]),
763            |_matches| {
764                called = true;
765                Ok::<_, std::io::Error>(())
766            },
767        );
768
769        let error = result.expect_err("version display should be returned");
770        assert!(
771            matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
772        );
773        assert!(!called);
774    }
775
776    #[test]
777    fn tui_run_returns_typed_value_on_submit() {
778        #[derive(Debug, clap::Parser, PartialEq, Eq)]
779        #[command(name = "tool")]
780        struct Cli {
781            #[arg(long, default_value = "world")]
782            name: String,
783        }
784
785        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
786            AppKeyCode::Char('r'),
787            AppKeyModifiers {
788                control: true,
789                ..AppKeyModifiers::default()
790            },
791        ))]);
792
793        let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
794
795        assert_eq!(
796            result.expect("typed run should succeed"),
797            Some(Cli {
798                name: "world".to_string()
799            })
800        );
801    }
802
803    #[test]
804    fn hide_entrypoint_hides_a_matching_top_level_subcommand_from_the_render_tree() {
805        #[derive(Debug, clap::Parser, PartialEq, Eq)]
806        #[command(name = "tool")]
807        enum Cli {
808            Tui,
809            Hello {
810                #[arg(long, default_value = "world")]
811                name: String,
812            },
813        }
814
815        let app = super::Tui::<Cli>::new()
816            .hide_entrypoint("tui")
817            .expect("top-level entrypoint should exist");
818        let spec = crate::spec::CommandSpec::from_command(&app.inner.command);
819
820        assert!(
821            spec.subcommands
822                .iter()
823                .all(|subcommand| subcommand.name != "tui")
824        );
825        assert!(
826            app.inner
827                .command
828                .get_subcommands()
829                .all(|subcommand| subcommand.get_name() != "tui" || subcommand.is_hide_set())
830        );
831    }
832
833    #[test]
834    fn hide_entrypoint_returns_unknown_entrypoint_with_candidates() {
835        #[derive(Debug, clap::Parser, PartialEq, Eq)]
836        #[command(name = "tool")]
837        enum Cli {
838            Tui,
839            Build,
840            Serve,
841        }
842
843        let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("missing") else {
844            panic!("missing entrypoint should fail");
845        };
846
847        assert!(matches!(
848            error,
849            TuiError::UnknownEntrypoint { ref name, ref candidates }
850                if name == "missing" && candidates == &vec![
851                    "tui".to_string(),
852                    "build".to_string(),
853                    "serve".to_string()
854                ]
855        ));
856    }
857
858    #[test]
859    fn hide_entrypoint_does_not_match_aliases() {
860        #[derive(Debug, clap::Parser, PartialEq, Eq)]
861        #[command(name = "tool")]
862        enum Cli {
863            #[command(visible_alias = "interactive")]
864            Tui,
865            Build,
866        }
867
868        let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("interactive") else {
869            panic!("aliases should not match");
870        };
871
872        assert!(matches!(
873            error,
874            TuiError::UnknownEntrypoint { ref name, ref candidates }
875                if name == "interactive" && candidates == &vec![
876                    "tui".to_string(),
877                    "build".to_string()
878                ]
879        ));
880    }
881
882    #[test]
883    fn hide_entrypoint_can_be_applied_twice() {
884        #[derive(Debug, clap::Parser, PartialEq, Eq)]
885        #[command(name = "tool")]
886        enum Cli {
887            Tui,
888            Build,
889        }
890
891        let app = super::Tui::<Cli>::new()
892            .hide_entrypoint("tui")
893            .expect("first hide should succeed")
894            .hide_entrypoint("tui")
895            .expect("second hide should also succeed");
896
897        let hidden = app
898            .inner
899            .command
900            .get_subcommands()
901            .find(|subcommand| subcommand.get_name() == "tui")
902            .expect("tui subcommand should still exist");
903
904        assert!(hidden.is_hide_set());
905    }
906
907    #[test]
908    fn tui_run_returns_none_on_cancel() {
909        #[derive(Debug, clap::Parser, PartialEq, Eq)]
910        #[command(name = "tool", version = "1.2.3")]
911        struct Cli;
912
913        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
914            AppKeyCode::Char('c'),
915            AppKeyModifiers {
916                control: true,
917                ..AppKeyModifiers::default()
918            },
919        ))]);
920
921        let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
922
923        assert_eq!(result.expect("cancel should map to None"), None);
924    }
925
926    #[test]
927    fn tui_run_returns_clap_display_errors_without_printing() {
928        #[derive(Debug, clap::Parser, PartialEq, Eq)]
929        #[command(name = "tool", version = "1.2.3")]
930        struct Cli;
931
932        let error = super::parse_result(Cli::try_parse_from(os_vec(&["tool", "--version"])))
933            .expect_err("version display should be returned");
934        assert!(
935            matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
936        );
937    }
938
939    #[test]
940    fn tui_run_reparses_selected_command_after_hiding_entrypoint() {
941        #[derive(Debug, clap::Parser, PartialEq, Eq)]
942        #[command(name = "tool")]
943        enum Cli {
944            Tui,
945            Hello {
946                #[arg(long, default_value = "world")]
947                name: String,
948            },
949        }
950
951        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
952            AppKeyCode::Char('r'),
953            AppKeyModifiers {
954                control: true,
955                ..AppKeyModifiers::default()
956            },
957        ))]);
958
959        let config = TuiConfig {
960            start_command: Some("hello".to_string()),
961            ..TuiConfig::default()
962        };
963
964        let result = super::Tui::<Cli, _>::new()
965            .hide_entrypoint("tui")
966            .expect("entrypoint should exist")
967            .with_config(config)
968            .with_runtime(runtime)
969            .run();
970
971        assert_eq!(
972            result.expect("typed run should succeed"),
973            Some(Cli::Hello {
974                name: "world".to_string()
975            })
976        );
977    }
978
979    #[test]
980    fn tui_run_propagates_runtime_failures() {
981        #[derive(Debug, clap::Parser, PartialEq, Eq)]
982        #[command(name = "tool")]
983        struct Cli;
984
985        #[derive(Debug)]
986        struct FailingRuntime;
987
988        impl Runtime for FailingRuntime {
989            type Backend = TestBackend;
990
991            fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
992                Err(std::io::Error::other("boom").into())
993            }
994
995            fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
996
997            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
998                unreachable!("terminal initialization should fail first")
999            }
1000
1001            fn read_event(&mut self) -> Result<AppEvent, TuiError> {
1002                unreachable!("terminal initialization should fail first")
1003            }
1004
1005            fn copy_to_clipboard(&mut self, _text: &str) -> Result<(), String> {
1006                unreachable!("terminal initialization should fail first")
1007            }
1008        }
1009
1010        let error = super::Tui::<Cli, _>::new()
1011            .with_runtime(FailingRuntime)
1012            .run()
1013            .expect_err("runtime failure should propagate");
1014
1015        assert!(matches!(error, TuiError::Terminal(_)));
1016    }
1017
1018    #[test]
1019    fn help_style_invalid_run_toast_does_not_show_about_text() {
1020        let command = Command::new("tool")
1021            .about("Run the selected tool")
1022            .arg_required_else_help(true)
1023            .arg(Arg::new("path").required(true));
1024        let mut runtime = TestRuntime::with_events([]);
1025        let mut session = terminal_session(&mut runtime);
1026        let mut state = app_state_from_command(&command);
1027
1028        let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
1029
1030        assert!(matches!(outcome, ActionOutcome::Continue));
1031        let toast = state.notifications.toast.as_ref().expect("toast");
1032        assert!(toast.is_error);
1033        assert_eq!(toast.message, "Missing required argument: path");
1034        assert!(!toast.message.contains("Run the selected tool"));
1035    }
1036
1037    #[test]
1038    fn resize_event_requests_redraw() {
1039        let mut runtime = TestRuntime::with_events([]);
1040        let mut session = terminal_session(&mut runtime);
1041        let mut state = app_state();
1042
1043        let outcome = handle_app_event(
1044            &AppEvent::Resize {
1045                width: 120,
1046                height: 40,
1047            },
1048            &mut state,
1049            &FrameSnapshot::default(),
1050            &TuiConfig::default(),
1051            &mut session,
1052        );
1053
1054        assert!(matches!(
1055            outcome,
1056            EventOutcome::Continue { needs_redraw: true }
1057        ));
1058    }
1059
1060    #[test]
1061    fn paste_event_updates_focused_form_field() {
1062        let command = Command::new("tool").arg(Arg::new("path").long("path"));
1063        let mut runtime = TestRuntime::with_events([]);
1064        let mut session = terminal_session(&mut runtime);
1065        let mut state = app_state_from_command(&command);
1066        state.ui.focus_form();
1067
1068        let outcome = handle_app_event(
1069            &AppEvent::Paste("/tmp/foo".to_string()),
1070            &mut state,
1071            &FrameSnapshot::default(),
1072            &TuiConfig::default(),
1073            &mut session,
1074        );
1075
1076        assert!(matches!(
1077            outcome,
1078            EventOutcome::Continue { needs_redraw: true }
1079        ));
1080        let form = state.domain.current_form().expect("form");
1081        let arg = state.domain.arg_for_input("path").expect("path arg");
1082        assert_eq!(
1083            form.compatibility_value(arg),
1084            Some(crate::input::ArgValue::Text("/tmp/foo".to_string()))
1085        );
1086        let derived = crate::pipeline::derive(&state);
1087        assert_eq!(
1088            derived.authoritative_argv,
1089            vec![
1090                "tool".to_string(),
1091                "--path".to_string(),
1092                "/tmp/foo".to_string(),
1093            ]
1094        );
1095        assert!(derived.validation.is_valid);
1096    }
1097
1098    #[test]
1099    fn paste_event_updates_search_query_when_search_is_focused() {
1100        let mut runtime = TestRuntime::with_events([]);
1101        let mut session = terminal_session(&mut runtime);
1102        let mut state = app_state();
1103        state.ui.focus_search();
1104
1105        let outcome = handle_app_event(
1106            &AppEvent::Paste("build".to_string()),
1107            &mut state,
1108            &FrameSnapshot::default(),
1109            &TuiConfig::default(),
1110            &mut session,
1111        );
1112
1113        assert!(matches!(
1114            outcome,
1115            EventOutcome::Continue { needs_redraw: true }
1116        ));
1117        assert_eq!(state.ui.search_query, "build");
1118    }
1119
1120    #[test]
1121    fn toast_timeout_behavior_is_unchanged() {
1122        let mut state = app_state();
1123        state.notifications.show_toast(
1124            "Copied command to clipboard",
1125            Duration::from_millis(250),
1126            false,
1127        );
1128
1129        let timeout = redraw_timeout(&state);
1130
1131        assert!(timeout > Duration::ZERO);
1132        assert!(timeout <= Duration::from_millis(250));
1133    }
1134
1135    #[test]
1136    fn expired_toast_clears_during_continuous_key_input() {
1137        let mut runtime = TestRuntime::with_events([]);
1138        let mut session = terminal_session(&mut runtime);
1139        let mut state = app_state();
1140        state.notifications.toast = Some(Toast {
1141            message: "Copied command to clipboard".to_string(),
1142            expires_at: Instant::now()
1143                .checked_sub(Duration::from_millis(1))
1144                .expect("duration should be representable"),
1145            is_error: false,
1146        });
1147
1148        let outcome = handle_app_event(
1149            &AppEvent::Key(AppKeyEvent::new(
1150                AppKeyCode::Char('x'),
1151                AppKeyModifiers::default(),
1152            )),
1153            &mut state,
1154            &FrameSnapshot::default(),
1155            &TuiConfig::default(),
1156            &mut session,
1157        );
1158
1159        assert!(matches!(
1160            outcome,
1161            EventOutcome::Continue { needs_redraw: true }
1162        ));
1163        assert!(state.notifications.toast.is_none());
1164    }
1165}