use std::ffi::OsString;
use std::fmt;
use std::io::{self, Write as _};
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::render::RenderOptions;
use crate::theme::Theme;
use crate::view::{JsonLayout, 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>;
#[derive(Clone, Copy)]
struct Fallback(Option<u16>);
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum BrokenPipe {
#[default]
Ignore,
Fail,
}
pub struct App<C> {
bin: String,
before_parse: Vec<BeforeParse>,
select_view: SelectView<C>,
automatic_width_buffer: Option<u16>,
automatic_width_buffer_envs: Option<&'static [&'static str]>,
minimum_automatic_width: Option<u16>,
fallback_width: Option<Fallback>,
styles: Option<RenderOptions>,
json_layout: Option<JsonLayout>,
json_layout_envs: Option<&'static [&'static str]>,
theme: Option<Theme>,
broken_pipe: BrokenPipe,
#[cfg(feature = "usage")]
mounted_as: Option<String>,
#[cfg(feature = "usage")]
render_usage: RenderUsage,
marker: PhantomData<C>,
}
impl<C> fmt::Debug for App<C> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("App")
.field("bin", &self.bin)
.finish_non_exhaustive()
}
}
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)),
automatic_width_buffer: None,
automatic_width_buffer_envs: None,
minimum_automatic_width: None,
fallback_width: None,
styles: None,
json_layout: None,
json_layout_envs: None,
theme: None,
broken_pipe: BrokenPipe::Ignore,
#[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 automatic_width_buffer(mut self, cells: u16) -> Self {
self.automatic_width_buffer = Some(cells);
self
}
#[must_use]
pub fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
self.automatic_width_buffer_envs = Some(names);
self
}
#[must_use]
pub fn minimum_automatic_width(mut self, cells: u16) -> Self {
self.minimum_automatic_width = Some(cells);
self
}
#[must_use]
pub fn fallback_width(mut self, width: Option<u16>) -> Self {
self.fallback_width = Some(Fallback(width));
self
}
#[cfg(any(feature = "input-yaml", feature = "input-toml"))]
#[must_use]
pub fn input_limits(self, limits: crate::input::Limits) -> Self {
crate::input::set_default_limits(limits);
self
}
#[must_use]
pub fn styles(mut self, styles: RenderOptions) -> Self {
self.styles = Some(styles);
self
}
#[must_use]
pub fn json_layout(mut self, layout: JsonLayout) -> Self {
self.json_layout = Some(layout);
self
}
#[must_use]
pub fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
self.json_layout_envs = Some(names);
self
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = Some(theme);
self
}
#[must_use]
pub fn broken_pipe(mut self, policy: BrokenPipe) -> Self {
self.broken_pipe = policy;
self
}
fn written(&self, written: io::Result<()>, code: ExitCode) -> ExitCode {
match written {
Ok(()) => code,
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => match self.broken_pipe {
BrokenPipe::Fail if code == ExitCode::SUCCESS => ExitCode::FAILURE,
BrokenPipe::Ignore | BrokenPipe::Fail => code,
},
Err(_) => ExitCode::FAILURE,
}
}
fn configured_view(&self, mut view: View) -> View {
if let Some(buffer) = self.automatic_width_buffer {
view = view.automatic_width_buffer(buffer);
}
if let Some(names) = self.automatic_width_buffer_envs {
view = view.automatic_width_buffer_envs(names);
}
if let Some(minimum) = self.minimum_automatic_width {
view = view.minimum_automatic_width(minimum);
}
if let Some(Fallback(width)) = self.fallback_width {
view = view.fallback_width(width);
}
if let Some(styles) = self.styles {
view = view.styles(styles);
}
if let Some(theme) = self.theme {
view = view.theme(theme);
}
if let Some(layout) = self.json_layout {
view = view.json_layout(layout);
}
if let Some(names) = self.json_layout_envs {
view = view.json_layout_envs(names);
}
view
}
#[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 = "completion")]
#[must_use]
pub fn completion(self) -> Self
where
C: CommandFactory + 'static,
{
let bin = self.bin.clone();
self.before_parse(move |args| crate::completion::complete::<C>(&bin, args))
}
#[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 = "return it from main, or the command exits 0"]
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 = "return it from main, or the command exits 0"]
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 self.written(
crate::view::write_stdout(spec.as_bytes()),
ExitCode::SUCCESS,
);
}
for hook in &self.before_parse {
if let Some(code) = hook(&raw) {
return code;
}
}
let raw_view = self.configured_view(raw_view::<C>(&raw));
for warning in raw_view.env_warnings(crate::render::process_env) {
let _ = writeln!(std::io::stderr().lock(), "{}: warning: {warning}", self.bin);
}
if words.len() == 1 && crate::parser::requires_input::<C>() {
return self.written(
crate::help::emit_bare_with_options::<C>(raw_view.render_options()),
ExitCode::from(2),
);
}
match crate::help::try_emit_from_with_options::<C>(&words, raw_view.render_options()) {
Ok(true) => return ExitCode::SUCCESS,
Ok(false) => {}
Err(error) => return self.written(Err(error), ExitCode::SUCCESS),
}
let command = crate::parser::apply_defaults(C::command());
let command = match crate::flags::env_under_flags(command, &raw) {
Ok(command) => command,
Err(error) => {
return self.clap_error(&error, &parse_error_view(&raw, raw_view), &words);
}
};
let matches = match command.try_get_matches_from(&raw) {
Ok(matches) => matches,
Err(error) => {
return self.clap_error(&error, &parse_error_view(&raw, raw_view), &words);
}
};
let cli = match C::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(error) => {
return self.clap_error(&error, &parse_error_view(&raw, raw_view), &words);
}
};
let view = self.configured_view((self.select_view)(&cli));
match execute(cli) {
Ok(value) => self.written(
view.show(&value).map(|_| ()),
ExitCode::from(value.exit_code()),
),
Err(error) => self.written(
view.emit_err(&self.bin, &format!("{error:#}")).map(|_| ()),
ExitCode::FAILURE,
),
}
}
fn clap_error(&self, error: &clap::Error, view: &View, args: &[String]) -> ExitCode {
use clap::error::ErrorKind;
let code = exit_code(error.exit_code());
let kind = error.kind();
if !is_clap_display(kind) {
return self.written(
view.emit_err(&self.bin, error.to_string().trim())
.map(|_| ()),
code,
);
}
let options = view.render_options();
let written = if kind == ErrorKind::DisplayVersion {
crate::view::write_stdout(error.to_string().as_bytes())
} else if kind == ErrorKind::DisplayHelp {
crate::help::emit_selected::<C>(args, options)
} else {
crate::help::emit_selected_to_stderr::<C>(args, options)
};
self.written(written, code)
}
}
fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
let parsed = crate::parser::parsed_output::<C>(raw);
View::new(parsed.format, parsed.color)
}
fn parse_error_view(raw: &[OsString], mut view: View) -> View {
let mut args = raw.iter().skip(1).map(|arg| arg.to_str());
let mut last = None;
while let Some(arg) = args.next() {
let value = match arg {
Some("--") => break,
Some("--format") => args.next().flatten(),
Some(arg) => arg.strip_prefix("--format="),
None => None,
};
if let Some(format) =
value.and_then(|value| <OutputFormat as clap::ValueEnum>::from_str(value, false).ok())
{
last = Some(format);
}
}
if let Some(format) = last {
view = view.in_format(format);
if format.is_json() {
view = view.color(ColorMode::Never);
}
}
view
}
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, BrokenPipe, is_clap_display, parse_error_view, raw_view};
use crate::document::{Document, Fields};
use crate::render::{RecordStyle, RenderOptions};
use crate::view::{JsonLayout, Present, View};
use crate::{ColorMode, OutputArgs, OutputFormat, Role, Theme};
#[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 a_parse_error_honours_a_later_json_format() {
let pretty = View::new(OutputFormat::Pretty, ColorMode::Always);
let cases = [
(&["toy", "status", "--bogus", "--format", "json"][..], true),
(&["toy", "status", "--bogus", "--format=json"][..], true),
(&["toy", "status", "--bogus"][..], false),
(
&["toy", "status", "--bogus", "--", "--format", "json"][..],
false,
),
(
&["toy", "status", "--bogus", "--format", "pretty"][..],
false,
),
(
&["toy", "--bogus", "--format", "json", "--format", "pretty"][..],
false,
),
(
&["toy", "--bogus", "--format=pretty", "--format", "json"][..],
true,
),
];
for (argv, json) in cases {
let raw: Vec<OsString> = argv.iter().map(OsString::from).collect();
let view = parse_error_view(&raw, pretty);
assert_eq!(view.format().is_json(), json, "{argv:?}");
if json {
assert_eq!(
view.render_options(),
RenderOptions::new(ColorMode::Never),
"{argv:?}"
);
}
}
let json_first = View::new(OutputFormat::Json, ColorMode::Never);
let raw: Vec<OsString> = ["toy", "--format", "json", "--bogus", "--format", "pretty"]
.iter()
.map(OsString::from)
.collect();
assert!(
!parse_error_view(&raw, json_first).format().is_json(),
"a later --format pretty undoes the pre-parse's JSON"
);
}
#[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 app_width_policy_configures_every_view() {
let app = App::<Cli>::new("toy")
.automatic_width_buffer(0)
.automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
.minimum_automatic_width(8);
let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
assert_eq!(
view.render_options(),
RenderOptions::new(ColorMode::Never)
.automatic_width_buffer(0)
.automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
.minimum_automatic_width(8)
);
}
#[test]
fn app_json_layout_envs_reach_every_view() {
let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
let view = App::<Cli>::new("toy")
.json_layout_envs(&["TOY_JSON"])
.configured_view(View::new(OutputFormat::Json, ColorMode::Never));
assert_eq!(view.json_layout_with(compact), JsonLayout::Compact);
let disabled = App::<Cli>::new("toy")
.json_layout_envs(&[])
.configured_view(View::new(OutputFormat::Json, ColorMode::Never));
assert_eq!(disabled.json_layout_with(compact), JsonLayout::Pretty);
}
#[test]
fn app_styles_and_fallback_reach_every_view() {
let app = App::<Cli>::new("toy")
.fallback_width(None)
.styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
.json_layout(JsonLayout::Compact);
let options = app
.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
.render_options();
assert_eq!(
options,
RenderOptions::new(ColorMode::Never)
.fallback_width(None)
.record_style(RecordStyle::Boxed)
);
assert_eq!(options.resolved_record_style(), RecordStyle::Boxed);
let json = app
.configured_view(View::new(OutputFormat::Json, ColorMode::Never))
.capture(&Status { pending: 1 })
.unwrap();
assert_eq!(json.text(), "{\"pending\":1}\n");
}
#[test]
fn app_theme_reaches_every_view_over_the_styles_theme() {
let underline = ctl_text::Style::new().effects(ctl_text::anstyle::Effects::UNDERLINE);
let theme = Theme::new().style(Role::Heading, underline);
let app = App::<Cli>::new("toy")
.theme(theme)
.styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed));
let options = app
.configured_view(View::new(OutputFormat::Pretty, ColorMode::Always))
.render_options();
assert_eq!(
options,
RenderOptions::new(ColorMode::Always)
.theme(theme)
.record_style(RecordStyle::Boxed)
);
assert_eq!(options.resolved_record_style(), RecordStyle::Boxed);
}
#[test]
fn a_closed_pipe_exits_as_the_policy_says() {
use std::process::ExitCode;
let closed = || Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
let other = || Err(std::io::Error::other("disk full"));
let ignore = App::<Cli>::new("toy");
assert_eq!(
ignore.written(closed(), ExitCode::SUCCESS),
ExitCode::SUCCESS
);
assert_eq!(
ignore.written(closed(), ExitCode::from(2)),
ExitCode::from(2)
);
assert_eq!(
ignore.written(closed(), ExitCode::FAILURE),
ExitCode::FAILURE
);
assert_eq!(
ignore.written(other(), ExitCode::SUCCESS),
ExitCode::FAILURE
);
assert_eq!(ignore.written(Ok(()), ExitCode::from(2)), ExitCode::from(2));
let fail = App::<Cli>::new("toy").broken_pipe(BrokenPipe::Fail);
assert_eq!(fail.written(closed(), ExitCode::SUCCESS), ExitCode::FAILURE);
assert_eq!(fail.written(closed(), ExitCode::from(2)), ExitCode::from(2));
assert_eq!(fail.written(closed(), ExitCode::FAILURE), ExitCode::FAILURE);
}
#[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.render_options(), RenderOptions::new(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.render_options(), RenderOptions::new(ColorMode::Never));
}
}