use std::ffi::OsString;
use std::marker::PhantomData;
use std::process::ExitCode;
use anyhow::Result;
#[cfg(feature = "usage")]
use clap::Command;
use clap::{CommandFactory, Parser};
use crate::color::ColorMode;
use crate::format::OutputFormat;
use crate::view::{Present, View};
type BeforeParse = Box<dyn Fn(&[OsString]) -> Option<ExitCode>>;
type SelectView<C> = Box<dyn Fn(&C) -> View>;
#[cfg(feature = "usage")]
type RenderUsage = Box<dyn Fn(Command, &str) -> String>;
pub struct App<C> {
bin: String,
before_parse: Vec<BeforeParse>,
select_view: SelectView<C>,
#[cfg(feature = "usage")]
mounted_as: Option<String>,
#[cfg(feature = "usage")]
render_usage: RenderUsage,
marker: PhantomData<C>,
}
impl<C> App<C> {
#[must_use]
pub fn new(bin: impl Into<String>) -> Self {
Self {
bin: bin.into(),
before_parse: Vec::new(),
select_view: Box::new(|_| View::new(OutputFormat::Pretty, ColorMode::Auto)),
#[cfg(feature = "usage")]
mounted_as: None,
#[cfg(feature = "usage")]
render_usage: Box::new(crate::usage::spec),
marker: PhantomData,
}
}
#[must_use]
pub fn view(mut self, select: impl Fn(&C) -> View + 'static) -> Self {
self.select_view = Box::new(select);
self
}
#[must_use]
pub fn before_parse(
mut self,
hook: impl Fn(&[OsString]) -> Option<ExitCode> + 'static,
) -> Self {
self.before_parse.push(Box::new(hook));
self
}
#[cfg(feature = "usage")]
#[must_use]
pub fn mounted_as(mut self, task: impl Into<String>) -> Self {
self.mounted_as = Some(task.into());
self
}
#[cfg(feature = "usage")]
#[must_use]
pub fn usage_spec(mut self, render: impl Fn(Command, &str) -> String + 'static) -> Self {
self.render_usage = Box::new(render);
self
}
}
impl<C> App<C>
where
C: Parser + CommandFactory,
{
#[must_use]
pub fn run<T>(self, execute: impl FnOnce(C) -> Result<T>) -> ExitCode
where
T: Present,
{
self.run_from(std::env::args_os(), execute)
}
#[must_use]
pub fn run_from<T>(
self,
args: impl IntoIterator<Item = impl Into<OsString>>,
execute: impl FnOnce(C) -> Result<T>,
) -> ExitCode
where
T: Present,
{
let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
let words = raw
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
#[cfg(feature = "usage")]
if let Some(task) = &self.mounted_as
&& let Some(spec_bin) = crate::usage::spec_bin(words.iter().skip(1), task)
{
let mut command = C::command();
command.set_bin_name(&spec_bin);
let spec = (self.render_usage)(command, &spec_bin);
return crate::view::write_stdout(spec.as_bytes(), ColorMode::Never)
.map_or(ExitCode::FAILURE, |()| ExitCode::SUCCESS);
}
for hook in &self.before_parse {
if let Some(code) = hook(&raw) {
return code;
}
}
let raw_view = raw_view::<C>(&raw);
if words.len() == 1 && crate::parser::requires_input::<C>() {
return crate::help::emit_bare::<C>(raw_view.color)
.map_or(ExitCode::FAILURE, |()| ExitCode::from(2));
}
match crate::help::try_emit_from_with_color::<C>(&words, raw_view.color) {
Ok(true) => return ExitCode::SUCCESS,
Ok(false) => {}
Err(_) => return ExitCode::FAILURE,
}
let mut command = crate::parser::apply_defaults(C::command());
if raw_view.format.is_json() {
command = command.color(clap::ColorChoice::Never);
}
let matches = match command.try_get_matches_from(&raw) {
Ok(matches) => matches,
Err(error) => return self.clap_error(&error, raw_view),
};
let cli = match C::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(error) => return self.clap_error(&error, raw_view),
};
let view = (self.select_view)(&cli);
match execute(cli) {
Ok(value) => view.show(&value).unwrap_or(ExitCode::FAILURE),
Err(error) => view
.emit_err(&self.bin, &format!("{error:#}"))
.unwrap_or(ExitCode::FAILURE),
}
}
fn clap_error(&self, error: &clap::Error, view: View) -> ExitCode {
let code = exit_code(error.exit_code());
if is_clap_display(error.kind()) {
let _ = error.print();
return code;
}
view.emit_err(&self.bin, error.to_string().trim())
.map_or(ExitCode::FAILURE, |_| code)
}
}
fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
let parsed = crate::parser::parsed_output::<C>(raw);
View::new(parsed.format, parsed.color)
}
fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
matches!(
kind,
clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
)
}
fn exit_code(code: i32) -> ExitCode {
u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::ffi::OsString;
use std::rc::Rc;
use clap::{Parser, Subcommand};
use serde::Serialize;
use super::{App, is_clap_display, raw_view};
use crate::document::{Document, Fields};
use crate::view::{Present, View};
use crate::{ColorMode, OutputArgs, OutputFormat};
#[derive(Parser)]
#[command(version, about = "toy")]
struct Cli {
#[command(flatten)]
output: OutputArgs,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Status(StatusArgs),
}
#[derive(clap::Args)]
struct StatusArgs {
#[arg(short = 'm', long, allow_hyphen_values = true)]
message: Option<String>,
}
#[derive(Parser)]
#[command(version, about = "optional toy")]
struct OptionalCli {}
#[derive(Parser)]
#[command(version, about = "nested toy")]
struct NestedCli {
#[command(subcommand)]
command: NestedCommand,
}
#[derive(Subcommand)]
enum NestedCommand {
Group {
#[command(subcommand)]
command: GroupCommand,
},
}
#[derive(Subcommand)]
enum GroupCommand {
Status,
}
#[derive(Serialize)]
struct Status {
pending: usize,
}
impl Present for Status {
fn present(&self) -> Document {
Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
}
}
#[test]
fn parses_executes_and_suppresses_quiet_pretty() {
let ran = Rc::new(Cell::new(false));
let observed = Rc::clone(&ran);
let code = App::<Cli>::new("toy")
.view(|cli| {
View::new(cli.output.format, cli.output.color())
.quiet(cli.output.quiet)
.width(80)
})
.run_from(["toy", "status", "--quiet"], move |_| {
observed.set(true);
Ok(Status { pending: 0 })
});
assert_eq!(code, std::process::ExitCode::SUCCESS);
assert!(ran.get());
}
#[test]
fn pre_parse_hook_runs_before_clap() {
let code = App::<Cli>::new("toy")
.before_parse(|args| {
args.iter()
.any(|arg| arg == "--complete")
.then_some(std::process::ExitCode::SUCCESS)
})
.run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
assert_eq!(code, std::process::ExitCode::SUCCESS);
}
#[cfg(feature = "usage")]
#[test]
fn mounted_usage_accepts_a_custom_renderer() {
use std::cell::RefCell;
let observed = Rc::new(RefCell::new(None));
let callback_observed = Rc::clone(&observed);
let code = App::<Cli>::new("toy")
.mounted_as("q")
.usage_spec(move |command, bin| {
*callback_observed.borrow_mut() =
Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
format!("custom {bin}\n")
})
.run_from(["toy", "--usage-spec=mounted"], |_| {
Ok(Status { pending: 0 })
});
assert_eq!(code, std::process::ExitCode::SUCCESS);
assert_eq!(
observed.borrow().as_ref(),
Some(&("mounted".to_owned(), Some("mounted".to_owned())))
);
}
#[test]
fn defaults_are_pretty_auto() {
let app = App::<Cli>::new("toy").view(|_| {
View::new(OutputFormat::Pretty, ColorMode::Auto)
.quiet(true)
.width(80)
});
let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
assert_eq!(code, std::process::ExitCode::SUCCESS);
}
#[test]
fn clap_help_kinds_stay_on_claps_display_path() {
use clap::error::ErrorKind;
assert!(is_clap_display(ErrorKind::DisplayVersion));
assert!(is_clap_display(ErrorKind::DisplayHelp));
assert!(is_clap_display(
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
));
assert!(!is_clap_display(ErrorKind::UnknownArgument));
}
#[test]
fn missing_nested_subcommand_uses_claps_help_exit() {
let code =
App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
assert_eq!(code, std::process::ExitCode::from(2));
}
#[test]
fn bare_invocation_is_usage_error() {
let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
assert_eq!(code, std::process::ExitCode::from(2));
}
#[test]
fn bare_invocation_executes_when_the_root_accepts_it() {
let ran = Rc::new(Cell::new(false));
let observed = Rc::clone(&ran);
let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
observed.set(true);
Ok(Status { pending: 0 })
});
assert_eq!(code, std::process::ExitCode::SUCCESS);
assert!(ran.get());
}
#[test]
fn explicit_help_used_as_a_domain_value_reaches_execution() {
let ran = Rc::new(Cell::new(false));
let observed = Rc::clone(&ran);
let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
observed.set(true);
Ok(Status { pending: 0 })
});
assert_eq!(code, std::process::ExitCode::SUCCESS);
assert!(ran.get());
}
#[test]
fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
let view = raw_view::<Cli>(&domain_value);
assert_eq!(view.format, OutputFormat::Pretty);
assert_eq!(view.color, ColorMode::Auto);
let global_after_subcommand =
["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
let view = raw_view::<Cli>(&global_after_subcommand);
assert_eq!(view.format, OutputFormat::Json);
assert_eq!(view.color, ColorMode::Never);
}
}