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}