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}