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}