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
489    // One shared column width across every group, so descriptions line up in
490    // the same place no matter which section a command sits in.
491    let width = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0).max(4);
492    // Where a description starts: two-space left margin + name + three-space
493    // gutter. Wrapped description lines hang-indent to the same column.
494    let desc_col = 2 + width + 3;
495
496    // Ordered command groups. A command whose name isn't listed here (a plugin
497    // or app command) falls through to the final "Other commands" section, so
498    // the built-in grouping never hides a third-party command.
499    const GROUPS: &[(&str, &[&str])] = &[
500        (
501            "Create a project or plugin",
502            &["startproject", "startapp", "startplugin", "startcommand"],
503        ),
504        ("Run the app", &["serve", "dev"]),
505        (
506            "Database & migrations",
507            &[
508                "migrate",
509                "makemigrations",
510                "showmigrations",
511                "checkmigrations",
512                "squashmigrations",
513                "inspectdb",
514                "transferdata",
515                "dumpdata",
516                "loaddata",
517                "importcsv",
518            ],
519        ),
520        (
521            "Generate & utilities",
522            &["typegen", "maskkeygen", "gen-client"],
523        ),
524    ];
525
526    let mut s = String::new();
527    s.push_str("umbral - manage your umbral app\n\n");
528    s.push_str("Usage:\n  umbral <command> [options]\n");
529
530    let mut rendered: std::collections::HashSet<&str> = std::collections::HashSet::new();
531    for (title, names) in GROUPS {
532        let mut group: Vec<(&str, &str)> = rows
533            .iter()
534            .filter(|(n, _)| names.contains(n))
535            .copied()
536            .collect();
537        push_group(&mut s, title, &mut group, width, desc_col);
538        for (n, _) in &group {
539            rendered.insert(*n);
540        }
541    }
542    // Everything not claimed by a built-in group: plugin + app commands, plus
543    // any new built-in that hasn't been slotted into a group above.
544    let mut other: Vec<(&str, &str)> = rows
545        .iter()
546        .filter(|(n, _)| !rendered.contains(n))
547        .copied()
548        .collect();
549    push_group(&mut s, "Other commands", &mut other, width, desc_col);
550
551    s.push('\n');
552    s.push_str("Run `umbral <command> --help` for command-specific help.\n");
553    s
554}
555
556/// Render one titled group of commands into `s`. Rows are sorted by name;
557/// descriptions align to `desc_col` and long ones wrap with a hanging indent.
558/// A no-op for an empty group (so unused sections don't print an empty header).
559fn push_group(
560    s: &mut String,
561    title: &str,
562    rows: &mut [(&str, &str)],
563    width: usize,
564    desc_col: usize,
565) {
566    if rows.is_empty() {
567        return;
568    }
569    rows.sort_by(|a, b| a.0.cmp(b.0));
570    s.push('\n');
571    s.push_str(title);
572    s.push_str(":\n\n");
573    let last = rows.len().saturating_sub(1);
574    for (i, (name, desc)) in rows.iter().enumerate() {
575        let desc = if desc.is_empty() { "-" } else { desc };
576        // First line of a multi-line `about` is the summary.
577        let summary = desc.lines().next().unwrap_or("-");
578        let wrapped = wrap_hanging(summary, desc_col, 96);
579        s.push_str(&format!("  {name:<width$}   {wrapped}\n"));
580        // A blank line between entries so a multi-line (wrapped) description
581        // doesn't run straight into the next command.
582        if i != last {
583            s.push('\n');
584        }
585    }
586}
587
588/// Word-wrap `text` so that no line exceeds `max_col` columns, hanging-indenting
589/// every line after the first to `indent` spaces (the description column). The
590/// first line carries no indent — the caller has already emitted the
591/// `  <name>   ` prefix that puts it at `indent`.
592fn wrap_hanging(text: &str, indent: usize, max_col: usize) -> String {
593    let avail = max_col.saturating_sub(indent).max(24);
594    let mut out = String::new();
595    let mut line_len = 0usize;
596    for (i, word) in text.split_whitespace().enumerate() {
597        if i == 0 {
598            out.push_str(word);
599            line_len = word.len();
600        } else if line_len + 1 + word.len() > avail {
601            out.push('\n');
602            out.push_str(&" ".repeat(indent));
603            out.push_str(word);
604            line_len = word.len();
605        } else {
606            out.push(' ');
607            out.push_str(word);
608            line_len += 1 + word.len();
609        }
610    }
611    out
612}
613
614#[cfg(test)]
615mod tests {
616    use std::sync::Arc;
617    use std::sync::atomic::{AtomicUsize, Ordering};
618
619    use super::*;
620    use crate::plugin::Plugin;
621
622    struct Counter(Arc<AtomicUsize>);
623
624    #[async_trait]
625    impl PluginCommand for Counter {
626        fn command(&self) -> clap::Command {
627            clap::Command::new("count").about("Increment a counter")
628        }
629        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
630            self.0.fetch_add(1, Ordering::SeqCst);
631            Ok(())
632        }
633    }
634
635    struct OnePlugin {
636        name: &'static str,
637        cmd: Box<dyn Fn() -> Box<dyn PluginCommand> + Send + Sync>,
638    }
639
640    impl Plugin for OnePlugin {
641        fn name(&self) -> &'static str {
642            self.name
643        }
644        fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
645            vec![(self.cmd)()]
646        }
647    }
648
649    #[tokio::test]
650    async fn empty_plugin_list_is_unmatched() {
651        let plugins: Vec<Box<dyn Plugin>> = Vec::new();
652        let out = dispatch(&plugins, ["argv0"]).await.unwrap();
653        assert!(matches!(out, DispatchOutcome::Unmatched));
654    }
655
656    #[tokio::test]
657    async fn matched_command_runs_its_handler() {
658        let counter = Arc::new(AtomicUsize::new(0));
659        let c = counter.clone();
660        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
661            name: "one",
662            cmd: Box::new(move || Box::new(Counter(c.clone()))),
663        })];
664        let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
665        assert!(matches!(out, DispatchOutcome::Matched(name) if name == "count"));
666        assert_eq!(counter.load(Ordering::SeqCst), 1);
667    }
668
669    #[tokio::test]
670    async fn duplicate_command_name_across_plugins_is_dropped() {
671        let counter_a = Arc::new(AtomicUsize::new(0));
672        let counter_b = Arc::new(AtomicUsize::new(0));
673        let ca = counter_a.clone();
674        let cb = counter_b.clone();
675        let plugins: Vec<Box<dyn Plugin>> = vec![
676            Box::new(OnePlugin {
677                name: "first",
678                cmd: Box::new(move || Box::new(Counter(ca.clone()))),
679            }),
680            Box::new(OnePlugin {
681                name: "second",
682                cmd: Box::new(move || Box::new(Counter(cb.clone()))),
683            }),
684        ];
685        let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
686        assert!(matches!(out, DispatchOutcome::Matched(_)));
687        // The FIRST-registered plugin's command wins.
688        assert_eq!(counter_a.load(Ordering::SeqCst), 1);
689        assert_eq!(counter_b.load(Ordering::SeqCst), 0);
690    }
691
692    struct NoAboutCmd;
693
694    #[async_trait]
695    impl PluginCommand for NoAboutCmd {
696        fn command(&self) -> clap::Command {
697            // Deliberately no `.about(...)` — exercises the blank-desc path.
698            clap::Command::new("tasks-worker")
699        }
700        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
701            Ok(())
702        }
703    }
704
705    struct AboutCmd;
706
707    #[async_trait]
708    impl PluginCommand for AboutCmd {
709        fn command(&self) -> clap::Command {
710            clap::Command::new("tasks-worker").about("Run the task worker")
711        }
712        async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
713            Ok(())
714        }
715    }
716
717    fn plugin_with(cmd: fn() -> Box<dyn PluginCommand>) -> Box<dyn Plugin> {
718        Box::new(OnePlugin {
719            name: "tasks",
720            cmd: Box::new(cmd),
721        })
722    }
723
724    #[test]
725    fn command_catalog_collects_name_and_about() {
726        let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(AboutCmd))];
727        let cat = command_catalog(&plugins);
728        assert_eq!(cat.len(), 1);
729        assert_eq!(cat[0].0, "tasks-worker");
730        assert_eq!(cat[0].1.as_deref(), Some("Run the task worker"));
731    }
732
733    #[test]
734    fn command_catalog_lists_command_without_about_as_none() {
735        let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(NoAboutCmd))];
736        let cat = command_catalog(&plugins);
737        assert_eq!(cat.len(), 1);
738        assert_eq!(cat[0].0, "tasks-worker");
739        assert_eq!(cat[0].1, None);
740    }
741
742    #[test]
743    fn render_help_aligns_and_shows_dash_for_blank() {
744        // A built-in-style entry, a plugin entry with about, one without.
745        let catalog = vec![
746            (
747                "migrate".to_string(),
748                Some("Apply pending migrations".to_string()),
749            ),
750            (
751                "tasks-worker".to_string(),
752                Some("Run the task worker".to_string()),
753            ),
754            ("blank".to_string(), None),
755        ];
756        let out = render_help(&catalog);
757
758        // Both descriptions present.
759        assert!(
760            out.contains("Apply pending migrations"),
761            "missing built-in desc:\n{out}"
762        );
763        assert!(
764            out.contains("Run the task worker"),
765            "missing plugin desc:\n{out}"
766        );
767        // Blank-about command shows a dash.
768        assert!(
769            out.contains("blank") && out.contains(" -\n"),
770            "missing dash for blank:\n{out}"
771        );
772        // Column alignment holds across groups: the longest name is
773        // `tasks-worker` (12), and every row pads its name to that shared
774        // width, so descriptions start at the same offset even though
775        // `migrate` and `tasks-worker` live in different sections.
776        let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
777        let migrate_line = out.lines().find(|l| l.contains("migrate")).unwrap();
778        let worker_desc_col = worker_line.find("Run the task worker").unwrap();
779        let migrate_desc_col = migrate_line.find("Apply pending migrations").unwrap();
780        assert_eq!(
781            worker_desc_col, migrate_desc_col,
782            "descriptions not column-aligned:\n{out}"
783        );
784        // Grouped: `migrate` is a built-in under "Database & migrations";
785        // `tasks-worker` and `blank` are plugin/app commands under "Other
786        // commands". The database section renders before the other section.
787        assert!(
788            out.contains("Database & migrations:"),
789            "missing DB group header:\n{out}"
790        );
791        assert!(
792            out.contains("Other commands:"),
793            "missing other group header:\n{out}"
794        );
795        let mi = out.find("\n  migrate").unwrap();
796        let bi = out.find("\n  blank").unwrap();
797        let ti = out.find("\n  tasks-worker").unwrap();
798        // migrate (Database group) precedes the Other group; within Other,
799        // rows are sorted so blank precedes tasks-worker.
800        assert!(mi < bi, "database group should render first:\n{out}");
801        assert!(bi < ti, "other-group rows not sorted by name:\n{out}");
802    }
803
804    #[test]
805    fn render_help_dedups_first_wins() {
806        // Built-in `migrate` placed first should win over a plugin that
807        // also registers `migrate` with a different description.
808        let catalog = vec![
809            (
810                "migrate".to_string(),
811                Some("Apply pending migrations".to_string()),
812            ),
813            ("migrate".to_string(), Some("a plugin override".to_string())),
814        ];
815        let out = render_help(&catalog);
816        assert!(out.contains("Apply pending migrations"), "{out}");
817        assert!(!out.contains("a plugin override"), "{out}");
818    }
819
820    #[tokio::test]
821    async fn help_request_returns_help_outcome() {
822        let counter = Arc::new(AtomicUsize::new(0));
823        let c = counter.clone();
824        let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
825            name: "one",
826            cmd: Box::new(move || Box::new(Counter(c.clone()))),
827        })];
828        let out = dispatch(&plugins, ["argv0", "--help"]).await.unwrap();
829        assert!(
830            matches!(out, DispatchOutcome::Help(text) if text.contains("count")),
831            "expected Help with subcommand listed"
832        );
833        // Handler did NOT run on --help.
834        assert_eq!(counter.load(Ordering::SeqCst), 0);
835    }
836}