1use std::ffi::OsString;
55
56use async_trait::async_trait;
57use clap::ArgMatches;
58
59use crate::plugin::Plugin;
60
61pub type CliError = Box<dyn std::error::Error + Send + Sync>;
65
66#[async_trait]
68pub trait PluginCommand: Send + Sync + 'static {
69 fn command(&self) -> clap::Command;
74
75 async fn run(&self, matches: &ArgMatches) -> Result<(), CliError>;
79}
80
81#[derive(Debug)]
86pub enum DispatchOutcome {
87 Matched(String),
90 Unmatched,
94 Help(String),
98}
99
100pub async fn dispatch<I, T>(
112 plugins: &[Box<dyn Plugin>],
113 args: I,
114) -> Result<DispatchOutcome, CliError>
115where
116 I: IntoIterator<Item = T>,
117 T: Into<OsString> + Clone,
118{
119 let mut commands: Vec<(String, Box<dyn PluginCommand>)> = Vec::new();
123 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
124 for plugin in plugins {
125 for cmd in plugin.commands() {
126 let name = cmd.command().get_name().to_string();
127 if !seen.insert(name.clone()) {
128 tracing::warn!(
129 target: "umbral::cli",
130 "duplicate plugin command `{name}` from `{}`; ignoring",
131 plugin.name()
132 );
133 continue;
134 }
135 commands.push((name, cmd));
136 }
137 }
138
139 if commands.is_empty() {
141 return Ok(DispatchOutcome::Unmatched);
142 }
143
144 let mut root = clap::Command::new("umbral")
145 .about("umbral plugin subcommands")
146 .disable_help_subcommand(true)
147 .subcommand_required(false)
148 .arg_required_else_help(false);
149 for (_, cmd) in &commands {
150 root = root.subcommand(cmd.command());
151 }
152
153 let owned: Vec<OsString> = args.into_iter().map(|t| t.into()).collect();
156 let matches = match root.clone().try_get_matches_from(owned) {
157 Ok(m) => m,
158 Err(e) => {
159 return match e.kind() {
169 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
170 Ok(DispatchOutcome::Help(e.render().to_string()))
171 }
172 clap::error::ErrorKind::InvalidSubcommand
173 | clap::error::ErrorKind::UnknownArgument => Ok(DispatchOutcome::Unmatched),
174 _ => Err(Box::new(e)),
175 };
176 }
177 };
178
179 let (name, sub_matches) = match matches.subcommand() {
180 Some((n, m)) => (n.to_string(), m.clone()),
181 None => return Ok(DispatchOutcome::Unmatched),
182 };
183
184 for (cmd_name, cmd) in &commands {
185 if cmd_name == &name {
186 cmd.run(&sub_matches).await?;
187 return Ok(DispatchOutcome::Matched(name));
188 }
189 }
190 Ok(DispatchOutcome::Unmatched)
191}
192
193pub fn command_catalog(plugins: &[Box<dyn Plugin>]) -> Vec<(String, Option<String>)> {
207 let mut out: Vec<(String, Option<String>)> = Vec::new();
208 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
209 for plugin in plugins {
210 for cmd in plugin.commands() {
211 let clap_cmd = cmd.command();
212 let name = clap_cmd.get_name().to_string();
213 if !seen.insert(name.clone()) {
214 continue;
215 }
216 let about = clap_cmd.get_about().map(|s| s.to_string());
217 if about.is_none() {
218 tracing::debug!(
219 target: "umbral::cli",
220 "plugin command `{name}` (from `{}`) has no `about`; \
221 it lists with a blank description. Add `.about(...)` so \
222 users can discover what it does.",
223 plugin.name()
224 );
225 }
226 out.push((name, about));
227 }
228 }
229 out
230}
231
232pub fn render_help(catalog: &[(String, Option<String>)]) -> String {
245 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
247 let mut rows: Vec<(&str, &str)> = Vec::new();
248 for (name, about) in catalog {
249 if !seen.insert(name.as_str()) {
250 continue;
251 }
252 let desc = about.as_deref().map(str::trim).unwrap_or("");
253 rows.push((name.as_str(), desc));
254 }
255 rows.sort_by(|a, b| a.0.cmp(b.0));
256
257 let width = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
258
259 let mut s = String::new();
260 s.push_str("umbral — manage your umbral app\n\n");
261 s.push_str("Usage: umbral <command> [options]\n\n");
262 s.push_str("Commands:\n");
263 for (name, desc) in &rows {
264 let desc = if desc.is_empty() { "-" } else { desc };
265 let summary = desc.lines().next().unwrap_or("-");
267 s.push_str(&format!(" {name:<width$} {summary}\n"));
268 }
269 s.push('\n');
270 s.push_str("Run `umbral <command> --help` for command-specific help.\n");
271 s
272}
273
274#[cfg(test)]
275mod tests {
276 use std::sync::Arc;
277 use std::sync::atomic::{AtomicUsize, Ordering};
278
279 use super::*;
280 use crate::plugin::Plugin;
281
282 struct Counter(Arc<AtomicUsize>);
283
284 #[async_trait]
285 impl PluginCommand for Counter {
286 fn command(&self) -> clap::Command {
287 clap::Command::new("count").about("Increment a counter")
288 }
289 async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
290 self.0.fetch_add(1, Ordering::SeqCst);
291 Ok(())
292 }
293 }
294
295 struct OnePlugin {
296 name: &'static str,
297 cmd: Box<dyn Fn() -> Box<dyn PluginCommand> + Send + Sync>,
298 }
299
300 impl Plugin for OnePlugin {
301 fn name(&self) -> &'static str {
302 self.name
303 }
304 fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
305 vec![(self.cmd)()]
306 }
307 }
308
309 #[tokio::test]
310 async fn empty_plugin_list_is_unmatched() {
311 let plugins: Vec<Box<dyn Plugin>> = Vec::new();
312 let out = dispatch(&plugins, ["argv0"]).await.unwrap();
313 assert!(matches!(out, DispatchOutcome::Unmatched));
314 }
315
316 #[tokio::test]
317 async fn matched_command_runs_its_handler() {
318 let counter = Arc::new(AtomicUsize::new(0));
319 let c = counter.clone();
320 let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
321 name: "one",
322 cmd: Box::new(move || Box::new(Counter(c.clone()))),
323 })];
324 let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
325 assert!(matches!(out, DispatchOutcome::Matched(name) if name == "count"));
326 assert_eq!(counter.load(Ordering::SeqCst), 1);
327 }
328
329 #[tokio::test]
330 async fn duplicate_command_name_across_plugins_is_dropped() {
331 let counter_a = Arc::new(AtomicUsize::new(0));
332 let counter_b = Arc::new(AtomicUsize::new(0));
333 let ca = counter_a.clone();
334 let cb = counter_b.clone();
335 let plugins: Vec<Box<dyn Plugin>> = vec![
336 Box::new(OnePlugin {
337 name: "first",
338 cmd: Box::new(move || Box::new(Counter(ca.clone()))),
339 }),
340 Box::new(OnePlugin {
341 name: "second",
342 cmd: Box::new(move || Box::new(Counter(cb.clone()))),
343 }),
344 ];
345 let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
346 assert!(matches!(out, DispatchOutcome::Matched(_)));
347 assert_eq!(counter_a.load(Ordering::SeqCst), 1);
349 assert_eq!(counter_b.load(Ordering::SeqCst), 0);
350 }
351
352 struct NoAboutCmd;
353
354 #[async_trait]
355 impl PluginCommand for NoAboutCmd {
356 fn command(&self) -> clap::Command {
357 clap::Command::new("tasks-worker")
359 }
360 async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
361 Ok(())
362 }
363 }
364
365 struct AboutCmd;
366
367 #[async_trait]
368 impl PluginCommand for AboutCmd {
369 fn command(&self) -> clap::Command {
370 clap::Command::new("tasks-worker").about("Run the task worker")
371 }
372 async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
373 Ok(())
374 }
375 }
376
377 fn plugin_with(cmd: fn() -> Box<dyn PluginCommand>) -> Box<dyn Plugin> {
378 Box::new(OnePlugin {
379 name: "tasks",
380 cmd: Box::new(cmd),
381 })
382 }
383
384 #[test]
385 fn command_catalog_collects_name_and_about() {
386 let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(AboutCmd))];
387 let cat = command_catalog(&plugins);
388 assert_eq!(cat.len(), 1);
389 assert_eq!(cat[0].0, "tasks-worker");
390 assert_eq!(cat[0].1.as_deref(), Some("Run the task worker"));
391 }
392
393 #[test]
394 fn command_catalog_lists_command_without_about_as_none() {
395 let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(NoAboutCmd))];
396 let cat = command_catalog(&plugins);
397 assert_eq!(cat.len(), 1);
398 assert_eq!(cat[0].0, "tasks-worker");
399 assert_eq!(cat[0].1, None);
400 }
401
402 #[test]
403 fn render_help_aligns_and_shows_dash_for_blank() {
404 let catalog = vec![
406 (
407 "migrate".to_string(),
408 Some("Apply pending migrations".to_string()),
409 ),
410 (
411 "tasks-worker".to_string(),
412 Some("Run the task worker".to_string()),
413 ),
414 ("blank".to_string(), None),
415 ];
416 let out = render_help(&catalog);
417
418 assert!(
420 out.contains("Apply pending migrations"),
421 "missing built-in desc:\n{out}"
422 );
423 assert!(
424 out.contains("Run the task worker"),
425 "missing plugin desc:\n{out}"
426 );
427 assert!(
429 out.contains("blank") && out.contains(" -\n"),
430 "missing dash for blank:\n{out}"
431 );
432 let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
436 let migrate_line = out.lines().find(|l| l.contains("migrate")).unwrap();
437 let worker_desc_col = worker_line.find("Run the task worker").unwrap();
438 let migrate_desc_col = migrate_line.find("Apply pending migrations").unwrap();
439 assert_eq!(
440 worker_desc_col, migrate_desc_col,
441 "descriptions not column-aligned:\n{out}"
442 );
443 let bi = out.find("\n blank").unwrap();
445 let mi = out.find("\n migrate").unwrap();
446 let ti = out.find("\n tasks-worker").unwrap();
447 assert!(bi < mi && mi < ti, "commands not sorted by name:\n{out}");
448 }
449
450 #[test]
451 fn render_help_dedups_first_wins() {
452 let catalog = vec![
455 (
456 "migrate".to_string(),
457 Some("Apply pending migrations".to_string()),
458 ),
459 ("migrate".to_string(), Some("a plugin override".to_string())),
460 ];
461 let out = render_help(&catalog);
462 assert!(out.contains("Apply pending migrations"), "{out}");
463 assert!(!out.contains("a plugin override"), "{out}");
464 }
465
466 #[tokio::test]
467 async fn help_request_returns_help_outcome() {
468 let counter = Arc::new(AtomicUsize::new(0));
469 let c = counter.clone();
470 let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
471 name: "one",
472 cmd: Box::new(move || Box::new(Counter(c.clone()))),
473 })];
474 let out = dispatch(&plugins, ["argv0", "--help"]).await.unwrap();
475 assert!(
476 matches!(out, DispatchOutcome::Help(text) if text.contains("count")),
477 "expected Help with subcommand listed"
478 );
479 assert_eq!(counter.load(Ordering::SeqCst), 0);
481 }
482}