Skip to main content

umbral_core/
cli.rs

1//! Plugin-contributed CLI subcommands — the M7 `Plugin::commands()`
2//! deferral landing.
3//!
4//! Plugins implement [`PluginCommand`] to expose a `clap` subcommand
5//! and an async handler. `App::build()` retains every registered
6//! plugin in topological order; [`dispatch`] walks that list,
7//! collects each plugin's commands, builds a single top-level clap
8//! parser, and routes the user's args to the right handler.
9//!
10//! ## Why a trait, not a function pointer
11//!
12//! Plugin commands are async, and the implementation often needs to
13//! capture instance state from the plugin (a configured prefix, a
14//! handler registry, etc.). `Box<dyn PluginCommand>` lets a plugin
15//! pass values through; a `fn` pointer can't carry closure state.
16//!
17//! ## Why clap
18//!
19//! `clap` is the de-facto Rust CLI library and is already in use by
20//! `umbral-cli`. Plugins return a `clap::Command`, which carries help
21//! text, arg validation, and subcommand groupings for free. The
22//! dispatcher composes the per-plugin `Command` values under a
23//! single parent so `umbral-cli <plugin-cmd>` works as one tree.
24//!
25//! ## Example
26//!
27//! ```ignore
28//! use umbral::cli::{dispatch, CliError, PluginCommand};
29//!
30//! struct WorkerCmd;
31//!
32//! #[async_trait::async_trait]
33//! impl PluginCommand for WorkerCmd {
34//!     fn command(&self) -> clap::Command {
35//!         clap::Command::new("tasks-worker")
36//!             .about("Run the background task worker")
37//!             .arg(clap::Arg::new("once")
38//!                 .long("once")
39//!                 .action(clap::ArgAction::SetTrue))
40//!     }
41//!
42//!     async fn run(&self, m: &clap::ArgMatches) -> Result<(), CliError> {
43//!         if m.get_flag("once") {
44//!             umbral_tasks::run_worker_once().await?;
45//!         } else {
46//!             // run_worker loops forever
47//!             umbral_tasks::run_worker(Default::default()).await
48//!         }
49//!         Ok(())
50//!     }
51//! }
52//! ```
53
54use std::ffi::OsString;
55
56use async_trait::async_trait;
57use clap::ArgMatches;
58
59use crate::plugin::Plugin;
60
61/// Error returned by a plugin command. Boxed so plugins can return
62/// any concrete error type without forcing the trait into a
63/// generic-over-E shape.
64pub type CliError = Box<dyn std::error::Error + Send + Sync>;
65
66/// One CLI subcommand contributed by a plugin.
67#[async_trait]
68pub trait PluginCommand: Send + Sync + 'static {
69    /// The clap subcommand. `Command::get_name()` is the literal the
70    /// user types after the program name (`umbral-cli <name>`).
71    /// Long-form help, arg parsing, and subcommand grouping are all
72    /// the plugin's to configure on the returned value.
73    fn command(&self) -> clap::Command;
74
75    /// Run the command. Called after clap has parsed args matching
76    /// `self.command()`; `matches` is the per-subcommand
77    /// `ArgMatches` (not the top-level one).
78    async fn run(&self, matches: &ArgMatches) -> Result<(), CliError>;
79}
80
81/// Outcome of a dispatch call. Lets the caller decide what to do when
82/// no plugin command matched — typically the framework binary then
83/// falls through to its hardcoded subcommands (`serve`, `migrate`,
84/// `makemigrations`, …).
85#[derive(Debug)]
86pub enum DispatchOutcome {
87    /// A plugin command matched and its `run` completed. The bool is
88    /// the matched subcommand name so the binary can log / report.
89    Matched(String),
90    /// No plugin command matched the parsed args. The framework
91    /// binary should handle the request itself or surface a "no such
92    /// command" error.
93    Unmatched,
94    /// User asked for help (`--help` on the top level). The
95    /// formatted message is captured here so the binary can print
96    /// it (or merge with its own help).
97    Help(String),
98}
99
100/// Dispatch CLI args across the registered plugins' commands.
101///
102/// `args` is the raw `std::env::args_os()` slice including argv[0].
103/// The dispatcher builds a top-level `clap::Command` named after
104/// argv[0], hangs every plugin's contributed subcommand off it, and
105/// matches.
106///
107/// Duplicate command names across plugins are caught here (as a
108/// build-time would be ideal, but the plugin set isn't known at
109/// build time): the second plugin to register the same name loses,
110/// and a warning is logged.
111pub async fn dispatch<I, T>(
112    plugins: &[Box<dyn Plugin>],
113    args: I,
114) -> Result<DispatchOutcome, CliError>
115where
116    I: IntoIterator<Item = T>,
117    T: Into<OsString> + Clone,
118{
119    // Collect every plugin command, deduplicating by name. The
120    // first-registered wins; subsequent collisions log and are
121    // dropped.
122    let mut commands: Vec<(String, Box<dyn PluginCommand>)> = Vec::new();
123    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
124    for plugin in plugins {
125        for cmd in plugin.commands() {
126            let name = cmd.command().get_name().to_string();
127            if !seen.insert(name.clone()) {
128                tracing::warn!(
129                    target: "umbral::cli",
130                    "duplicate plugin command `{name}` from `{}`; ignoring",
131                    plugin.name()
132                );
133                continue;
134            }
135            commands.push((name, cmd));
136        }
137    }
138
139    // Nothing contributed → caller handles everything.
140    if commands.is_empty() {
141        return Ok(DispatchOutcome::Unmatched);
142    }
143
144    let mut root = clap::Command::new("umbral")
145        .about("umbral plugin subcommands")
146        .disable_help_subcommand(true)
147        .subcommand_required(false)
148        .arg_required_else_help(false);
149    for (_, cmd) in &commands {
150        root = root.subcommand(cmd.command());
151    }
152
153    // Run match. `try_get_matches_from` swallows the std::process::exit
154    // clap usually does on parse error / --help.
155    let owned: Vec<OsString> = args.into_iter().map(|t| t.into()).collect();
156    let matches = match root.clone().try_get_matches_from(owned) {
157        Ok(m) => m,
158        Err(e) => {
159            // --help / --version / parse errors. For --help we surface
160            // the rendered text so the caller can print it. For
161            // "subcommand not one of mine" — InvalidSubcommand /
162            // UnknownArgument — we return Unmatched so the caller's
163            // own clap parser (umbral-cli's built-in subcommands like
164            // serve / migrate / dev) gets a chance to match. Without
165            // this, `cargo run -- dev` errors out at this layer
166            // because plugin-dispatch claims authority over argv but
167            // doesn't know what `dev` is, and `dev` is a built-in.
168            return match e.kind() {
169                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
170                    Ok(DispatchOutcome::Help(e.render().to_string()))
171                }
172                clap::error::ErrorKind::InvalidSubcommand
173                | clap::error::ErrorKind::UnknownArgument => Ok(DispatchOutcome::Unmatched),
174                _ => Err(Box::new(e)),
175            };
176        }
177    };
178
179    let (name, sub_matches) = match matches.subcommand() {
180        Some((n, m)) => (n.to_string(), m.clone()),
181        None => return Ok(DispatchOutcome::Unmatched),
182    };
183
184    for (cmd_name, cmd) in &commands {
185        if cmd_name == &name {
186            cmd.run(&sub_matches).await?;
187            return Ok(DispatchOutcome::Matched(name));
188        }
189    }
190    Ok(DispatchOutcome::Unmatched)
191}
192
193/// Collect every plugin-contributed command as `(name, about)` pairs.
194///
195/// This is the plugin half of the unified help catalog the CLI prints
196/// on `umbral help`, `umbral --help`, and `umbral <unknown>`. The other
197/// half — the framework's built-in subcommands (`serve` / `migrate` /
198/// …) — is collected in `umbral-cli` from the derived clap `Command`,
199/// then merged with this list by [`render_help`].
200///
201/// Duplicate names across plugins are dropped (first-registered wins),
202/// mirroring [`dispatch`]'s own dedup so the listing matches what would
203/// actually run. A command whose `clap::Command` carries no `about`
204/// still appears (with `None` description); the CLI renders a dash for
205/// it and emits a `debug!` nudging the plugin author to add help text.
206pub fn command_catalog(plugins: &[Box<dyn Plugin>]) -> Vec<(String, Option<String>)> {
207    let mut out: Vec<(String, Option<String>)> = Vec::new();
208    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
209    for plugin in plugins {
210        for cmd in plugin.commands() {
211            let clap_cmd = cmd.command();
212            let name = clap_cmd.get_name().to_string();
213            if !seen.insert(name.clone()) {
214                continue;
215            }
216            let about = clap_cmd.get_about().map(|s| s.to_string());
217            if about.is_none() {
218                tracing::debug!(
219                    target: "umbral::cli",
220                    "plugin command `{name}` (from `{}`) has no `about`; \
221                     it lists with a blank description. Add `.about(...)` so \
222                     users can discover what it does.",
223                    plugin.name()
224                );
225            }
226            out.push((name, about));
227        }
228    }
229    out
230}
231
232/// Render the unified command listing shown on help / unknown-command.
233///
234/// `catalog` is the merged `(name, about)` set — built-in subcommands
235/// plus every plugin-contributed command. Entries are sorted by name
236/// and deduplicated (first occurrence wins, so callers should place the
237/// built-ins first to let them win a name clash). Descriptions are
238/// padded into an aligned column; a command with no `about` shows a
239/// dash.
240///
241/// The output is a complete help screen (header, usage, command table,
242/// footer hint) ready to print to stdout (for `help`/`--help`) or
243/// stderr (after an `error: unknown command` line).
244pub fn render_help(catalog: &[(String, Option<String>)]) -> String {
245    // Dedup by name, preserving order so built-ins (passed first) win.
246    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
247    let mut rows: Vec<(&str, &str)> = Vec::new();
248    for (name, about) in catalog {
249        if !seen.insert(name.as_str()) {
250            continue;
251        }
252        let desc = about.as_deref().map(str::trim).unwrap_or("");
253        rows.push((name.as_str(), desc));
254    }
255    rows.sort_by(|a, b| a.0.cmp(b.0));
256
257    let width = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
258
259    let mut s = String::new();
260    s.push_str("umbral — manage your umbral app\n\n");
261    s.push_str("Usage: umbral <command> [options]\n\n");
262    s.push_str("Commands:\n");
263    for (name, desc) in &rows {
264        let desc = if desc.is_empty() { "-" } else { desc };
265        // First line of a multi-line `about` is the summary.
266        let summary = desc.lines().next().unwrap_or("-");
267        s.push_str(&format!("  {name:<width$}  {summary}\n"));
268    }
269    s.push('\n');
270    s.push_str("Run `umbral <command> --help` for command-specific help.\n");
271    s
272}
273
274#[cfg(test)]
275mod tests {
276    use std::sync::Arc;
277    use std::sync::atomic::{AtomicUsize, Ordering};
278
279    use super::*;
280    use crate::plugin::Plugin;
281
282    struct Counter(Arc<AtomicUsize>);
283
284    #[async_trait]
285    impl PluginCommand for Counter {
286        fn command(&self) -> clap::Command {
287            clap::Command::new("count").about("Increment a counter")
288        }
289        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
290            self.0.fetch_add(1, Ordering::SeqCst);
291            Ok(())
292        }
293    }
294
295    struct OnePlugin {
296        name: &'static str,
297        cmd: Box<dyn Fn() -> Box<dyn PluginCommand> + Send + Sync>,
298    }
299
300    impl Plugin for OnePlugin {
301        fn name(&self) -> &'static str {
302            self.name
303        }
304        fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
305            vec![(self.cmd)()]
306        }
307    }
308
309    #[tokio::test]
310    async fn empty_plugin_list_is_unmatched() {
311        let plugins: Vec<Box<dyn Plugin>> = Vec::new();
312        let out = dispatch(&plugins, ["argv0"]).await.unwrap();
313        assert!(matches!(out, DispatchOutcome::Unmatched));
314    }
315
316    #[tokio::test]
317    async fn matched_command_runs_its_handler() {
318        let counter = Arc::new(AtomicUsize::new(0));
319        let c = counter.clone();
320        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
321            name: "one",
322            cmd: Box::new(move || Box::new(Counter(c.clone()))),
323        })];
324        let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
325        assert!(matches!(out, DispatchOutcome::Matched(name) if name == "count"));
326        assert_eq!(counter.load(Ordering::SeqCst), 1);
327    }
328
329    #[tokio::test]
330    async fn duplicate_command_name_across_plugins_is_dropped() {
331        let counter_a = Arc::new(AtomicUsize::new(0));
332        let counter_b = Arc::new(AtomicUsize::new(0));
333        let ca = counter_a.clone();
334        let cb = counter_b.clone();
335        let plugins: Vec<Box<dyn Plugin>> = vec![
336            Box::new(OnePlugin {
337                name: "first",
338                cmd: Box::new(move || Box::new(Counter(ca.clone()))),
339            }),
340            Box::new(OnePlugin {
341                name: "second",
342                cmd: Box::new(move || Box::new(Counter(cb.clone()))),
343            }),
344        ];
345        let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
346        assert!(matches!(out, DispatchOutcome::Matched(_)));
347        // The FIRST-registered plugin's command wins.
348        assert_eq!(counter_a.load(Ordering::SeqCst), 1);
349        assert_eq!(counter_b.load(Ordering::SeqCst), 0);
350    }
351
352    struct NoAboutCmd;
353
354    #[async_trait]
355    impl PluginCommand for NoAboutCmd {
356        fn command(&self) -> clap::Command {
357            // Deliberately no `.about(...)` — exercises the blank-desc path.
358            clap::Command::new("tasks-worker")
359        }
360        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
361            Ok(())
362        }
363    }
364
365    struct AboutCmd;
366
367    #[async_trait]
368    impl PluginCommand for AboutCmd {
369        fn command(&self) -> clap::Command {
370            clap::Command::new("tasks-worker").about("Run the task worker")
371        }
372        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
373            Ok(())
374        }
375    }
376
377    fn plugin_with(cmd: fn() -> Box<dyn PluginCommand>) -> Box<dyn Plugin> {
378        Box::new(OnePlugin {
379            name: "tasks",
380            cmd: Box::new(cmd),
381        })
382    }
383
384    #[test]
385    fn command_catalog_collects_name_and_about() {
386        let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(AboutCmd))];
387        let cat = command_catalog(&plugins);
388        assert_eq!(cat.len(), 1);
389        assert_eq!(cat[0].0, "tasks-worker");
390        assert_eq!(cat[0].1.as_deref(), Some("Run the task worker"));
391    }
392
393    #[test]
394    fn command_catalog_lists_command_without_about_as_none() {
395        let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(NoAboutCmd))];
396        let cat = command_catalog(&plugins);
397        assert_eq!(cat.len(), 1);
398        assert_eq!(cat[0].0, "tasks-worker");
399        assert_eq!(cat[0].1, None);
400    }
401
402    #[test]
403    fn render_help_aligns_and_shows_dash_for_blank() {
404        // A built-in-style entry, a plugin entry with about, one without.
405        let catalog = vec![
406            (
407                "migrate".to_string(),
408                Some("Apply pending migrations".to_string()),
409            ),
410            (
411                "tasks-worker".to_string(),
412                Some("Run the task worker".to_string()),
413            ),
414            ("blank".to_string(), None),
415        ];
416        let out = render_help(&catalog);
417
418        // Both descriptions present.
419        assert!(
420            out.contains("Apply pending migrations"),
421            "missing built-in desc:\n{out}"
422        );
423        assert!(
424            out.contains("Run the task worker"),
425            "missing plugin desc:\n{out}"
426        );
427        // Blank-about command shows a dash.
428        assert!(
429            out.contains("blank") && out.contains(" -\n"),
430            "missing dash for blank:\n{out}"
431        );
432        // Column alignment: the longest name is `tasks-worker` (12). The
433        // shorter `migrate` row pads its name out to the same column, so
434        // its description starts at the same offset.
435        let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
436        let migrate_line = out.lines().find(|l| l.contains("migrate")).unwrap();
437        let worker_desc_col = worker_line.find("Run the task worker").unwrap();
438        let migrate_desc_col = migrate_line.find("Apply pending migrations").unwrap();
439        assert_eq!(
440            worker_desc_col, migrate_desc_col,
441            "descriptions not column-aligned:\n{out}"
442        );
443        // Sorted by name: blank < migrate < tasks-worker.
444        let bi = out.find("\n  blank").unwrap();
445        let mi = out.find("\n  migrate").unwrap();
446        let ti = out.find("\n  tasks-worker").unwrap();
447        assert!(bi < mi && mi < ti, "commands not sorted by name:\n{out}");
448    }
449
450    #[test]
451    fn render_help_dedups_first_wins() {
452        // Built-in `migrate` placed first should win over a plugin that
453        // also registers `migrate` with a different description.
454        let catalog = vec![
455            (
456                "migrate".to_string(),
457                Some("Apply pending migrations".to_string()),
458            ),
459            ("migrate".to_string(), Some("a plugin override".to_string())),
460        ];
461        let out = render_help(&catalog);
462        assert!(out.contains("Apply pending migrations"), "{out}");
463        assert!(!out.contains("a plugin override"), "{out}");
464    }
465
466    #[tokio::test]
467    async fn help_request_returns_help_outcome() {
468        let counter = Arc::new(AtomicUsize::new(0));
469        let c = counter.clone();
470        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
471            name: "one",
472            cmd: Box::new(move || Box::new(Counter(c.clone()))),
473        })];
474        let out = dispatch(&plugins, ["argv0", "--help"]).await.unwrap();
475        assert!(
476            matches!(out, DispatchOutcome::Help(text) if text.contains("count")),
477            "expected Help with subcommand listed"
478        );
479        // Handler did NOT run on --help.
480        assert_eq!(counter.load(Ordering::SeqCst), 0);
481    }
482}