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;
13use std::io::IsTerminal;
14
15use clawless_core::cancellation::Cancellation;
16use clawless_core::context::{Context, Interactivity};
17use clawless_core::event::event_channel;
18use clawless_core::exit::Exit;
19use clawless_core::output::Output;
20use clawless_core::signal::wait_for_shutdown;
21
22use crate::error::{CommandResult, ErrorContext};
23use crate::output::OutputFlags;
24use crate::presenter::{Presenter, TerminalPresenter};
25
26/// Reports whether a user is present who can answer the application
27///
28/// A user is present when `input` and `display` are both terminals. The user types on the
29/// input and reads on the display. The display is the standard error, because the standard
30/// output carries the result of the command.
31fn detect_interactivity(input: &impl IsTerminal, display: &impl IsTerminal) -> Interactivity {
32 match (input.is_terminal(), display.is_terminal()) {
33 (true, true) => Interactivity::Interactive,
34 (true, false) => Interactivity::NonInteractive,
35 (false, true) => Interactivity::NonInteractive,
36 (false, false) => Interactivity::NonInteractive,
37 }
38}
39
40/// CLI command runner
41///
42/// Encapsulates the lifecycle of running a CLI command: creating the event channel, constructing
43/// the [`Context`], registering signal handlers, and presenting output through a
44/// [`TerminalPresenter`]. The `main!()` macro dispatches to [`CommandRunner::run`] when the
45/// resolved leaf is a `ResolvedLeaf::Command`.
46///
47/// # Examples
48///
49/// ```rust,ignore
50/// // This is what main!() expands to for commands:
51/// ResolvedLeaf::Command { matches, exec } => {
52/// clawless::runner::CommandRunner::run(matches, exec)
53/// }
54/// ```
55// r[impl dispatch.exec.command-runner]
56#[derive(Debug)]
57pub struct CommandRunner;
58
59impl CommandRunner {
60 /// Runs a CLI command to completion
61 ///
62 /// Sets up the command lifecycle:
63 ///
64 /// 1. Detects whether a user is present who can answer the application
65 /// 2. Creates a [`Cancellation`] token for cooperative shutdown
66 /// 3. Extracts output flags from the pre-parsed argument matches
67 /// 4. Creates an event channel and builds the [`Context`]
68 /// 5. Configures a [`TerminalPresenter`] with the parsed output flags
69 /// 6. Creates a Tokio runtime, spawns the signal handler, and runs the command through the
70 /// presenter
71 ///
72 /// A user is present when the standard input and the standard error are both terminals. The
73 /// [`Context`] carries the result as its [`Interactivity`].
74 ///
75 /// Output flags (`--quiet`, `--verbose`, `--json`) are augmented at the root level by
76 /// `main!()` with `.global(true)`, so they are available in every leaf's [`ArgMatches`].
77 ///
78 /// The runner returns the [`Exit`] of the command, and only after the presenter has rendered
79 /// every event of the command. If the command fails, or if the runner cannot build the
80 /// [`Context`] or the Tokio runtime, the [`Exit`] has the exit code 1 and the text of the
81 /// error. The `main` function returns the [`Exit`] in a `ProcessExit`, and the process then
82 /// writes its text and exits with its code. The text therefore comes after all the output of
83 /// the command.
84 ///
85 /// # Arguments
86 ///
87 /// * `matches` — The parsed [`ArgMatches`] for this command leaf, as resolved by the
88 /// subcommand tree walk.
89 /// * `exec` — Executes the command with the given matches and context. The `#[command]` macro
90 /// generates a function for this, and any other callable works. A caller that builds its
91 /// command tree at run time passes a closure that owns the command it resolved. The command
92 /// returns `()` for the exit code 0, or an [`Exit`] to choose the exit code and the text.
93 ///
94 /// [`ArgMatches`]: clap::ArgMatches
95 /// [`Cancellation`]: clawless_core::cancellation::Cancellation
96 /// [`Context`]: clawless_core::context::Context
97 /// [`Exit`]: clawless_core::exit::Exit
98 /// [`Interactivity`]: clawless_core::context::Interactivity
99 /// [`TerminalPresenter`]: crate::presenter::TerminalPresenter
100 // r[impl dispatch.exec.callable]
101 pub fn run<E, F, T>(matches: clap::ArgMatches, exec: E) -> Exit
102 where
103 E: FnOnce(clap::ArgMatches, Context) -> F,
104 F: Future<Output = CommandResult<T>> + Send + 'static,
105 T: Into<Exit>,
106 {
107 let interactivity = detect_interactivity(&std::io::stdin(), &std::io::stderr());
108
109 Self::run_with(matches, exec, interactivity)
110 }
111
112 /// Runs a CLI command to completion for the given interactivity
113 ///
114 /// [`CommandRunner::run`] detects the interactivity and calls this function. A test calls
115 /// this function directly, because the streams of a test are never terminals.
116 fn run_with<E, F, T>(matches: clap::ArgMatches, exec: E, interactivity: Interactivity) -> Exit
117 where
118 E: FnOnce(clap::ArgMatches, Context) -> F,
119 F: Future<Output = CommandResult<T>> + Send + 'static,
120 T: Into<Exit>,
121 {
122 Self::try_run(matches, exec, interactivity).unwrap_or_else(|error| Exit::from_error(&error))
123 }
124
125 /// Runs a CLI command to completion and returns its exit, or the error that ended it
126 ///
127 /// # Errors
128 ///
129 /// Returns an error if context construction fails (e.g., the current working directory cannot
130 /// be determined), if the Tokio runtime cannot be created, or if the command itself fails.
131 fn try_run<E, F, T>(
132 matches: clap::ArgMatches,
133 exec: E,
134 interactivity: Interactivity,
135 ) -> CommandResult<Exit>
136 where
137 E: FnOnce(clap::ArgMatches, Context) -> F,
138 F: Future<Output = CommandResult<T>> + Send + 'static,
139 T: Into<Exit>,
140 {
141 let cancellation = Cancellation::new();
142 let output_flags = OutputFlags::from_arg_matches(&matches);
143
144 let (sender, receiver) = event_channel();
145 let output = Output::new(sender);
146
147 let context = Context::builder()
148 .cancellation(cancellation.clone())
149 .interactivity(interactivity)
150 .output(output)
151 .build()
152 .context("build the context of the command")?;
153
154 let presenter = TerminalPresenter::builder()
155 .receiver(receiver)
156 .verbosity(output_flags.verbosity())
157 .mode(output_flags.mode())
158 .interactivity(interactivity)
159 .build();
160
161 let rt = tokio::runtime::Runtime::new().context("start the async runtime")?;
162 rt.block_on(async {
163 tokio::spawn(wait_for_shutdown(cancellation));
164
165 let command = exec(matches, context);
166 presenter
167 .present(Box::pin(async move { command.await.map(Into::into) }))
168 .await
169 })
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 // An assertion in a test panics by design. A `# Panics` section on every test
176 // would repeat that and give the reader no information.
177 #![allow(clippy::missing_panics_doc)]
178
179 use std::fs::File;
180 use std::path::Path;
181 use std::process::ExitCode;
182 use std::sync::atomic::{AtomicBool, Ordering};
183 use std::sync::{Arc, Mutex};
184
185 use super::*;
186
187 /// Returns the matches of a command line without arguments
188 fn matches() -> clap::ArgMatches {
189 OutputFlags::augment_command(clap::Command::new("test")).get_matches_from(["test"])
190 }
191
192 #[test]
193 fn detect_interactivity_without_a_terminal_returns_non_interactive() {
194 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
195 let input = File::open(&manifest).expect("should open the manifest");
196 let display = File::open(&manifest).expect("should open the manifest");
197
198 let interactivity = detect_interactivity(&input, &display);
199
200 assert_eq!(interactivity, Interactivity::NonInteractive);
201 }
202
203 // r[verify dispatch.exec.callable]
204 #[test]
205 fn run_with_a_closure_that_owns_state_executes_the_leaf() {
206 let executed = Arc::new(AtomicBool::new(false));
207 let owned = Arc::clone(&executed);
208
209 drop(CommandRunner::run(
210 matches(),
211 move |_matches, _context| async move {
212 owned.store(true, Ordering::SeqCst);
213 Ok(())
214 },
215 ));
216
217 assert!(executed.load(Ordering::SeqCst));
218 }
219
220 #[test]
221 fn run_with_a_command_that_chooses_its_exit_returns_that_exit() {
222 let chosen = Exit::builder()
223 .code(ExitCode::from(3))
224 .text("error: 0.7.0 is not a stable release\n")
225 .build();
226 let returned = chosen.clone();
227
228 let exit = CommandRunner::run_with(
229 matches(),
230 move |_matches, _context| async move { Ok(returned) },
231 Interactivity::NonInteractive,
232 );
233
234 assert_eq!(exit, chosen);
235 }
236
237 #[test]
238 fn run_with_a_command_that_fails_returns_the_error_with_code_1() {
239 let exit = CommandRunner::run_with(
240 matches(),
241 |_matches, _context| async { Err::<(), _>(anyhow::anyhow!("the file is missing")) },
242 Interactivity::NonInteractive,
243 );
244
245 assert_eq!(
246 exit,
247 Exit::builder()
248 .code(ExitCode::FAILURE)
249 .text("Error: the file is missing\n")
250 .build()
251 );
252 }
253
254 #[test]
255 fn run_with_a_command_that_succeeds_returns_success() {
256 let exit = CommandRunner::run_with(
257 matches(),
258 |_matches, _context| async { Ok(()) },
259 Interactivity::NonInteractive,
260 );
261
262 assert_eq!(exit, Exit::from(()));
263 }
264
265 #[test]
266 fn run_with_interactivity_passes_it_to_the_context() {
267 let seen = Arc::new(Mutex::new(None));
268 let owned = Arc::clone(&seen);
269
270 drop(CommandRunner::run_with(
271 matches(),
272 move |_matches, context| async move {
273 *owned.lock().expect("should lock") = Some(context.interactivity());
274 Ok(())
275 },
276 Interactivity::Interactive,
277 ));
278
279 assert_eq!(
280 *seen.lock().expect("should lock"),
281 Some(Interactivity::Interactive)
282 );
283 }
284
285 #[test]
286 fn trait_send() {
287 fn assert_send<T: Send>() {}
288 assert_send::<CommandRunner>();
289 }
290
291 #[test]
292 fn trait_sync() {
293 fn assert_sync<T: Sync>() {}
294 assert_sync::<CommandRunner>();
295 }
296
297 #[test]
298 fn trait_unpin() {
299 fn assert_unpin<T: Unpin>() {}
300 assert_unpin::<CommandRunner>();
301 }
302}