Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,51 @@ pub struct InvalidValue<'t> {
pub reason: ::std::string::String,
}

/// A command-wide validation or finalization failure.
///
/// Return this from a `#[usage(validate_with = ...)]` hook or from the
/// `TryFrom` implementation named by `#[usage(try_into = ...)]`. The derive
/// turns it into the same [`Error::InvalidValue`] diagnostic used by field
/// conversion, so callers keep one parse error type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
name: &'static str,
value: String,
reason: String,
}

impl ValidationError {
/// Start an error attributed to a flag, positional, or command name.
pub fn field(name: &'static str) -> Self {
Self {
name,
value: String::new(),
reason: String::new(),
}
}

/// Record the value that failed the command-wide invariant.
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = value.into();
self
}

/// Explain the invariant that the value did not satisfy.
pub fn reason(mut self, reason: impl Into<String>) -> Self {
self.reason = reason.into();
self
}

/// Convert this application-level failure into the parser's diagnostic.
pub fn into_parse_error<'v>(self) -> Error<'static, 'v> {
Error::InvalidValue(Box::new(InvalidValue {
name: self.name,
value: self.value,
reason: self.reason,
}))
}
}

/// Interpret a value as UTF-8.
///
/// The parser hands back bytes borrowed from `argv`; this is the conversion most
Expand Down
109 changes: 109 additions & 0 deletions conformance/tests/finalize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use std::ffi::OsStr;
use std::path::PathBuf;

use usage_argv::{Error, ValidationError};
use usage_derive::Cli;

#[derive(Clone, Debug, Cli)]
#[usage(bin = "copy", validate_with = validate_copy, try_into = CopyCommand)]
struct CopyArgs {
#[usage(long)]
source: PathBuf,
#[usage(long)]
destination: PathBuf,
}

#[derive(Debug, PartialEq, Eq)]
struct CopyCommand {
source: PathBuf,
destination: PathBuf,
}

fn validate_copy(args: &CopyArgs) -> Result<(), ValidationError> {
if args.source == args.destination {
return Err(ValidationError::field("--destination")
.value(args.destination.display().to_string())
.reason("must differ from --source"));
}
Ok(())
}

impl TryFrom<CopyArgs> for CopyCommand {
type Error = ValidationError;

fn try_from(args: CopyArgs) -> Result<Self, Self::Error> {
if args.destination.extension().is_some_and(|ext| ext == "tmp") {
return Err(ValidationError::field("--destination")
.value(args.destination.display().to_string())
.reason("temporary destinations are not supported"));
}
Ok(Self {
source: args.source,
destination: args.destination,
})
}
}

fn argv<'a>(words: &'a [&'a str]) -> Vec<&'a OsStr> {
words.iter().map(OsStr::new).collect()
}

#[test]
fn validates_after_all_fields_are_typed() {
let words = argv(&["--source", "same.txt", "--destination", "same.txt"]);
let error = CopyArgs::parse_from(&words).expect_err("paths must differ");
let Error::InvalidValue(invalid) = error else {
panic!("expected an invalid value");
};
assert_eq!(invalid.name, "--destination");
assert_eq!(invalid.value, "same.txt");
assert_eq!(invalid.reason, "must differ from --source");
}

#[test]
fn finalizes_directly_into_the_domain_type() {
let words = argv(&["--source", "input.txt", "--destination", "output.txt"]);
let command = CopyArgs::parse_into_from(&words).expect("valid command");
assert_eq!(
command,
CopyCommand {
source: PathBuf::from("input.txt"),
destination: PathBuf::from("output.txt"),
}
);
}

#[test]
fn finalization_failures_use_the_normal_parse_error() {
let words = argv(&["--source", "input.txt", "--destination", "output.tmp"]);
let error = CopyArgs::parse_into_from(&words).expect_err("temporary output");
let Error::InvalidValue(invalid) = error else {
panic!("expected an invalid value");
};
assert_eq!(invalid.name, "--destination");
assert_eq!(invalid.value, "output.tmp");
assert_eq!(invalid.reason, "temporary destinations are not supported");
}

#[test]
fn updates_validate_the_candidate_and_leave_the_standing_value_on_failure() {
let words = argv(&["--source", "input.txt", "--destination", "output.txt"]);
let mut args = CopyArgs::parse_from(&words).expect("valid initial command");
let update = argv(&["--destination", "input.txt"]);
assert!(args.try_update_from(&update).is_err());
assert_eq!(args.source, PathBuf::from("input.txt"));
assert_eq!(args.destination, PathBuf::from("output.txt"));
}

#[test]
fn full_argv_finalization_strips_the_program_name() {
let words = argv(&[
"copy",
"--source",
"input.txt",
"--destination",
"output.txt",
]);
assert!(CopyArgs::parse_into_from_argv(&words).is_ok());
assert!(CopyArgs::try_parse_into_from(&words).is_ok());
}
125 changes: 117 additions & 8 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ fn built_value(cli: &Cli, sub_build: &TokenStream, fields: &[TokenStream]) -> To
}
}

/// Apply a root command's cross-field validation after every typed field exists.
fn validated_value(cli: &Cli, built: &TokenStream) -> TokenStream {
let Some(validate_with) = &cli.validate_with else {
return built.clone();
};
quote! {{
let __usage_built = #built;
#validate_with(&__usage_built)
.map_err(usage_argv::ValidationError::into_parse_error)?;
__usage_built
}}
}

/// The runtime as the adopter depended on it.
///
/// A direct `usage-argv` dependency wins when both forms are present: a low-level adopter may
Expand Down Expand Up @@ -413,7 +426,7 @@ pub fn emit(cli: &Cli) -> TokenStream {
.filter(|f| !matches!(f.kind, Kind::Subcommand { .. }))
.map(|field| field_final(field, None))
.collect();
let built = built_value(cli, &sub_build, &field_finals);
let built = validated_value(cli, &built_value(cli, &sub_build, &field_finals));
quote! {
/// Parse a command line, and the settings it gave values for.
///
Expand Down Expand Up @@ -488,7 +501,7 @@ pub fn emit(cli: &Cli) -> TokenStream {
.filter(|f| !matches!(f.kind, Kind::Subcommand { .. }))
.map(|field| field_final(field, None))
.collect();
let built = built_value(cli, &sub_build, &field_finals);
let built = validated_value(cli, &built_value(cli, &sub_build, &field_finals));
let built_for_view = if cli.views.is_empty() {
built.clone()
} else {
Expand All @@ -513,8 +526,95 @@ pub fn emit(cli: &Cli) -> TokenStream {
} else {
&sub_default_view_build
};
built_value(cli, view_sub_build, &view_field_finals)
validated_value(cli, &built_value(cli, view_sub_build, &view_field_finals))
};
let update_clone_bound = cli
.validate_with
.as_ref()
.map(|_| quote!(where Self: ::std::clone::Clone));
let update_merge = if let Some(validate_with) = &cli.validate_with {
quote! {
let mut __usage_candidate = self.clone();
merge(partial, &mut __usage_candidate)?;
#validate_with(&__usage_candidate)
.map_err(usage_argv::ValidationError::into_parse_error)?;
*self = __usage_candidate;
::std::result::Result::Ok(())
}
} else {
quote!(merge(partial, self))
};

let parse_into = cli.try_into.as_ref().map(|target| {
quote! {
/// Parse a command line and finalize the parser type into the application's domain type.
pub fn parse_into_from<'v>(
argv: &[&'v ::std::ffi::OsStr],
) -> ::std::result::Result<#target, usage_argv::Error<'static, 'v>> {
Self::parse_from(argv).and_then(Self::__usage_finalize)
}

/// [`Self::parse_into_from`], collecting the deprecations it used.
pub fn parse_into_from_with_warnings<'v>(
argv: &[&'v ::std::ffi::OsStr],
warnings: &mut ::std::vec::Vec<usage_argv::warn::Warning<'static>>,
) -> ::std::result::Result<#target, usage_argv::Error<'static, 'v>> {
Self::parse_from_with_warnings(argv, warnings).and_then(Self::__usage_finalize)
}

/// Parse a full argv, including the program name, and finalize it.
pub fn parse_into_from_argv<'v>(
argv: &[&'v ::std::ffi::OsStr],
) -> ::std::result::Result<#target, usage_argv::Error<'static, 'v>> {
Self::parse_from_argv(argv).and_then(Self::__usage_finalize)
}

/// Parse using clap's argv convention and finalize it.
pub fn try_parse_into_from<'v>(
argv: &[&'v ::std::ffi::OsStr],
) -> ::std::result::Result<#target, usage_argv::Error<'static, 'v>> {
Self::try_parse_from(argv).and_then(Self::__usage_finalize)
}

#[doc(hidden)]
fn __usage_finalize<'v>(
parsed: Self,
) -> ::std::result::Result<#target, usage_argv::Error<'static, 'v>> {
<#target as ::std::convert::TryFrom<Self>>::try_from(parsed)
.map_err(usage_argv::ValidationError::into_parse_error)
}

/// Parse the process's arguments and finalize them into the domain type.
pub fn parse_into() -> #target {
#completion_intercept
#parse_preamble
let mut __usage_warnings = ::std::vec::Vec::new();
let __usage_result = Self::__usage_parse_from_argv(
&__usage_all_refs,
::std::option::Option::Some(&mut __usage_warnings),
).and_then(Self::__usage_finalize);
match __usage_result {
::std::result::Result::Ok(finalized) => {
if !__usage_warnings.is_empty() {
::std::eprint!(
"{}",
usage_argv::render_warnings(&__usage_warnings),
);
}
finalized
}
::std::result::Result::Err(error) => {
Self::__usage_exit_on_error(
error,
&__usage_all_refs,
&__usage_argv,
__usage_selected_view,
)
}
}
}
}
});

// The process entry with a settings layer beside it, for a CLI that binds settings and
// resolves them at startup — the fleet's `main` shape. Same help, version, and error
Expand Down Expand Up @@ -1210,6 +1310,7 @@ pub fn emit(cli: &Cli) -> TokenStream {

#settings_binding_forward
#settings_parse
#parse_into

/// Parse a command line, excluding the program name.
pub fn parse_from<'v>(
Expand Down Expand Up @@ -1388,13 +1489,15 @@ pub fn emit(cli: &Cli) -> TokenStream {
pub fn try_update_from<'v>(
&mut self,
argv: &[&'v ::std::ffi::OsStr],
) -> ::std::result::Result<(), usage_argv::Error<'static, 'v>> {
) -> ::std::result::Result<(), usage_argv::Error<'static, 'v>>
#update_clone_bound
{
// A fresh partial, never seeded from `self`: `FromStr` has no inverse, so
// there is no way back from a typed field to the word that made it.
#defaults
read_argv_into(Self::command(), argv, &mut partial)?;
check_update(&mut partial, self)?;
merge(partial, self)
#update_merge
}

/// Merge a full argv, including the program name, into this value.
Expand All @@ -1408,7 +1511,9 @@ pub fn emit(cli: &Cli) -> TokenStream {
pub fn try_update_from_argv<'v>(
&mut self,
argv: &[&'v ::std::ffi::OsStr],
) -> ::std::result::Result<(), usage_argv::Error<'static, 'v>> {
) -> ::std::result::Result<(), usage_argv::Error<'static, 'v>>
#update_clone_bound
{
let ::std::option::Option::Some((__usage_argv0, __usage_words)) =
argv.split_first()
else {
Expand Down Expand Up @@ -1447,7 +1552,9 @@ pub fn emit(cli: &Cli) -> TokenStream {

/// [`Self::try_update_from`], answering a failure the way [`Self::parse`]
/// does: help or a version on stdout, a message on stderr, and exit.
pub fn update_from<'v>(&mut self, argv: &[&'v ::std::ffi::OsStr]) {
pub fn update_from<'v>(&mut self, argv: &[&'v ::std::ffi::OsStr])
#update_clone_bound
{
if let ::std::result::Result::Err(e) = self.try_update_from(argv) {
Self::__usage_exit_on_error(
e,
Expand All @@ -1459,7 +1566,9 @@ pub fn emit(cli: &Cli) -> TokenStream {
}

/// [`Self::try_update_from_argv`], exiting on failure as [`Self::parse`] does.
pub fn update_from_argv<'v>(&mut self, argv: &[&'v ::std::ffi::OsStr]) {
pub fn update_from_argv<'v>(&mut self, argv: &[&'v ::std::ffi::OsStr])
#update_clone_bound
{
if let ::std::result::Result::Err(e) = self.try_update_from_argv(argv) {
let __usage_words =
argv.split_first().map_or(argv, |(_, rest)| rest);
Expand Down
Loading
Loading