use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use umbral::cli::{CliError, PluginCommand, clap};
use umbral::plugin::Plugin;
use umbral::{App, Settings};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct Parsed {
slug: String,
limit: u64,
tags: Vec<String>,
dry_run: bool,
}
struct BackfillSlugsCommand {
seen: Arc<std::sync::Mutex<Option<Parsed>>>,
}
#[umbral::async_trait]
impl PluginCommand for BackfillSlugsCommand {
fn command(&self) -> clap::Command {
clap::Command::new("backfill_slugs")
.about("Fill in empty post slugs")
.arg(clap::Arg::new("slug").required(true))
.arg(
clap::Arg::new("limit")
.long("limit")
.short('l')
.value_parser(clap::value_parser!(u64))
.default_value("25"),
)
.arg(
clap::Arg::new("tag")
.long("tag")
.action(clap::ArgAction::Append),
)
.arg(
clap::Arg::new("dry-run")
.long("dry-run")
.action(clap::ArgAction::SetTrue),
)
}
async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {
*self.seen.lock().unwrap() = Some(Parsed {
slug: matches.get_one::<String>("slug").unwrap().clone(),
limit: *matches.get_one::<u64>("limit").unwrap(),
tags: matches
.get_many::<String>("tag")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
dry_run: matches.get_flag("dry-run"),
});
Ok(())
}
}
struct PluginSideCommand {
fired: Arc<AtomicBool>,
}
#[umbral::async_trait]
impl PluginCommand for PluginSideCommand {
fn command(&self) -> clap::Command {
clap::Command::new("backfill_slugs").about("the plugin's version")
}
async fn run(&self, _m: &clap::ArgMatches) -> Result<(), CliError> {
self.fired.store(true, Ordering::SeqCst);
Ok(())
}
}
struct ClashingPlugin {
fired: Arc<AtomicBool>,
}
impl Plugin for ClashingPlugin {
fn name(&self) -> &'static str {
"clashing"
}
fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
vec![Box::new(PluginSideCommand {
fired: self.fired.clone(),
})]
}
}
#[tokio::test]
async fn app_command_runs_wins_its_clash_and_lists() {
let settings = Settings::from_env().expect("figment defaults always load");
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite should always connect");
let seen = Arc::new(std::sync::Mutex::new(None));
let plugin_fired = Arc::new(AtomicBool::new(false));
let app = App::builder()
.settings(settings)
.database("default", pool)
.command(BackfillSlugsCommand { seen: seen.clone() })
.plugin(ClashingPlugin {
fired: plugin_fired.clone(),
})
.build_deferred()
.expect("build_deferred with figment defaults");
let catalog =
umbral::cli::command_catalog_with_app_commands(app.commands(), app.plugins(), &[]);
assert!(
catalog.iter().any(|(name, about)| name == "backfill_slugs"
&& about.as_deref() == Some("Fill in empty post slugs")),
"app command missing from the catalog `umbral help` renders: {catalog:?}"
);
let argv: Vec<std::ffi::OsString> = vec![
"test-binary".into(),
"backfill_slugs".into(),
"hello-world".into(),
"--limit".into(),
"5".into(),
"--tag".into(),
"a".into(),
"--tag".into(),
"b".into(),
"--dry-run".into(),
];
umbral_cli::dispatch_with_argv(app, argv)
.await
.expect("dispatch should route to the app command");
let parsed = seen.lock().unwrap().clone().expect("run() never fired");
assert_eq!(
parsed,
Parsed {
slug: "hello-world".to_string(),
limit: 5,
tags: vec!["a".to_string(), "b".to_string()],
dry_run: true,
}
);
assert!(
!plugin_fired.load(Ordering::SeqCst),
"the plugin's command ran even though the app registered the same name"
);
}