use std::ffi::OsString;
use async_trait::async_trait;
use clap::ArgMatches;
use crate::plugin::Plugin;
pub type CliError = Box<dyn std::error::Error + Send + Sync>;
#[async_trait]
pub trait PluginCommand: Send + Sync + 'static {
fn command(&self) -> clap::Command;
async fn run(&self, matches: &ArgMatches) -> Result<(), CliError>;
}
#[derive(Debug)]
pub enum DispatchOutcome {
Matched(String),
Unmatched,
Help(String),
}
pub async fn dispatch<I, T>(
plugins: &[Box<dyn Plugin>],
args: I,
) -> Result<DispatchOutcome, CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut commands: Vec<(String, Box<dyn PluginCommand>)> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for plugin in plugins {
for cmd in plugin.commands() {
let name = cmd.command().get_name().to_string();
if !seen.insert(name.clone()) {
tracing::warn!(
target: "umbral::cli",
"duplicate plugin command `{name}` from `{}`; ignoring",
plugin.name()
);
continue;
}
commands.push((name, cmd));
}
}
if commands.is_empty() {
return Ok(DispatchOutcome::Unmatched);
}
let mut root = clap::Command::new("umbral")
.about("umbral plugin subcommands")
.disable_help_subcommand(true)
.subcommand_required(false)
.arg_required_else_help(false);
for (_, cmd) in &commands {
root = root.subcommand(cmd.command());
}
let owned: Vec<OsString> = args.into_iter().map(|t| t.into()).collect();
let matches = match root.clone().try_get_matches_from(owned) {
Ok(m) => m,
Err(e) => {
return match e.kind() {
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
Ok(DispatchOutcome::Help(e.render().to_string()))
}
clap::error::ErrorKind::InvalidSubcommand
| clap::error::ErrorKind::UnknownArgument => Ok(DispatchOutcome::Unmatched),
_ => Err(Box::new(e)),
};
}
};
let (name, sub_matches) = match matches.subcommand() {
Some((n, m)) => (n.to_string(), m.clone()),
None => return Ok(DispatchOutcome::Unmatched),
};
for (cmd_name, cmd) in &commands {
if cmd_name == &name {
cmd.run(&sub_matches).await?;
return Ok(DispatchOutcome::Matched(name));
}
}
Ok(DispatchOutcome::Unmatched)
}
pub fn command_catalog(plugins: &[Box<dyn Plugin>]) -> Vec<(String, Option<String>)> {
let mut out: Vec<(String, Option<String>)> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for plugin in plugins {
for cmd in plugin.commands() {
let clap_cmd = cmd.command();
let name = clap_cmd.get_name().to_string();
if !seen.insert(name.clone()) {
continue;
}
let about = clap_cmd.get_about().map(|s| s.to_string());
if about.is_none() {
tracing::debug!(
target: "umbral::cli",
"plugin command `{name}` (from `{}`) has no `about`; \
it lists with a blank description. Add `.about(...)` so \
users can discover what it does.",
plugin.name()
);
}
out.push((name, about));
}
}
out
}
pub fn render_help(catalog: &[(String, Option<String>)]) -> String {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut rows: Vec<(&str, &str)> = Vec::new();
for (name, about) in catalog {
if !seen.insert(name.as_str()) {
continue;
}
let desc = about.as_deref().map(str::trim).unwrap_or("");
rows.push((name.as_str(), desc));
}
rows.sort_by(|a, b| a.0.cmp(b.0));
let width = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
let mut s = String::new();
s.push_str("umbral — manage your umbral app\n\n");
s.push_str("Usage: umbral <command> [options]\n\n");
s.push_str("Commands:\n");
for (name, desc) in &rows {
let desc = if desc.is_empty() { "-" } else { desc };
let summary = desc.lines().next().unwrap_or("-");
s.push_str(&format!(" {name:<width$} {summary}\n"));
}
s.push('\n');
s.push_str("Run `umbral <command> --help` for command-specific help.\n");
s
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::plugin::Plugin;
struct Counter(Arc<AtomicUsize>);
#[async_trait]
impl PluginCommand for Counter {
fn command(&self) -> clap::Command {
clap::Command::new("count").about("Increment a counter")
}
async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
struct OnePlugin {
name: &'static str,
cmd: Box<dyn Fn() -> Box<dyn PluginCommand> + Send + Sync>,
}
impl Plugin for OnePlugin {
fn name(&self) -> &'static str {
self.name
}
fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
vec![(self.cmd)()]
}
}
#[tokio::test]
async fn empty_plugin_list_is_unmatched() {
let plugins: Vec<Box<dyn Plugin>> = Vec::new();
let out = dispatch(&plugins, ["argv0"]).await.unwrap();
assert!(matches!(out, DispatchOutcome::Unmatched));
}
#[tokio::test]
async fn matched_command_runs_its_handler() {
let counter = Arc::new(AtomicUsize::new(0));
let c = counter.clone();
let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
name: "one",
cmd: Box::new(move || Box::new(Counter(c.clone()))),
})];
let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
assert!(matches!(out, DispatchOutcome::Matched(name) if name == "count"));
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn duplicate_command_name_across_plugins_is_dropped() {
let counter_a = Arc::new(AtomicUsize::new(0));
let counter_b = Arc::new(AtomicUsize::new(0));
let ca = counter_a.clone();
let cb = counter_b.clone();
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(OnePlugin {
name: "first",
cmd: Box::new(move || Box::new(Counter(ca.clone()))),
}),
Box::new(OnePlugin {
name: "second",
cmd: Box::new(move || Box::new(Counter(cb.clone()))),
}),
];
let out = dispatch(&plugins, ["argv0", "count"]).await.unwrap();
assert!(matches!(out, DispatchOutcome::Matched(_)));
assert_eq!(counter_a.load(Ordering::SeqCst), 1);
assert_eq!(counter_b.load(Ordering::SeqCst), 0);
}
struct NoAboutCmd;
#[async_trait]
impl PluginCommand for NoAboutCmd {
fn command(&self) -> clap::Command {
clap::Command::new("tasks-worker")
}
async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
Ok(())
}
}
struct AboutCmd;
#[async_trait]
impl PluginCommand for AboutCmd {
fn command(&self) -> clap::Command {
clap::Command::new("tasks-worker").about("Run the task worker")
}
async fn run(&self, _matches: &ArgMatches) -> Result<(), CliError> {
Ok(())
}
}
fn plugin_with(cmd: fn() -> Box<dyn PluginCommand>) -> Box<dyn Plugin> {
Box::new(OnePlugin {
name: "tasks",
cmd: Box::new(cmd),
})
}
#[test]
fn command_catalog_collects_name_and_about() {
let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(AboutCmd))];
let cat = command_catalog(&plugins);
assert_eq!(cat.len(), 1);
assert_eq!(cat[0].0, "tasks-worker");
assert_eq!(cat[0].1.as_deref(), Some("Run the task worker"));
}
#[test]
fn command_catalog_lists_command_without_about_as_none() {
let plugins: Vec<Box<dyn Plugin>> = vec![plugin_with(|| Box::new(NoAboutCmd))];
let cat = command_catalog(&plugins);
assert_eq!(cat.len(), 1);
assert_eq!(cat[0].0, "tasks-worker");
assert_eq!(cat[0].1, None);
}
#[test]
fn render_help_aligns_and_shows_dash_for_blank() {
let catalog = vec![
(
"migrate".to_string(),
Some("Apply pending migrations".to_string()),
),
(
"tasks-worker".to_string(),
Some("Run the task worker".to_string()),
),
("blank".to_string(), None),
];
let out = render_help(&catalog);
assert!(
out.contains("Apply pending migrations"),
"missing built-in desc:\n{out}"
);
assert!(
out.contains("Run the task worker"),
"missing plugin desc:\n{out}"
);
assert!(
out.contains("blank") && out.contains(" -\n"),
"missing dash for blank:\n{out}"
);
let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
let migrate_line = out.lines().find(|l| l.contains("migrate")).unwrap();
let worker_desc_col = worker_line.find("Run the task worker").unwrap();
let migrate_desc_col = migrate_line.find("Apply pending migrations").unwrap();
assert_eq!(
worker_desc_col, migrate_desc_col,
"descriptions not column-aligned:\n{out}"
);
let bi = out.find("\n blank").unwrap();
let mi = out.find("\n migrate").unwrap();
let ti = out.find("\n tasks-worker").unwrap();
assert!(bi < mi && mi < ti, "commands not sorted by name:\n{out}");
}
#[test]
fn render_help_dedups_first_wins() {
let catalog = vec![
(
"migrate".to_string(),
Some("Apply pending migrations".to_string()),
),
("migrate".to_string(), Some("a plugin override".to_string())),
];
let out = render_help(&catalog);
assert!(out.contains("Apply pending migrations"), "{out}");
assert!(!out.contains("a plugin override"), "{out}");
}
#[tokio::test]
async fn help_request_returns_help_outcome() {
let counter = Arc::new(AtomicUsize::new(0));
let c = counter.clone();
let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(OnePlugin {
name: "one",
cmd: Box::new(move || Box::new(Counter(c.clone()))),
})];
let out = dispatch(&plugins, ["argv0", "--help"]).await.unwrap();
assert!(
matches!(out, DispatchOutcome::Help(text) if text.contains("count")),
"expected Help with subcommand listed"
);
assert_eq!(counter.load(Ordering::SeqCst), 0);
}
}