use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::{AppState, Result};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Args(Vec<String>);
impl Args {
pub fn new(args: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self(args.into_iter().map(Into::into).collect())
}
pub fn all(&self) -> &[String] {
&self.0
}
pub fn positional(&self) -> Vec<&str> {
let mut out = Vec::new();
let mut words = self.0.iter().peekable();
while let Some(word) = words.next() {
if word.starts_with("--") {
if !word.contains('=') && words.peek().is_some_and(|next| !next.starts_with("--")) {
words.next();
}
} else {
out.push(word.as_str());
}
}
out
}
pub fn value(&self, flag: &str) -> Option<&str> {
let mut words = self.0.iter();
while let Some(word) = words.next() {
if word == flag {
return words
.next()
.map(String::as_str)
.filter(|v| !v.starts_with("--"));
}
if let Some(value) = word
.strip_prefix(flag)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(value);
}
}
None
}
pub fn has(&self, flag: &str) -> bool {
self.0
.iter()
.any(|word| word == flag || word.starts_with(&format!("{flag}=")))
}
}
pub trait AppCommand: clap::Parser + Send + 'static {
fn run(self, state: AppState) -> impl Future<Output = Result> + Send;
}
pub(crate) type CommandFn =
Arc<dyn Fn(AppState, Args) -> Pin<Box<dyn Future<Output = Result> + Send>> + Send + Sync>;
#[derive(Clone)]
pub(crate) struct Command {
pub name: String,
pub about: String,
pub run: CommandFn,
}
pub(crate) fn command<F, Fut>(name: &str, about: &str, run: F) -> Command
where
F: Fn(Args, AppState) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result> + Send + 'static,
{
Command {
name: name.to_owned(),
about: about.to_owned(),
run: Arc::new(move |state, args| Box::pin(run(args, state))),
}
}
pub(crate) fn typed<T: AppCommand>() -> Command {
let definition = T::command();
let name = definition.get_name().to_owned();
let about = definition
.get_about()
.map(|about| about.to_string())
.unwrap_or_default();
let bin = name.clone();
Command {
name,
about,
run: Arc::new(move |state, args: Args| {
let bin = bin.clone();
Box::pin(async move {
match T::try_parse_from(std::iter::once(bin).chain(args.0)) {
Ok(command) => command.run(state).await,
Err(err)
if matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
) =>
{
print!("{}", err.render());
Ok(())
}
Err(err) => {
let rendered = err.render().to_string();
let message = rendered.trim_end();
let message = message.strip_prefix("error: ").unwrap_or(message);
Err(crate::Error::Internal(if message.contains("Usage:") {
anyhow::anyhow!("{message}")
} else {
anyhow::anyhow!("{message}\n\n{}", T::command().render_usage())
}))
}
}
})
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_flags_and_positional_words() {
let args = Args::new(["report.csv", "--email", "a@b.c", "--dry-run", "--limit=5"]);
assert_eq!(args.value("--email"), Some("a@b.c"));
assert_eq!(args.value("--limit"), Some("5"));
assert_eq!(args.value("--dry-run"), None);
assert!(args.has("--dry-run") && args.has("--limit") && !args.has("--force"));
assert_eq!(args.positional(), ["report.csv"]);
}
}