use clap::{ArgMatches, Command};
use standout_input::env::{DefaultStdin, StdinReader};
use std::rc::Rc;
use crate::cli::app::find_subcommand;
use crate::cli::handler::Extensions;
use crate::cli::App;
impl App {
pub(crate) fn resolve_default_command(
&self,
cmd: &Command,
matches: &ArgMatches,
) -> Result<Option<String>, UnknownDefaultCommand> {
resolve(
cmd,
matches,
&self.app_state,
self.default_command_resolver.as_ref(),
self.default_command.as_deref(),
)
}
}
#[derive(Debug, Clone)]
pub struct UnknownDefaultCommand {
pub name: String,
pub known: Vec<String>,
pub app: String,
}
impl std::fmt::Display for UnknownDefaultCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"default command resolver returned `{}`, which is not a command of `{}`. \
Known commands: [{}]. Return `None` to decline instead of naming an unknown command.",
self.name,
self.app,
self.known.join(", ")
)
}
}
impl std::error::Error for UnknownDefaultCommand {}
pub struct DefaultCommandContext<'a> {
matches: &'a ArgMatches,
app_state: &'a Extensions,
stdin: &'a dyn StdinReader,
}
impl<'a> DefaultCommandContext<'a> {
pub fn matches(&self) -> &'a ArgMatches {
self.matches
}
pub fn app_state<T: 'static>(&self) -> Option<&'a T> {
self.app_state.get::<T>()
}
pub fn stdin_is_terminal(&self) -> bool {
self.stdin.is_terminal()
}
pub fn stdin_is_piped(&self) -> bool {
!self.stdin_is_terminal()
}
}
pub type DefaultCommandResolver = Rc<dyn Fn(&DefaultCommandContext<'_>) -> Option<String>>;
pub(crate) fn resolve(
cmd: &Command,
matches: &ArgMatches,
app_state: &Extensions,
resolver: Option<&DefaultCommandResolver>,
static_default: Option<&str>,
) -> Result<Option<String>, UnknownDefaultCommand> {
if matches.subcommand().is_some() {
return Ok(None);
}
if let Some(resolver) = resolver {
let stdin = DefaultStdin;
let ctx = DefaultCommandContext {
matches,
app_state,
stdin: &stdin,
};
if let Some(name) = resolver(&ctx) {
return check_known_command(cmd, name).map(Some);
}
}
Ok(static_default.map(String::from))
}
fn check_known_command(cmd: &Command, name: String) -> Result<String, UnknownDefaultCommand> {
if find_subcommand(cmd, &name).is_some() {
return Ok(name);
}
Err(UnknownDefaultCommand {
name,
known: cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect(),
app: cmd.get_name().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use standout_input::env::MockStdin;
fn app_cmd() -> Command {
Command::new("myapp")
.subcommand(Command::new("list").alias("ls"))
.subcommand(Command::new("add"))
}
fn naked_matches() -> ArgMatches {
app_cmd().try_get_matches_from(["myapp"]).unwrap()
}
fn resolver_returning(name: Option<&'static str>) -> DefaultCommandResolver {
Rc::new(move |_ctx| name.map(String::from))
}
#[test]
fn static_default_applies_to_a_naked_invocation() {
let resolved = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
None,
Some("list"),
);
assert_eq!(resolved.unwrap().as_deref(), Some("list"));
}
#[test]
fn no_default_configured_resolves_to_none() {
let resolved = resolve(&app_cmd(), &naked_matches(), &Extensions::new(), None, None);
assert_eq!(resolved.unwrap(), None);
}
#[test]
fn explicit_command_is_not_naked() {
let matches = app_cmd().try_get_matches_from(["myapp", "add"]).unwrap();
let resolved = resolve(
&app_cmd(),
&matches,
&Extensions::new(),
Some(&resolver_returning(Some("list"))),
Some("list"),
);
assert_eq!(resolved.unwrap(), None);
}
#[test]
fn resolver_wins_over_the_static_default() {
let resolved = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
Some(&resolver_returning(Some("add"))),
Some("list"),
);
assert_eq!(resolved.unwrap().as_deref(), Some("add"));
}
#[test]
fn declining_resolver_falls_back_to_the_static_default() {
let resolved = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
Some(&resolver_returning(None)),
Some("list"),
);
assert_eq!(resolved.unwrap().as_deref(), Some("list"));
}
#[test]
fn declining_resolver_without_a_static_default_resolves_to_none() {
let resolved = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
Some(&resolver_returning(None)),
None,
);
assert_eq!(resolved.unwrap(), None);
}
#[test]
fn an_alias_is_a_known_command() {
let resolved = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
Some(&resolver_returning(Some("ls"))),
None,
);
assert_eq!(resolved.unwrap().as_deref(), Some("ls"));
}
#[test]
fn unknown_resolver_output_is_a_typed_error() {
let err = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
Some(&resolver_returning(Some("nope"))),
None,
)
.expect_err("an unknown command name must not resolve");
assert_eq!(err.name, "nope");
assert_eq!(err.app, "myapp");
assert_eq!(err.known, vec!["list", "add"]);
let msg = err.to_string();
assert!(msg.contains("returned `nope`"), "{msg}");
assert!(msg.contains("is not a command of `myapp`"), "{msg}");
assert!(msg.contains("list, add"), "{msg}");
assert!(msg.contains("Return `None`"), "{msg}");
}
#[test]
fn an_unknown_resolver_output_does_not_fall_back_to_the_static_default() {
let err = resolve(
&app_cmd(),
&naked_matches(),
&Extensions::new(),
Some(&resolver_returning(Some("nope"))),
Some("list"),
);
assert!(err.is_err());
}
#[test]
fn resolver_reads_root_matches() {
let cmd = app_cmd().arg(
clap::Arg::new("all")
.long("all")
.action(clap::ArgAction::SetTrue),
);
let matches = cmd
.clone()
.try_get_matches_from(["myapp", "--all"])
.unwrap();
let resolver: DefaultCommandResolver = Rc::new(|ctx| {
if ctx.matches().get_flag("all") {
Some("list".to_string())
} else {
Some("add".to_string())
}
});
let resolved = resolve(&cmd, &matches, &Extensions::new(), Some(&resolver), None);
assert_eq!(resolved.unwrap().as_deref(), Some("list"));
}
#[test]
fn resolver_reads_app_state() {
struct Mode(&'static str);
let mut state = Extensions::new();
state.insert(Mode("add"));
let resolver: DefaultCommandResolver =
Rc::new(|ctx| ctx.app_state::<Mode>().map(|mode| mode.0.to_string()));
let resolved = resolve(&app_cmd(), &naked_matches(), &state, Some(&resolver), None);
assert_eq!(resolved.unwrap().as_deref(), Some("add"));
}
#[test]
fn context_reports_the_stdin_terminal_fact_without_consuming() {
let terminal = MockStdin::terminal();
let matches = naked_matches();
let state = Extensions::new();
let ctx = DefaultCommandContext {
matches: &matches,
app_state: &state,
stdin: &terminal,
};
assert!(ctx.stdin_is_terminal());
assert!(!ctx.stdin_is_piped());
let piped = MockStdin::piped("data");
let ctx = DefaultCommandContext {
matches: &matches,
app_state: &state,
stdin: &piped,
};
assert!(!ctx.stdin_is_terminal());
assert!(ctx.stdin_is_piped());
let empty = MockStdin::piped_empty();
let ctx = DefaultCommandContext {
matches: &matches,
app_state: &state,
stdin: &empty,
};
assert!(!ctx.stdin_is_terminal());
assert!(ctx.stdin_is_piped());
}
}