Skip to main content

clawless_cli/
runner.rs

1//! CLI command runner
2//!
3//! This module defines [`CommandRunner`], which encapsulates the full lifecycle of a CLI command:
4//! event channel creation, context construction, signal handling, and terminal presentation. The
5//! `main!()` macro dispatches to `CommandRunner::run` when the resolved leaf is a command.
6//!
7//! Application authors do not interact with this module directly. The `main!()` macro calls
8//! [`CommandRunner::run`] with the resolved matches and the leaf's exec function. The runner takes
9//! any callable, so a caller that builds its command tree at run time can dispatch to a closure
10//! that owns the command it resolved.
11
12use std::future::Future;
13
14use clawless_core::cancellation::Cancellation;
15use clawless_core::context::Context;
16use clawless_core::event::event_channel;
17use clawless_core::output::Output;
18use clawless_core::signal::wait_for_shutdown;
19
20use crate::error::CommandResult;
21use crate::output::OutputFlags;
22use crate::presenter::{Presenter, TerminalPresenter};
23
24/// CLI command runner
25///
26/// Encapsulates the lifecycle of running a CLI command: creating the event channel, constructing
27/// the [`Context`], registering signal handlers, and presenting output through a
28/// [`TerminalPresenter`]. The `main!()` macro dispatches to [`CommandRunner::run`] when the
29/// resolved leaf is a `ResolvedLeaf::Command`.
30///
31/// # Examples
32///
33/// ```rust,ignore
34/// // This is what main!() expands to for commands:
35/// ResolvedLeaf::Command { matches, exec } => {
36///     clawless::runner::CommandRunner::run(matches, exec)
37/// }
38/// ```
39// r[impl dispatch.exec.command-runner]
40#[derive(Debug)]
41pub struct CommandRunner;
42
43impl CommandRunner {
44    /// Runs a CLI command to completion
45    ///
46    /// Sets up the command lifecycle:
47    ///
48    /// 1. Creates a [`Cancellation`] token for cooperative shutdown
49    /// 2. Extracts output flags from the pre-parsed argument matches
50    /// 3. Creates an event channel and builds the [`Context`]
51    /// 4. Configures a [`TerminalPresenter`] with the parsed output flags
52    /// 5. Creates a Tokio runtime, spawns the signal handler, and runs the command through the
53    ///    presenter
54    ///
55    /// Output flags (`--quiet`, `--verbose`, `--json`) are augmented at the root level by
56    /// `main!()` with `.global(true)`, so they are available in every leaf's [`ArgMatches`].
57    ///
58    /// # Arguments
59    ///
60    /// * `matches` — The parsed [`ArgMatches`] for this command leaf, as resolved by the
61    ///   subcommand tree walk.
62    /// * `exec` — Executes the command with the given matches and context. The `#[command]` macro
63    ///   generates a function for this, and any other callable works. A caller that builds its
64    ///   command tree at run time passes a closure that owns the command it resolved.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if context construction fails (e.g., the current working directory cannot be
69    /// determined), if the Tokio runtime cannot be created, or if the command itself fails.
70    ///
71    /// [`ArgMatches`]: clap::ArgMatches
72    /// [`Cancellation`]: clawless_core::cancellation::Cancellation
73    /// [`Context`]: clawless_core::context::Context
74    /// [`TerminalPresenter`]: crate::presenter::TerminalPresenter
75    // r[impl dispatch.exec.callable]
76    pub fn run<E, F>(matches: clap::ArgMatches, exec: E) -> Result<(), Box<dyn std::error::Error>>
77    where
78        E: FnOnce(clap::ArgMatches, Context) -> F,
79        F: Future<Output = CommandResult> + Send + 'static,
80    {
81        let cancellation = Cancellation::new();
82        let output_flags = OutputFlags::from_arg_matches(&matches);
83
84        let (sender, receiver) = event_channel();
85        let output = Output::new(sender);
86
87        let context = Context::builder()
88            .cancellation(cancellation.clone())
89            .output(output)
90            .build()?;
91
92        let presenter = TerminalPresenter::builder()
93            .receiver(receiver)
94            .verbosity(output_flags.verbosity())
95            .mode(output_flags.mode())
96            .build();
97
98        let rt = tokio::runtime::Runtime::new()?;
99        rt.block_on(async {
100            tokio::spawn(wait_for_shutdown(cancellation));
101
102            presenter.present(Box::pin(exec(matches, context))).await
103        })?;
104
105        Ok(())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    // An assertion in a test panics by design. A `# Panics` section on every test
112    // would repeat that and give the reader no information.
113    #![allow(clippy::missing_panics_doc)]
114
115    use std::sync::Arc;
116    use std::sync::atomic::{AtomicBool, Ordering};
117
118    use super::*;
119
120    // r[verify dispatch.exec.callable]
121    #[test]
122    fn run_with_a_closure_that_owns_state_executes_the_leaf() {
123        let matches =
124            OutputFlags::augment_command(clap::Command::new("test")).get_matches_from(["test"]);
125        let executed = Arc::new(AtomicBool::new(false));
126        let owned = Arc::clone(&executed);
127
128        CommandRunner::run(matches, move |_matches, _context| async move {
129            owned.store(true, Ordering::SeqCst);
130            Ok(())
131        })
132        .expect("the runner runs the leaf to completion");
133
134        assert!(executed.load(Ordering::SeqCst));
135    }
136
137    #[test]
138    fn trait_send() {
139        fn assert_send<T: Send>() {}
140        assert_send::<CommandRunner>();
141    }
142
143    #[test]
144    fn trait_sync() {
145        fn assert_sync<T: Sync>() {}
146        assert_sync::<CommandRunner>();
147    }
148
149    #[test]
150    fn trait_unpin() {
151        fn assert_unpin<T: Unpin>() {}
152        assert_unpin::<CommandRunner>();
153    }
154}