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/// Re-export of `clap`, the crate [`PluginCommand`] names in its own
62/// public signature (`fn command(&self) -> clap::Command`).
63///
64/// A trait whose surface names a foreign type has to hand that type
65/// out, or every implementor adds its own `clap = "4"` dependency and
66/// gets to discover — at link time, via a type mismatch a page long —
67/// that it resolved a different major version than the framework. Write
68/// `use umbral::cli::clap;` and you are provably on the same clap the
69/// dispatcher parses with.
70pub use clap;
71
72/// Error returned by a plugin command. Boxed so plugins can return
73/// any concrete error type without forcing the trait into a
74/// generic-over-E shape.
75pub type CliError = Box<dyn std::error::Error + Send + Sync>;
76
77/// One CLI subcommand contributed by a plugin.
78#[async_trait]
79pub trait PluginCommand: Send + Sync + 'static {
80    /// The clap subcommand. `Command::get_name()` is the literal the
81    /// user types after the program name (`umbral-cli <name>`).
82    /// Long-form help, arg parsing, and subcommand grouping are all
83    /// the plugin's to configure on the returned value.
84    fn command(&self) -> clap::Command;
85
86    /// Run the command. Called after clap has parsed args matching
87    /// `self.command()`; `matches` is the per-subcommand
88    /// `ArgMatches` (not the top-level one).
89    async fn run(&self, matches: &ArgMatches) -> Result<(), CliError>;
90
91    /// Whether this command needs a *live* application — pools open, schema
92    /// migrated, every plugin's `on_ready` fired.
93    ///
94    /// Default `true`, which is right for almost everything: a command that
95    /// touches data wants the app up.
96    ///
97    /// Return `false` for a command that only touches the filesystem — a code
98    /// generator, a linter, a config dump. `on_ready` hooks seed content and
99    /// backfill rows, so firing them for `startpermission` means a pure
100    /// codegen command writes to the database, and on a fresh checkout it
101    /// fails against tables `migrate` has not created yet — before writing the
102    /// file it exists to write.
103    ///
104    /// The framework's own schema commands (`migrate`, `makemigrations`, …)
105    /// are excluded by name in `umbral-cli`; that list structurally cannot
106    /// know about a plugin's offline commands, which is why the plugin gets to
107    /// declare it here.
108    fn needs_ready(&self) -> bool {
109        true
110    }
111}
112
113/// Outcome of a dispatch call. Lets the caller decide what to do when
114/// no plugin command matched — typically the framework binary then
115/// falls through to its hardcoded subcommands (`serve`, `migrate`,
116/// `makemigrations`, …).
117#[derive(Debug)]
118pub enum DispatchOutcome {
119    /// A plugin command matched and its `run` completed. The bool is
120    /// the matched subcommand name so the binary can log / report.
121    Matched(String),
122    /// No plugin command matched the parsed args. The framework
123    /// binary should handle the request itself or surface a "no such
124    /// command" error.
125    Unmatched,
126    /// User asked for help (`--help` on the top level). The
127    /// formatted message is captured here so the binary can print
128    /// it (or merge with its own help).
129    Help(String),
130}
131
132/// Dispatch CLI args across the registered plugins' commands.
133///
134/// `args` is the raw `std::env::args_os()` slice including argv[0].
135/// The dispatcher builds a top-level `clap::Command` named after
136/// argv[0], hangs every plugin's contributed subcommand off it, and
137/// matches.
138///
139/// Duplicate command names across plugins are caught here (as a
140/// build-time would be ideal, but the plugin set isn't known at
141/// build time): the second plugin to register the same name loses,
142/// and a warning is logged.
143pub async fn dispatch<I, T>(
144    plugins: &[Box<dyn Plugin>],
145    args: I,
146) -> Result<DispatchOutcome, CliError>
147where
148    I: IntoIterator<Item = T>,
149    T: Into<OsString> + Clone,
150{
151    dispatch_with_app_commands(&[], plugins, &[], args).await
152}
153
154/// [`dispatch`], plus the commands the *project* registered directly on
155/// its `App` via [`crate::app::AppBuilder::command`].
156///
157/// A project's own management command (`backfill_slugs`, `import_prices`)
158/// belongs to no plugin — it belongs to the binary. Without this, the only
159/// way to add one is to wrap it in a dummy plugin, which is a contract
160/// smell: the plugin trait exists to package a *reusable* unit, not to be
161/// the sole doorway to argv.
162///
163/// App commands are collected first, so on a name clash the project's own
164/// command wins over a plugin's (most-specific layer wins) and a warning
165/// names the plugin that lost.
166pub async fn dispatch_with_app_commands<I, T>(
167    app_commands: &[Box<dyn PluginCommand>],
168    plugins: &[Box<dyn Plugin>],
169    reserved: &[&str],
170    args: I,
171) -> Result<DispatchOutcome, CliError>
172where
173    I: IntoIterator<Item = T>,
174    T: Into<OsString> + Clone,
175{
176    // A one-shot convenience. A caller that ALSO needs the catalog or a
177    // readiness answer should build a `CommandSet` once and ask it all three
178    // questions: collecting is what runs every plugin's command constructors,
179    // builds their clap parsers, and prints the built-in-shadow warning — so
180    // collecting per-question printed that warning per-question too.
181    CommandSet::collect(app_commands, plugins, reserved)
182        .dispatch(args)
183        .await
184}
185
186/// One collected command, however it got here.
187///
188/// The app registers its commands once on the builder and the `App` owns
189/// them for its lifetime, so those arrive as borrows. A plugin *builds* a
190/// fresh `Box<dyn PluginCommand>` every time `Plugin::commands()` is
191/// called, so those arrive owned. `run` takes `&self`, so neither side
192/// needs to be cloned — this enum is just the seam that lets one list
193/// hold both.
194enum CommandHandle<'a> {
195    Borrowed(&'a dyn PluginCommand),
196    Owned(Box<dyn PluginCommand>),
197}
198
199impl CommandHandle<'_> {
200    fn get(&self) -> &dyn PluginCommand {
201        match self {
202            Self::Borrowed(c) => *c,
203            Self::Owned(c) => c.as_ref(),
204        }
205    }
206}
207
208/// The registered commands, collected once.
209///
210/// Collecting is not free and it is not idempotent-looking: it runs every
211/// plugin's `commands()` constructor, builds a `clap::Command` per command
212/// (help prose and all), and PRINTS the built-in-shadow warning. Doing that
213/// two or three times per invocation — once for the readiness check, once to
214/// dispatch, once more for the help catalog — meant a user with a shadowing
215/// command saw the same scary warning twice, which reads like two problems.
216///
217/// So: collect once, then ask it questions.
218pub struct CommandSet<'a> {
219    entries: Vec<Entry<'a>>,
220}
221
222struct Entry<'a> {
223    name: String,
224    /// Built once, here. Every consumer reads it rather than rebuilding it.
225    clap: clap::Command,
226    handle: CommandHandle<'a>,
227}
228
229impl<'a> CommandSet<'a> {
230    /// Collect the app's own commands followed by every plugin's, dropping any
231    /// that shadow a framework built-in named in `reserved`.
232    pub fn collect(
233        app_commands: &'a [Box<dyn PluginCommand>],
234        plugins: &'a [Box<dyn Plugin>],
235        reserved: &[&str],
236    ) -> Self {
237        Self {
238            entries: collect_commands(app_commands, plugins, reserved),
239        }
240    }
241
242    /// Nothing registered — the caller handles everything itself.
243    pub fn is_empty(&self) -> bool {
244        self.entries.is_empty()
245    }
246
247    /// Does the command named `name` need a live app? `None` if nothing
248    /// registered that name (the caller's own built-in rules then decide).
249    pub fn needs_ready(&self, name: &str) -> Option<bool> {
250        self.entries
251            .iter()
252            .find(|e| e.name == name)
253            .map(|e| e.handle.get().needs_ready())
254    }
255
256    /// `(name, about)` for every registered command — the listing half of
257    /// `umbral help`. Shares this collection with dispatch, so the help can
258    /// never advertise a command that would not actually run.
259    pub fn catalog(&self) -> Vec<(String, Option<String>)> {
260        self.entries
261            .iter()
262            .map(|e| {
263                let about = e.clap.get_about().map(|s| s.to_string());
264                if about.is_none() {
265                    tracing::debug!(
266                        target: "umbral::cli",
267                        "command `{}` has no `about`; it lists with a blank description. \
268                         Add `.about(...)` so users can discover what it does.",
269                        e.name,
270                    );
271                }
272                (e.name.clone(), about)
273            })
274            .collect()
275    }
276
277    /// Route `args` to the matching command.
278    pub async fn dispatch<I, T>(&self, args: I) -> Result<DispatchOutcome, CliError>
279    where
280        I: IntoIterator<Item = T>,
281        T: Into<OsString> + Clone,
282    {
283        if self.entries.is_empty() {
284            return Ok(DispatchOutcome::Unmatched);
285        }
286
287        let mut root = clap::Command::new("umbral")
288            .about("umbral plugin subcommands")
289            .disable_help_subcommand(true)
290            .subcommand_required(false)
291            .arg_required_else_help(false);
292        for entry in &self.entries {
293            root = root.subcommand(entry.clap.clone());
294        }
295
296        let owned: Vec<OsString> = args.into_iter().map(|t| t.into()).collect();
297        let matches = match root.clone().try_get_matches_from(owned) {
298            Ok(m) => m,
299            Err(e) => {
300                return match e.kind() {
301                    clap::error::ErrorKind::DisplayHelp
302                    | clap::error::ErrorKind::DisplayVersion => {
303                        Ok(DispatchOutcome::Help(e.render().to_string()))
304                    }
305                    clap::error::ErrorKind::InvalidSubcommand
306                    | clap::error::ErrorKind::UnknownArgument => Ok(DispatchOutcome::Unmatched),
307                    _ => Err(Box::new(e)),
308                };
309            }
310        };
311
312        let (name, sub_matches) = match matches.subcommand() {
313            Some((n, m)) => (n.to_string(), m.clone()),
314            None => return Ok(DispatchOutcome::Unmatched),
315        };
316
317        for entry in &self.entries {
318            if entry.name == name {
319                entry.handle.get().run(&sub_matches).await?;
320                return Ok(DispatchOutcome::Matched(name));
321            }
322        }
323        Ok(DispatchOutcome::Unmatched)
324    }
325}
326
327/// Collect the app's own commands followed by every plugin's, keyed by
328/// the clap name and deduplicated (first-registered wins).
329///
330/// The single place the precedence rule lives: app commands are pushed
331/// before plugin commands, so a project can deliberately shadow a
332/// plugin's command with its own. Both [`dispatch_with_app_commands`]
333/// and [`command_catalog_with_app_commands`] route through here, which
334/// is what keeps the help listing honest about what would actually run.
335fn collect_commands<'a>(
336    app_commands: &'a [Box<dyn PluginCommand>],
337    plugins: &'a [Box<dyn Plugin>],
338    reserved: &[&str],
339) -> Vec<Entry<'a>> {
340    let mut commands: Vec<Entry<'a>> = Vec::new();
341    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
342
343    // A command whose name is a framework built-in is DROPPED, not run.
344    //
345    // The dispatcher tries these commands before the built-in parser, so
346    // without this a command named `migrate` doesn't collide loudly — it
347    // quietly takes over, `cargo run -- migrate` runs the wrong thing, and the
348    // deploy ships an un-migrated schema with a zero exit code. Nobody finds
349    // that until production. The built-in wins; the shadow is refused out loud.
350    let shadow = |name: &str, source: &str| {
351        if !reserved.contains(&name) {
352            return false;
353        }
354        // eprintln, not just tracing: this must be visible in a plain
355        // `cargo run` with no subscriber configured, because the thing it is
356        // warning about is that a framework command has stopped working.
357        eprintln!(
358            "warning: {source} registers a command named `{name}`, which is a framework \
359             built-in. The built-in wins and the registered one is IGNORED — rename it. \
360             (Without this, `{name}` would silently run your command instead of the \
361             framework's.)"
362        );
363        tracing::warn!(
364            target: "umbral::cli",
365            "{source} command `{name}` shadows a framework built-in; ignoring it",
366        );
367        true
368    };
369
370    // Each `clap::Command` is built ONCE here and carried on the entry. It used
371    // to be built to read `.get_name()` and then thrown away, and rebuilt by
372    // every consumer — three times per invocation for prose that was discarded.
373    for cmd in app_commands {
374        let clap = cmd.command();
375        let name = clap.get_name().to_string();
376        if shadow(&name, "the app") {
377            continue;
378        }
379        if !seen.insert(name.clone()) {
380            tracing::warn!(
381                target: "umbral::cli",
382                "app command `{name}` is registered twice on the App builder; \
383                 ignoring the second",
384            );
385            continue;
386        }
387        commands.push(Entry {
388            name,
389            clap,
390            handle: CommandHandle::Borrowed(cmd.as_ref()),
391        });
392    }
393    for plugin in plugins {
394        for cmd in plugin.commands() {
395            let clap = cmd.command();
396            let name = clap.get_name().to_string();
397            if shadow(&name, &format!("plugin `{}`", plugin.name())) {
398                continue;
399            }
400            if !seen.insert(name.clone()) {
401                tracing::warn!(
402                    target: "umbral::cli",
403                    "duplicate command `{name}` from plugin `{}`; ignoring (an \
404                     earlier plugin — or the app itself — registered it first)",
405                    plugin.name()
406                );
407                continue;
408            }
409            commands.push(Entry {
410                name,
411                clap,
412                handle: CommandHandle::Owned(cmd),
413            });
414        }
415    }
416    commands
417}
418
419/// Does the command named `name` need a live app (pools, migrated schema,
420/// `on_ready` fired)?
421///
422/// `None` means no app or plugin registered that name — the framework binary's
423/// own built-in list decides. `Some(false)` is a command that declared itself
424/// offline via [`PluginCommand::needs_ready`], e.g. a code generator.
425pub fn command_needs_ready(
426    app_commands: &[Box<dyn PluginCommand>],
427    plugins: &[Box<dyn Plugin>],
428    name: &str,
429    reserved: &[&str],
430) -> Option<bool> {
431    CommandSet::collect(app_commands, plugins, reserved).needs_ready(name)
432}
433
434/// Collect every plugin-contributed command as `(name, about)` pairs.
435///
436/// This is the plugin half of the unified help catalog the CLI prints
437/// on `umbral help`, `umbral --help`, and `umbral <unknown>`. The other
438/// half — the framework's built-in subcommands (`serve` / `migrate` /
439/// …) — is collected in `umbral-cli` from the derived clap `Command`,
440/// then merged with this list by [`render_help`].
441///
442/// Duplicate names across plugins are dropped (first-registered wins),
443/// mirroring [`dispatch`]'s own dedup so the listing matches what would
444/// actually run. A command whose `clap::Command` carries no `about`
445/// still appears (with `None` description); the CLI renders a dash for
446/// it and emits a `debug!` nudging the plugin author to add help text.
447pub fn command_catalog(plugins: &[Box<dyn Plugin>]) -> Vec<(String, Option<String>)> {
448    command_catalog_with_app_commands(&[], plugins, &[])
449}
450
451/// [`command_catalog`], plus the project's own `AppBuilder::command`
452/// registrations — the listing half of [`dispatch_with_app_commands`].
453///
454/// Shares [`collect_commands`] with the dispatcher, so a command that
455/// lost a name clash is absent from the help for the same reason it
456/// would never have run: it is not in the collected set.
457pub fn command_catalog_with_app_commands(
458    app_commands: &[Box<dyn PluginCommand>],
459    plugins: &[Box<dyn Plugin>],
460    reserved: &[&str],
461) -> Vec<(String, Option<String>)> {
462    CommandSet::collect(app_commands, plugins, reserved).catalog()
463}
464
465/// Render the unified command listing shown on help / unknown-command.
466///
467/// `catalog` is the merged `(name, about)` set — built-in subcommands
468/// plus every plugin-contributed command. Entries are sorted by name
469/// and deduplicated (first occurrence wins, so callers should place the
470/// built-ins first to let them win a name clash). Descriptions are
471/// padded into an aligned column; a command with no `about` shows a
472/// dash.
473///
474/// The output is a complete help screen (header, usage, command table,
475/// footer hint) ready to print to stdout (for `help`/`--help`) or
476/// stderr (after an `error: unknown command` line).
477pub fn render_help(catalog: &[(String, Option<String>)]) -> String {
478    // Dedup by name, preserving order so built-ins (passed first) win.
479    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
480    let mut rows: Vec<(&str, &str)> = Vec::new();
481    for (name, about) in catalog {
482        if !seen.insert(name.as_str()) {
483            continue;
484        }
485        let desc = about.as_deref().map(str::trim).unwrap_or("");
486        rows.push((name.as_str(), desc));
487    }
488    rows.sort_by(|a, b| a.0.cmp(b.0));
489
490    let width = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
491
492    let mut s = String::new();
493    s.push_str("umbral — manage your umbral app\n\n");
494    s.push_str("Usage: umbral <command> [options]\n\n");
495    s.push_str("Commands:\n");
496    for (name, desc) in &rows {
497        let desc = if desc.is_empty() { "-" } else { desc };
498        // First line of a multi-line `about` is the summary.
499        let summary = desc.lines().next().unwrap_or("-");
500        s.push_str(&format!("  {name:<width$}  {summary}\n"));
501    }
502    s.push('\n');
503    s.push_str("Run `umbral <command> --help` for command-specific help.\n");
504    s
505}
506
507#[cfg(test)]
508mod tests {
509    use std::sync::Arc;
510    use std::sync::atomic::{AtomicUsize, Ordering};
511
512    use super::*;
513    use crate::plugin::Plugin;
514
515    struct Counter(Arc<AtomicUsize>);
516
517    #[async_trait]
518    impl PluginCommand for Counter {
519        fn command(&self) -> clap::Command {
520            clap::Command::new("count").about("Increment a counter")
521        }
522        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
523            self.0.fetch_add(1, Ordering::SeqCst);
524            Ok(())
525        }
526    }
527
528    struct OnePlugin {
529        name: &'static str,
530        cmd: Box<dyn Fn() -> Box<dyn PluginCommand> + Send + Sync>,
531    }
532
533    impl Plugin for OnePlugin {
534        fn name(&self) -> &'static str {
535            self.name
536        }
537        fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
538            vec![(self.cmd)()]
539        }
540    }
541
542    #[tokio::test]
543    async fn empty_plugin_list_is_unmatched() {
544        let plugins: Vec<Box<dyn Plugin>> = Vec::new();
545        let out = dispatch(&plugins, ["argv0"]).await.unwrap();
546        assert!(matches!(out, DispatchOutcome::Unmatched));
547    }
548
549    #[tokio::test]
550    async fn matched_command_runs_its_handler() {
551        let counter = Arc::new(AtomicUsize::new(0));
552        let c = counter.clone();
553        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
554            name: "one",
555            cmd: Box::new(move || Box::new(Counter(c.clone()))),
556        })];
557        let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
558        assert!(matches!(out, DispatchOutcome::Matched(name) if name == "count"));
559        assert_eq!(counter.load(Ordering::SeqCst), 1);
560    }
561
562    #[tokio::test]
563    async fn duplicate_command_name_across_plugins_is_dropped() {
564        let counter_a = Arc::new(AtomicUsize::new(0));
565        let counter_b = Arc::new(AtomicUsize::new(0));
566        let ca = counter_a.clone();
567        let cb = counter_b.clone();
568        let plugins: Vec<Box<dyn Plugin>> = vec![
569            Box::new(OnePlugin {
570                name: "first",
571                cmd: Box::new(move || Box::new(Counter(ca.clone()))),
572            }),
573            Box::new(OnePlugin {
574                name: "second",
575                cmd: Box::new(move || Box::new(Counter(cb.clone()))),
576            }),
577        ];
578        let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
579        assert!(matches!(out, DispatchOutcome::Matched(_)));
580        // The FIRST-registered plugin's command wins.
581        assert_eq!(counter_a.load(Ordering::SeqCst), 1);
582        assert_eq!(counter_b.load(Ordering::SeqCst), 0);
583    }
584
585    struct NoAboutCmd;
586
587    #[async_trait]
588    impl PluginCommand for NoAboutCmd {
589        fn command(&self) -> clap::Command {
590            // Deliberately no `.about(...)` — exercises the blank-desc path.
591            clap::Command::new("tasks-worker")
592        }
593        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
594            Ok(())
595        }
596    }
597
598    struct AboutCmd;
599
600    #[async_trait]
601    impl PluginCommand for AboutCmd {
602        fn command(&self) -> clap::Command {
603            clap::Command::new("tasks-worker").about("Run the task worker")
604        }
605        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
606            Ok(())
607        }
608    }
609
610    fn plugin_with(cmd: fn() -> Box<dyn PluginCommand>) -> Box<dyn Plugin> {
611        Box::new(OnePlugin {
612            name: "tasks",
613            cmd: Box::new(cmd),
614        })
615    }
616
617    #[test]
618    fn command_catalog_collects_name_and_about() {
619        let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(AboutCmd))];
620        let cat = command_catalog(&plugins);
621        assert_eq!(cat.len(), 1);
622        assert_eq!(cat[0].0, "tasks-worker");
623        assert_eq!(cat[0].1.as_deref(), Some("Run the task worker"));
624    }
625
626    #[test]
627    fn command_catalog_lists_command_without_about_as_none() {
628        let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(NoAboutCmd))];
629        let cat = command_catalog(&plugins);
630        assert_eq!(cat.len(), 1);
631        assert_eq!(cat[0].0, "tasks-worker");
632        assert_eq!(cat[0].1, None);
633    }
634
635    #[test]
636    fn render_help_aligns_and_shows_dash_for_blank() {
637        // A built-in-style entry, a plugin entry with about, one without.
638        let catalog = vec![
639            (
640                "migrate".to_string(),
641                Some("Apply pending migrations".to_string()),
642            ),
643            (
644                "tasks-worker".to_string(),
645                Some("Run the task worker".to_string()),
646            ),
647            ("blank".to_string(), None),
648        ];
649        let out = render_help(&catalog);
650
651        // Both descriptions present.
652        assert!(
653            out.contains("Apply pending migrations"),
654            "missing built-in desc:\n{out}"
655        );
656        assert!(
657            out.contains("Run the task worker"),
658            "missing plugin desc:\n{out}"
659        );
660        // Blank-about command shows a dash.
661        assert!(
662            out.contains("blank") && out.contains(" -\n"),
663            "missing dash for blank:\n{out}"
664        );
665        // Column alignment: the longest name is `tasks-worker` (12). The
666        // shorter `migrate` row pads its name out to the same column, so
667        // its description starts at the same offset.
668        let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
669        let migrate_line = out.lines().find(|l| l.contains("migrate")).unwrap();
670        let worker_desc_col = worker_line.find("Run the task worker").unwrap();
671        let migrate_desc_col = migrate_line.find("Apply pending migrations").unwrap();
672        assert_eq!(
673            worker_desc_col, migrate_desc_col,
674            "descriptions not column-aligned:\n{out}"
675        );
676        // Sorted by name: blank < migrate < tasks-worker.
677        let bi = out.find("\n  blank").unwrap();
678        let mi = out.find("\n  migrate").unwrap();
679        let ti = out.find("\n  tasks-worker").unwrap();
680        assert!(bi < mi && mi < ti, "commands not sorted by name:\n{out}");
681    }
682
683    #[test]
684    fn render_help_dedups_first_wins() {
685        // Built-in `migrate` placed first should win over a plugin that
686        // also registers `migrate` with a different description.
687        let catalog = vec![
688            (
689                "migrate".to_string(),
690                Some("Apply pending migrations".to_string()),
691            ),
692            ("migrate".to_string(), Some("a plugin override".to_string())),
693        ];
694        let out = render_help(&catalog);
695        assert!(out.contains("Apply pending migrations"), "{out}");
696        assert!(!out.contains("a plugin override"), "{out}");
697    }
698
699    #[tokio::test]
700    async fn help_request_returns_help_outcome() {
701        let counter = Arc::new(AtomicUsize::new(0));
702        let c = counter.clone();
703        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
704            name: "one",
705            cmd: Box::new(move || Box::new(Counter(c.clone()))),
706        })];
707        let out = dispatch(&plugins, ["argv0", "--help"]).await.unwrap();
708        assert!(
709            matches!(out, DispatchOutcome::Help(text) if text.contains("count")),
710            "expected Help with subcommand listed"
711        );
712        // Handler did NOT run on --help.
713        assert_eq!(counter.load(Ordering::SeqCst), 0);
714    }
715}