use std::ffi::OsString;
use async_trait::async_trait;
use clap::ArgMatches;
use crate::plugin::Plugin;
pub use clap;
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>;
fn needs_ready(&self) -> bool {
true
}
}
#[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,
{
dispatch_with_app_commands(&[], plugins, &[], args).await
}
pub async fn dispatch_with_app_commands<I, T>(
app_commands: &[Box<dyn PluginCommand>],
plugins: &[Box<dyn Plugin>],
reserved: &[&str],
args: I,
) -> Result<DispatchOutcome, CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
CommandSet::collect(app_commands, plugins, reserved)
.dispatch(args)
.await
}
enum CommandHandle<'a> {
Borrowed(&'a dyn PluginCommand),
Owned(Box<dyn PluginCommand>),
}
impl CommandHandle<'_> {
fn get(&self) -> &dyn PluginCommand {
match self {
Self::Borrowed(c) => *c,
Self::Owned(c) => c.as_ref(),
}
}
}
pub struct CommandSet<'a> {
entries: Vec<Entry<'a>>,
}
struct Entry<'a> {
name: String,
clap: clap::Command,
handle: CommandHandle<'a>,
}
impl<'a> CommandSet<'a> {
pub fn collect(
app_commands: &'a [Box<dyn PluginCommand>],
plugins: &'a [Box<dyn Plugin>],
reserved: &[&str],
) -> Self {
Self {
entries: collect_commands(app_commands, plugins, reserved),
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn needs_ready(&self, name: &str) -> Option<bool> {
self.entries
.iter()
.find(|e| e.name == name)
.map(|e| e.handle.get().needs_ready())
}
pub fn catalog(&self) -> Vec<(String, Option<String>)> {
self.entries
.iter()
.map(|e| {
let about = e.clap.get_about().map(|s| s.to_string());
if about.is_none() {
tracing::debug!(
target: "umbral::cli",
"command `{}` has no `about`; it lists with a blank description. \
Add `.about(...)` so users can discover what it does.",
e.name,
);
}
(e.name.clone(), about)
})
.collect()
}
pub async fn dispatch<I, T>(&self, args: I) -> Result<DispatchOutcome, CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
if self.entries.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 entry in &self.entries {
root = root.subcommand(entry.clap.clone());
}
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 entry in &self.entries {
if entry.name == name {
entry.handle.get().run(&sub_matches).await?;
return Ok(DispatchOutcome::Matched(name));
}
}
Ok(DispatchOutcome::Unmatched)
}
}
fn collect_commands<'a>(
app_commands: &'a [Box<dyn PluginCommand>],
plugins: &'a [Box<dyn Plugin>],
reserved: &[&str],
) -> Vec<Entry<'a>> {
let mut commands: Vec<Entry<'a>> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let shadow = |name: &str, source: &str| {
if !reserved.contains(&name) {
return false;
}
eprintln!(
"warning: {source} registers a command named `{name}`, which is a framework \
built-in. The built-in wins and the registered one is IGNORED — rename it. \
(Without this, `{name}` would silently run your command instead of the \
framework's.)"
);
tracing::warn!(
target: "umbral::cli",
"{source} command `{name}` shadows a framework built-in; ignoring it",
);
true
};
for cmd in app_commands {
let clap = cmd.command();
let name = clap.get_name().to_string();
if shadow(&name, "the app") {
continue;
}
if !seen.insert(name.clone()) {
tracing::warn!(
target: "umbral::cli",
"app command `{name}` is registered twice on the App builder; \
ignoring the second",
);
continue;
}
commands.push(Entry {
name,
clap,
handle: CommandHandle::Borrowed(cmd.as_ref()),
});
}
for plugin in plugins {
for cmd in plugin.commands() {
let clap = cmd.command();
let name = clap.get_name().to_string();
if shadow(&name, &format!("plugin `{}`", plugin.name())) {
continue;
}
if !seen.insert(name.clone()) {
tracing::warn!(
target: "umbral::cli",
"duplicate command `{name}` from plugin `{}`; ignoring (an \
earlier plugin — or the app itself — registered it first)",
plugin.name()
);
continue;
}
commands.push(Entry {
name,
clap,
handle: CommandHandle::Owned(cmd),
});
}
}
commands
}
pub fn command_needs_ready(
app_commands: &[Box<dyn PluginCommand>],
plugins: &[Box<dyn Plugin>],
name: &str,
reserved: &[&str],
) -> Option<bool> {
CommandSet::collect(app_commands, plugins, reserved).needs_ready(name)
}
pub fn command_catalog(plugins: &[Box<dyn Plugin>]) -> Vec<(String, Option<String>)> {
command_catalog_with_app_commands(&[], plugins, &[])
}
pub fn command_catalog_with_app_commands(
app_commands: &[Box<dyn PluginCommand>],
plugins: &[Box<dyn Plugin>],
reserved: &[&str],
) -> Vec<(String, Option<String>)> {
CommandSet::collect(app_commands, plugins, reserved).catalog()
}
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));
}
let width = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0).max(4);
let desc_col = 2 + width + 3;
const GROUPS: &[(&str, &[&str])] = &[
(
"Create a project or plugin",
&["startproject", "startapp", "startplugin", "startcommand"],
),
("Run the app", &["serve", "dev"]),
(
"Database & migrations",
&[
"migrate",
"makemigrations",
"showmigrations",
"checkmigrations",
"squashmigrations",
"inspectdb",
"transferdata",
"dumpdata",
"loaddata",
"importcsv",
],
),
(
"Generate & utilities",
&["typegen", "maskkeygen", "gen-client"],
),
];
let mut s = String::new();
s.push_str("umbral - manage your umbral app\n\n");
s.push_str("Usage:\n umbral <command> [options]\n");
let mut rendered: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (title, names) in GROUPS {
let mut group: Vec<(&str, &str)> = rows
.iter()
.filter(|(n, _)| names.contains(n))
.copied()
.collect();
push_group(&mut s, title, &mut group, width, desc_col);
for (n, _) in &group {
rendered.insert(*n);
}
}
let mut other: Vec<(&str, &str)> = rows
.iter()
.filter(|(n, _)| !rendered.contains(n))
.copied()
.collect();
push_group(&mut s, "Other commands", &mut other, width, desc_col);
s.push('\n');
s.push_str("Run `umbral <command> --help` for command-specific help.\n");
s
}
fn push_group(
s: &mut String,
title: &str,
rows: &mut [(&str, &str)],
width: usize,
desc_col: usize,
) {
if rows.is_empty() {
return;
}
rows.sort_by(|a, b| a.0.cmp(b.0));
s.push('\n');
s.push_str(title);
s.push_str(":\n\n");
let last = rows.len().saturating_sub(1);
for (i, (name, desc)) in rows.iter().enumerate() {
let desc = if desc.is_empty() { "-" } else { desc };
let summary = desc.lines().next().unwrap_or("-");
let wrapped = wrap_hanging(summary, desc_col, 96);
s.push_str(&format!(" {name:<width$} {wrapped}\n"));
if i != last {
s.push('\n');
}
}
}
fn wrap_hanging(text: &str, indent: usize, max_col: usize) -> String {
let avail = max_col.saturating_sub(indent).max(24);
let mut out = String::new();
let mut line_len = 0usize;
for (i, word) in text.split_whitespace().enumerate() {
if i == 0 {
out.push_str(word);
line_len = word.len();
} else if line_len + 1 + word.len() > avail {
out.push('\n');
out.push_str(&" ".repeat(indent));
out.push_str(word);
line_len = word.len();
} else {
out.push(' ');
out.push_str(word);
line_len += 1 + word.len();
}
}
out
}
#[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}"
);
assert!(
out.contains("Database & migrations:"),
"missing DB group header:\n{out}"
);
assert!(
out.contains("Other commands:"),
"missing other group header:\n{out}"
);
let mi = out.find("\n migrate").unwrap();
let bi = out.find("\n blank").unwrap();
let ti = out.find("\n tasks-worker").unwrap();
assert!(mi < bi, "database group should render first:\n{out}");
assert!(bi < ti, "other-group rows 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);
}
}