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