From f1ef996473ee36a9d504bde243d33fb2cc301bab Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:23:00 +0000 Subject: [PATCH 1/6] feat(spec): add portable expression validation --- Cargo.lock | 75 ++++++++++++++++++++++++++ Cargo.toml | 2 + PLAN.md | 20 +++---- argv/src/spec.rs | 24 +++++++++ conformance/Cargo.toml | 1 + conformance/src/tables.rs | 4 ++ conformance/tests/validation.rs | 23 ++++++++ conformance/validation.json | 6 +++ derive/src/codegen.rs | 77 ++++++++++++++++++++++++++ derive/src/lib.rs | 6 ++- derive/src/model.rs | 31 ++++++++++- docs/spec/reference/arg.md | 7 +++ docs/spec/reference/flag.md | 4 ++ go/argv/argv.go | 9 +++- go/argv/post.go | 37 +++++++++++++ go/argv/post_test.go | 20 +++++++ go/argv/render.go | 3 ++ go/conformance/validation_test.go | 35 ++++++++++++ go/go.mod | 2 + go/go.sum | 2 + go/internal/spec/spec.go | 20 +++++++ go/internal/spec/spec_test.go | 20 +++++++ lib/Cargo.toml | 1 + lib/src/docs/models.rs | 4 ++ lib/src/error.rs | 7 +++ lib/src/parse.rs | 56 +++++++++++++++++++ lib/src/spec/arg.rs | 90 +++++++++++++++++++++++++++++++ lib/src/spec/builder.rs | 12 +++++ usage-rs/Cargo.toml | 11 ++-- usage-rs/src/lib.rs | 37 +++++++++++++ validation/Cargo.toml | 18 +++++++ validation/src/lib.rs | 57 ++++++++++++++++++++ 32 files changed, 705 insertions(+), 16 deletions(-) create mode 100644 conformance/tests/validation.rs create mode 100644 conformance/validation.json create mode 100644 go/conformance/validation_test.go create mode 100644 go/go.sum create mode 100644 validation/Cargo.toml create mode 100644 validation/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 96368f943..13655fd4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,22 @@ dependencies = [ "libc", ] +[[package]] +name = "expr-lang" +version = "1.1.1" +source = "git+https://github.com/jdx/expr.rs?rev=2c6cecc8b38eaeba4aae01bbe055305f1e14e422#2c6cecc8b38eaeba4aae01bbe055305f1e14e422" +dependencies = [ + "indexmap 2.14.0", + "log", + "once_cell", + "pest", + "pest_derive", + "regex", + "serde_json", + "strum", + "thiserror", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1312,6 +1328,48 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1767,6 +1825,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2264,6 +2323,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" @@ -2356,6 +2421,7 @@ dependencies = [ "usage-config", "usage-derive", "usage-lib", + "usage-validation", ] [[package]] @@ -2391,6 +2457,7 @@ dependencies = [ "tempfile", "tera", "thiserror", + "usage-validation", "versions", "xx", ] @@ -2401,6 +2468,14 @@ version = "5.1.0" dependencies = [ "usage-argv", "usage-derive", + "usage-validation", +] + +[[package]] +name = "usage-validation" +version = "5.1.0" +dependencies = [ + "expr-lang", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 281df6637..d5e6337ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "config-build", "derive", "usage-rs", + "validation", "clap_usage", "cli", "conformance", @@ -43,6 +44,7 @@ usage-config = { path = "./config", version = "5.1.0" } usage-derive = { path = "./derive", version = "5.1.0" } usage-lib = { path = "./lib", version = "5.1.0", features = ["clap"] } usage-rs = { path = "./usage-rs", version = "5.1.0" } +usage-validation = { path = "./validation", version = "5.1.0" } [workspace.metadata.release] allow-branch = ["main"] diff --git a/PLAN.md b/PLAN.md index b825ec927..ed4a9aaad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -400,9 +400,13 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and on the command line and has no env value. clap 4 has the setter and no getter, so the bridge cannot read it — same hole as `requires`. **Used by:** mise `bin_paths` (`default_value_if("json", IsPresent, "true")`). -- [ ] **`value_parser`** — clap takes an arbitrary parser function and range - validators (`value_parser!(u16).range(1..=65535)`). We are `T: FromStr` and - nothing else, so there is no per-field validation and no bounded numeric. +- [x] **Portable value validation** — `validate="int(value) >= 1 && int(value) <= + 65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and + generated Rust and Go parsers. `validate_error` supplies the user-facing failure. + This covers clap's common range-validation use case without embedding a Rust + parser function in the spec. clap's arbitrary `value_parser` remains inherently + opaque to `clap_usage`, so an existing clap command must declare the equivalent + rule when moving to the typed usage rewrite. - [ ] **Token-boundary controls** — `allow_negative_numbers`, `value_terminator` and `dont_delimit_trailing_values`. `allow_hyphen_values` is the broader answer to the first one, but accepting every dash-word is not equivalent to @@ -720,12 +724,10 @@ looking at the clap surface, not only at the spec. they force, and a binary that still answers `--help` / `--usage-spec` the same way. That is the experiment that tells you whether the rest of the fleet is a rewrite or a blocked rewrite. -- [ ] **The clap-only behaviour the fleet actually uses**, from the list above, - in the order it would change a command line rather than a compile. - -| gap | who | what breaks without it | -| --------------------- | ------------------------------- | -------------------------------------------------------- | -| `value_parser` ranges | unknown until the typed rewrite | `FromStr` accepts out-of-range numbers clap would refuse | +- [x] **The clap-only validation behaviour the fleet actually uses.** Portable + `validate` expressions cover numeric ranges in the typed rewrite. Arbitrary clap + parser functions remain opaque to `clap_usage`, but they no longer require a + Rust-only extension to the spec: the rewrite declares the equivalent expr rule. `external_subcommand` and `default_if` have landed: the parser, the derive, and the corpus all say them. clap's bridge reads `allow_external_subcommands`; diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 0d0731a24..1458ab001 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -592,6 +592,10 @@ pub struct FlagMeta<'a> { /// Canonical-to-alias pairs used when emitting a lossless spec. pub choice_aliases: &'a [(&'a str, &'a str)], pub ignore_case: bool, + /// Portable expr expression evaluated for each raw value. + pub validate: Option<&'a str>, + /// Message reported when validation returns false. + pub validate_error: Option<&'a str>, pub required: bool, /// Whether the flag's value may be left off, as in `--bump` or `--bump 5`. /// @@ -667,6 +671,8 @@ impl FlagMeta<'_> { choices: &[], choice_aliases: &[], ignore_case: false, + validate: None, + validate_error: None, required: false, value_optional: false, hide: false, @@ -720,6 +726,10 @@ pub struct ArgMeta<'a> { /// Canonical-to-alias pairs used when emitting a lossless spec. pub choice_aliases: &'a [(&'a str, &'a str)], pub ignore_case: bool, + /// Portable expr expression evaluated for each raw value. + pub validate: Option<&'a str>, + /// Message reported when validation returns false. + pub validate_error: Option<&'a str>, /// Whether the argument must be filled. The parser does not enforce this — /// it is checked once the last token has been read — but the spec has to say /// it, and help output has to show it. @@ -751,6 +761,8 @@ impl ArgMeta<'_> { choices: &[], choice_aliases: &[], ignore_case: false, + validate: None, + validate_error: None, required: true, hide: false, var_min: None, @@ -1312,6 +1324,12 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: if meta.value_optional { out.push_str(" required=#false"); } + if let Some(validate) = meta.validate { + write!(out, " validate={}", quoted(validate))?; + } + if let Some(error) = meta.validate_error { + write!(out, " validate_error={}", quoted(error))?; + } if meta.choices.is_empty() { out.push('\n'); } else { @@ -1381,6 +1399,12 @@ fn write_arg(out: &mut String, meta: &ArgMeta<'_>, depth: usize) -> core::fmt::R if let Some(env) = meta.env { write!(out, " env={}", quoted(env))?; } + if let Some(validate) = meta.validate { + write!(out, " validate={}", quoted(validate))?; + } + if let Some(error) = meta.validate_error { + write!(out, " validate_error={}", quoted(error))?; + } write_single_default(out, meta.default)?; let has_children = diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index 65d48e99a..765a2d8d6 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -25,6 +25,7 @@ usage-lib = { workspace = true } [dev-dependencies] insta = "1" usage-derive = { workspace = true } +usage-validation = { workspace = true } [[bin]] name = "oracle" diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 171e453ab..af8c3bca8 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -317,6 +317,8 @@ fn flag_meta( choices: visible_choices(choices), choice_aliases: choice_aliases(choices), ignore_case: choices.is_some_and(|c| c.ignore_case), + validate: arg.and_then(|a| a.validate.as_deref()).map(leak), + validate_error: arg.and_then(|a| a.validate_error.as_deref()).map(leak), required: f.required, hide: f.hide, count: f.count, @@ -376,6 +378,8 @@ fn arg_meta( choices: visible_choices(choices), choice_aliases: choice_aliases(choices), ignore_case: choices.is_some_and(|c| c.ignore_case), + validate: a.validate.as_deref().map(leak), + validate_error: a.validate_error.as_deref().map(leak), required: a.required, hide: a.hide, delimiter: a.delimiter, diff --git a/conformance/tests/validation.rs b/conformance/tests/validation.rs new file mode 100644 index 000000000..efab1d19d --- /dev/null +++ b/conformance/tests/validation.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +struct Vector { + expression: String, + value: String, + valid: bool, +} + +#[test] +fn rust_matches_the_portable_validation_vectors() { + let vectors: Vec = + serde_json::from_str(include_str!("../validation.json")).expect("valid vectors"); + for vector in vectors { + let actual = usage_validation::validate(&vector.expression, &vector.value) + .unwrap_or_else(|err| panic!("{} with {:?}: {err}", vector.expression, vector.value)); + assert_eq!( + actual, vector.valid, + "{} with {:?}", + vector.expression, vector.value + ); + } +} diff --git a/conformance/validation.json b/conformance/validation.json new file mode 100644 index 000000000..492fedbfe --- /dev/null +++ b/conformance/validation.json @@ -0,0 +1,6 @@ +[ + {"expression":"int(value) >= 1 && int(value) <= 65535","value":"9229","valid":true}, + {"expression":"int(value) >= 1 && int(value) <= 65535","value":"0","valid":false}, + {"expression":"value startsWith 'release-'","value":"release-5.1.0","valid":true}, + {"expression":"value startsWith 'release-'","value":"nightly","valid":false} +] diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index db4bf9cf2..adad9d646 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -69,9 +69,34 @@ fn derive_path() -> TokenStream { } } +/// The cold expression evaluator, resolved independently of the binding runtime. +fn validation_path() -> TokenStream { + match crate_name("usage-validation") { + Ok(FoundCrate::Itself) => quote!(::usage_validation), + Ok(FoundCrate::Name(name)) => { + let validation = format_ident!("{}", name.replace('-', "_")); + quote!(::#validation) + } + _ => match crate_name("usage-rs") { + Ok(FoundCrate::Itself) => quote!(::usage_rs::validation), + Ok(FoundCrate::Name(name)) => { + let facade = format_ident!("{}", name.replace('-', "_")); + quote!(::#facade::validation) + } + _ => quote!(::usage_validation), + }, + } +} + pub fn emit(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); + let validation = validation_path(); + let validation_import = cli + .fields + .iter() + .any(|field| field.validate.is_some()) + .then(|| quote!(use #validation as usage_validation;)); let flags: Vec<&Field> = cli .fields @@ -260,6 +285,7 @@ pub fn emit(cli: &Cli) -> TokenStream { )] const _: () = { use #runtime as usage_argv; + #validation_import #flatten_checks #keys @@ -971,6 +997,8 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { // nothing about whether its value is. let value_optional = field.value_optional; let (choices, accepted_choices, choice_aliases, ignore_case) = choices_tokens(field); + let validate = option_str(field.validate.as_deref()); + let validate_error = option_str(field.validate_error.as_deref()); let (var_min, var_max) = bounds_tokens(field); // Written as declared, in the spec's own spelling, so the emitted KDL says what // the struct says. @@ -1029,6 +1057,8 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { choices: #choices, choice_aliases: #choice_aliases, ignore_case: #ignore_case, + validate: #validate, + validate_error: #validate_error, var_min: #var_min, var_max: #var_max, overrides: &[#(#overrides),*], @@ -1061,6 +1091,8 @@ fn arg_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { // declare it. Every other shape gets its answer from the type. let required = field.shape == Shape::Required || field.required_collection; let (choices, accepted_choices, choice_aliases, ignore_case) = choices_tokens(field); + let validate = option_str(field.validate.as_deref()); + let validate_error = option_str(field.validate_error.as_deref()); let (var_min, var_max) = bounds_tokens(field); let delimiter = match field.delimiter { Some(c) => quote!(::std::option::Option::Some(#c)), @@ -1085,6 +1117,8 @@ fn arg_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { choices: #choices, choice_aliases: #choice_aliases, ignore_case: #ignore_case, + validate: #validate, + validate_error: #validate_error, var_min: #var_min, var_max: #var_max, delimiter: #delimiter, @@ -3955,6 +3989,48 @@ fn post_binding(cli: &Cli) -> TokenStream { }) }); + let validation_checks = cli.fields.iter().filter_map(|f| { + let expression = f.validate.as_ref()?; + let ident = &f.ident; + let name = &f.name; + let message = f + .validate_error + .as_deref() + .unwrap_or("does not satisfy the validation expression"); + let values = match f.shape { + Shape::Optional => quote!(partial.#ident.iter()), + Shape::Required => quote!(::std::iter::once(&partial.#ident)), + Shape::Many => quote!(partial.#ident.iter()), + Shape::Bool | Shape::Count => return None, + }; + Some(quote! { + for value in #values { + let ::std::result::Result::Ok(__usage_text) = ::std::str::from_utf8(value) + else { + // The field conversion reports non-UTF-8 with the original bytes. An expr + // variable is text, so claiming the validation failed would hide that more + // precise error. + continue; + }; + let __usage_reason = match usage_validation::validate(#expression, __usage_text) { + ::std::result::Result::Ok(true) => continue, + ::std::result::Result::Ok(false) => #message.to_string(), + ::std::result::Result::Err(error) => + ::std::format!("validation expression failed: {error}"), + }; + return ::std::result::Result::Err( + usage_argv::Error::InvalidValue(::std::boxed::Box::new( + usage_argv::InvalidValue { + name: #name, + value: __usage_text.to_string(), + reason: __usage_reason, + }, + )), + ); + } + }) + }); + let bound_checks = cli.fields.iter().filter_map(|f| { if f.var_min.is_none() && f.var_max.is_none() { return None; @@ -4423,6 +4499,7 @@ fn post_binding(cli: &Cli) -> TokenStream { #(#relationship_required_checks)* } #(#choice_checks)* + #(#validation_checks)* #(#bound_checks)* #sub_check } diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 74e88794f..0b78de60a 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -118,7 +118,7 @@ //! 2. **Required-ness**, which the type states: a `String` has nowhere to put //! "absent", so it must be given — unless a default or the environment already //! filled it. -//! 3. **`choices`** and **`var_min`/`var_max`**, which judge a value however it +//! 3. **`choices`, `validate`, and `var_min`/`var_max`**, which judge a value however it //! arrived, including from the environment or a default. //! //! Only the command that actually ran is judged. A flag that `install` requires says @@ -128,6 +128,10 @@ //! absent, not a violation, or `var_min` would be a second way to spell //! required-ness and there would be no way to say "at least two, if you use it". //! +//! `validate` is a portable [expr](https://expr-lang.org/) expression with one string +//! variable, `value`. It must return a boolean. `validate_error` supplies the message +//! shown when it returns false. +//! //! Contradictions are refused at compile time rather than at run time — `choices` on //! a `bool`, a `var_min` above its `var_max`, a bound on something that is not a //! `Vec`, or a default that is not one of the choices. diff --git a/derive/src/model.rs b/derive/src/model.rs index ba81154f2..3cc992217 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -187,6 +187,10 @@ pub struct Field { /// The values this may take. Checked after the parse, since a choice list is /// about what a value *means* rather than which token it came from. pub choices: Vec, + /// Portable expr expression evaluated for each raw value after binding. + pub validate: Option, + /// Message reported when `validate` returns false. + pub validate_error: Option, /// A Rust function that answers for this value when a shell asks. /// /// The counterpart of a spec's `run=`, and the source it is generated from: declaring the @@ -1155,6 +1159,8 @@ impl Field { value_name: None, required_collection: false, choices: Vec::new(), + validate: None, + validate_error: None, value_enum: false, var_min: None, var_max: None, @@ -1263,6 +1269,8 @@ impl Field { value_name: None, required_collection: false, choices: Vec::new(), + validate: None, + validate_error: None, value_enum: false, var_min: None, var_max: None, @@ -1365,6 +1373,8 @@ impl Field { value_name: None, required_collection: false, choices: Vec::new(), + validate: None, + validate_error: None, value_enum: false, var_min: None, var_max: None, @@ -1434,6 +1444,8 @@ impl Field { let mut hide = false; let mut is_arg = false; let mut choices: Vec = Vec::new(); + let mut validate: Option = None; + let mut validate_error: Option = None; let mut complete: Option = None; let mut complete_type: Option = None; let mut value_enum = false; @@ -1536,6 +1548,8 @@ impl Field { )); } } + "validate" => validate = Some(string_value(&meta)?), + "validate_error" => validate_error = Some(string_value(&meta)?), // Both spellings the spec has: one target as a value, several as a // list. A flag selector never contains a comma, so unlike `choices` // there is nothing to lose by accepting the shorter form. @@ -1605,7 +1619,8 @@ impl Field { format!( "unknown option `{other}`; a field takes `name`, `long`, \ `short`, `negate`, `global`, `var`, `variadic`, \ - `count`, `hide`, `arg`, `env`, `default`, `choices`, \ + `count`, `hide`, `arg`, `env`, `default`, `choices`, `validate`, \ + `validate_error`, \ `var_min`, `var_max`, `value_enum`, `value_hint`, `overrides`, \ `conflicts`, `requires`, `group`, `exclusive`, \ `delimiter`, `allow_hyphen_values`, `require_equals`, \ @@ -1773,6 +1788,18 @@ impl Field { "a `bool` or counting field has no value to check against `choices`", )); } + if validate.is_some() && matches!(shape, Shape::Bool | Shape::Count) { + return Err(syn::Error::new( + span, + "a `bool` or counting field has no value to validate", + )); + } + if validate_error.is_some() && validate.is_none() { + return Err(syn::Error::new( + span, + "`validate_error` needs a `validate` expression to report for", + )); + } if complete_type.is_some() && matches!(shape, Shape::Bool | Shape::Count) { return Err(syn::Error::new( span, @@ -2262,6 +2289,8 @@ impl Field { value_name, required_collection, choices, + validate, + validate_error, complete, complete_type, value_enum, diff --git a/docs/spec/reference/arg.md b/docs/spec/reference/arg.md index d137d5fe6..5dba0999d 100644 --- a/docs/spec/reference/arg.md +++ b/docs/spec/reference/arg.md @@ -7,6 +7,7 @@ arg "[file]" // optional positional arg arg "" default="file.txt" // default value for arg arg "" env="MY_FILE" // arg can be backed by an env var arg "" parse="mycli parse-file {}" // parse arg value with external command +arg "" validate="int(value) >= 1 && int(value) <= 65535" validate_error="must be a valid port" arg "[file]" var=#true // multiple args can be passed (e.g. mycli file1 file2 file3) (0 or more) arg "" var=#true // multiple args can be passed (e.g. mycli file1 file2 file3) (1 or more) @@ -15,6 +16,12 @@ arg "" var=#true var_min=3 // at least 3 args must be passed arg "" var=#true var_max=3 // up to 3 args can be passed ``` +`validate` is an [expr](https://expr-lang.org/) expression evaluated once for each +value after defaults and environment fallbacks are applied. The only variable is +`value`, always a string. The expression must return a boolean; `false` reports +`validate_error`, or a generic validation error when it is omitted. Because the +expression is stored in the spec, generated Rust and Go parsers enforce the same rule. + ## Using Variadic Args in Bash When using variadic arguments (`var=#true`), the values are passed as a shell-escaped diff --git a/docs/spec/reference/flag.md b/docs/spec/reference/flag.md index 6439ee364..88f336d09 100644 --- a/docs/spec/reference/flag.md +++ b/docs/spec/reference/flag.md @@ -56,6 +56,10 @@ flag "--shell " { choices "bash" "zsh" "fish" // must be one of the choices } +flag "--port " { + arg "" validate="int(value) >= 1 && int(value) <= 65535" validate_error="must be a valid port" +} + flag "--env " { choices env="DEPLOY_ENVS" // values from $DEPLOY_ENVS, split on commas and/or whitespace } diff --git a/go/argv/argv.go b/go/argv/argv.go index 4c42bd1bd..a1b937bc2 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -376,6 +376,9 @@ type Error struct { // point of the error is to show it back. Value string Want string + // Reason explains a declarative validation failure. Empty for ordinary typed + // conversion failures, which use Want instead. + Reason string // Other is the flag [Name] cannot be given with, for CodeConflictingFlags. // Both are carried because either alone reads as a puzzle: which flag is // unwelcome depends on what else was given. @@ -420,7 +423,11 @@ func (e *Error) Error() string { // off the command line, and this one is likelier than most to hold // something strange — it exists because the text was not what the type // expected. - return "invalid value for " + e.Name + ": " + safe(e.Value) + message := "invalid value for " + e.Name + ": " + safe(e.Value) + if e.Reason != "" { + message += ": " + e.Reason + } + return message } return "parse error" } diff --git a/go/argv/post.go b/go/argv/post.go index a170f531b..e87d1dfa6 100644 --- a/go/argv/post.go +++ b/go/argv/post.go @@ -1,8 +1,11 @@ package argv import ( + "fmt" "os" "strings" + + "github.com/expr-lang/expr" ) // The rules that are decided once the last token has been read. @@ -80,6 +83,12 @@ type Meta struct { // binding applies, and lives on [Flag.VarMax] and [Arg.VarMax] instead — a // value bound here would fail an invocation that never broke it. VarMax uint32 + // Validate is a portable expr expression evaluated once for each raw value. + // The environment contains one string variable, `value`. + Validate string + // ValidateError is reported when Validate returns false. Empty uses the + // runtime's generic validation message. + ValidateError string // The relationships that need a second entry to answer, all resolved to keys rather // than left as the names the spec writes. Resolution happens where the whole @@ -311,6 +320,34 @@ func Check(m *Meta, values []string, occurrences int) *Error { } } + if m.Validate != "" { + program, err := expr.Compile(m.Validate, expr.Env(map[string]any{"value": ""})) + if err != nil { + return &Error{Code: CodeInvalidValue, Name: m.Name, Spelling: m.Spelling, + Reason: "validation expression failed: " + err.Error()} + } + for _, value := range values { + result, err := expr.Run(program, map[string]any{"value": value}) + if err != nil { + return &Error{Code: CodeInvalidValue, Name: m.Name, Spelling: m.Spelling, + Value: value, Reason: "validation expression failed: " + err.Error()} + } + valid, ok := result.(bool) + if !ok { + return &Error{Code: CodeInvalidValue, Name: m.Name, Spelling: m.Spelling, + Value: value, Reason: fmt.Sprintf("validation expression must return a boolean, got %v", result)} + } + if !valid { + reason := m.ValidateError + if reason == "" { + reason = "does not satisfy the validation expression" + } + return &Error{Code: CodeInvalidValue, Name: m.Name, Spelling: m.Spelling, + Value: value, Reason: reason} + } + } + } + // Only where something was given. An absent optional variadic has not broken // its minimum; it simply is not there, and reporting `var_too_few` for it // would make every bounded variadic effectively required. diff --git a/go/argv/post_test.go b/go/argv/post_test.go index 704f2d7c0..256656d12 100644 --- a/go/argv/post_test.go +++ b/go/argv/post_test.go @@ -155,6 +155,26 @@ func TestCheckRichChoices(t *testing.T) { } } +func TestCheckPortableValidation(t *testing.T) { + meta := &Meta{ + Name: "port", + Validate: "int(value) >= 1 && int(value) <= 65535", + ValidateError: "must be a valid port", + } + if err := Check(meta, []string{"9229"}, 1); err != nil { + t.Fatalf("a valid port should pass: %v", err) + } + for _, value := range []string{"0", "65536"} { + err := Check(meta, []string{value}, 1) + if err == nil || err.Code != CodeInvalidValue || err.Value != value || err.Reason != meta.ValidateError { + t.Errorf("%q: want the declared validation error, got %+v", value, err) + } + } + if err := Check(meta, []string{"not-a-number"}, 1); err == nil || err.Code != CodeInvalidValue { + t.Fatalf("an expression evaluation error should reject the value: %+v", err) + } +} + // TestEnvTruth pins the allow-list, including what it deliberately leaves out. func TestEnvTruth(t *testing.T) { for _, s := range []string{"1", "true", "True", "TRUE"} { diff --git a/go/argv/render.go b/go/argv/render.go index 2917e6fa8..3586870d7 100644 --- a/go/argv/render.go +++ b/go/argv/render.go @@ -109,6 +109,9 @@ func explain(err *Error, help HelpTable) string { if err.Want != "" { msg += " (expected " + err.Want + ")" } + if err.Reason != "" { + msg += ": " + err.Reason + } return msg case CodeConflictingFlags: other := err.Other diff --git a/go/conformance/validation_test.go b/go/conformance/validation_test.go new file mode 100644 index 000000000..e9b1381bd --- /dev/null +++ b/go/conformance/validation_test.go @@ -0,0 +1,35 @@ +package conformance + +import ( + "encoding/json" + "os" + "testing" + + "github.com/expr-lang/expr" +) + +func TestGoMatchesThePortableValidationVectors(t *testing.T) { + type vector struct { + Expression string `json:"expression"` + Value string `json:"value"` + Valid bool `json:"valid"` + } + data, err := os.ReadFile("../../conformance/validation.json") + if err != nil { + t.Fatal(err) + } + var vectors []vector + if err := json.Unmarshal(data, &vectors); err != nil { + t.Fatal(err) + } + for _, vector := range vectors { + got, err := expr.Eval(vector.Expression, map[string]any{"value": vector.Value}) + if err != nil { + t.Fatalf("%s with %q: %v", vector.Expression, vector.Value, err) + } + valid, ok := got.(bool) + if !ok || valid != vector.Valid { + t.Errorf("%s with %q: want %v, got %v", vector.Expression, vector.Value, vector.Valid, got) + } + } +} diff --git a/go/go.mod b/go/go.mod index 10f5f0f7b..5c8c6880c 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,3 +1,5 @@ module github.com/jdx/usage/go go 1.21 + +require github.com/expr-lang/expr v1.17.8 // indirect diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 000000000..5c0c3bdb6 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,2 @@ +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index ccc317fa1..5cf1c66af 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -305,6 +305,8 @@ type Arg struct { HelpFirstLine string `json:"help_first_line"` HelpLong string `json:"help_long"` HelpHeading string `json:"help_heading"` + Validate string `json:"validate"` + ValidateError string `json:"validate_error"` } // Example is a worked invocation a page prints. @@ -824,6 +826,8 @@ func (b *builder) flag(f *Flag) *argv.Flag { Default: f.defaults(), Env: f.Env, VarMin: clampVarMax(f.VarMin), + Validate: valueValidation(f.Arg), + ValidateError: valueValidationError(f.Arg), // Occurrences. The per-occurrence value bound is a limit binding applies, // and is set on the parse table below rather than here. VarMax: clampVarMax(f.VarMax), @@ -872,6 +876,8 @@ func (b *builder) arg(a *Arg) *argv.Arg { Default: a.Default, Env: a.Env, VarMin: clampVarMax(a.VarMin), + Validate: a.Validate, + ValidateError: a.ValidateError, // No VarMax: for an argument the bound is a limit binding applies, which // is what makes `[a]… [b]` fillable at all, so judging it again here would // fail an invocation that never broke it. @@ -879,6 +885,20 @@ func (b *builder) arg(a *Arg) *argv.Arg { return out } +func valueValidation(arg *Arg) string { + if arg == nil { + return "" + } + return arg.Validate +} + +func valueValidationError(arg *Arg) string { + if arg == nil { + return "" + } + return arg.ValidateError +} + // clampVarMax turns the spec's bound into the table's. // // Zero means unbounded in the table, which is also what an absent var_max lowers diff --git a/go/internal/spec/spec_test.go b/go/internal/spec/spec_test.go index f29b187d5..a2aa2c0bf 100644 --- a/go/internal/spec/spec_test.go +++ b/go/internal/spec/spec_test.go @@ -124,6 +124,26 @@ func TestRichChoicesSeparateAcceptanceFromVisibility(t *testing.T) { } } +func TestValidationIsReadThroughTheValue(t *testing.T) { + root, meta := build(&Spec{ + Name: "ex", Bin: "ex", + Cmd: Cmd{Name: "ex", + Flags: []Flag{{Name: "port", Long: []string{"port"}, Arg: &Arg{ + Name: "port", Validate: "int(value) > 0", ValidateError: "must be positive", + }}}, + Args: []Arg{{Name: "file", Validate: "value != ''"}}, + }, + }) + + port := metaFor(t, meta, root, "port") + if port.Validate != "int(value) > 0" || port.ValidateError != "must be positive" { + t.Errorf("nested flag validation was lost: %+v", port) + } + if got := metaFor(t, meta, root, "file").Validate; got != "value != ''" { + t.Errorf("argument validation was lost: %q", got) + } +} + // The two tables are separate data tied together by key, so the tie is what is // worth testing: every entry's metadata must describe that entry and no other. func TestMetadataLinesUpWithTheParseTables(t *testing.T) { diff --git a/lib/Cargo.toml b/lib/Cargo.toml index bdc14fe7c..29192ac03 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -38,6 +38,7 @@ tera = { version = "2", optional = true } thiserror = "2" versions = "7" xx = "2" +usage-validation = { workspace = true } [features] default = ["docs"] diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 6d0deee51..8c800cd20 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -357,6 +357,8 @@ pub struct SpecArg { pub hide: bool, pub default: Vec, pub choices: Option, + pub validate: Option, + pub validate_error: Option, pub env: Option, pub help_heading: Option, pub rendered: bool, @@ -660,6 +662,8 @@ impl From<&crate::SpecArg> for SpecArg { hide: arg.hide, default: arg.default.clone(), choices: arg.choices.as_ref().map(|choices| choices.for_help()), + validate: arg.validate.clone(), + validate_error: arg.validate_error.clone(), env: arg.env.clone(), help_heading: arg.help_heading.clone(), rendered: false, diff --git a/lib/src/error.rs b/lib/src/error.rs index 0798b6790..046a16943 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -111,6 +111,13 @@ pub enum UsageErr { #[error("Invalid file path: {0}")] InvalidPath(String), + #[error("Invalid value for {name}: {value}: {reason}")] + InvalidValue { + name: String, + value: String, + reason: String, + }, + #[error("Unsupported shell: {0}")] UnsupportedShell(String), } diff --git a/lib/src/parse.rs b/lib/src/parse.rs index b713fefa4..8c591b4a1 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1569,6 +1569,31 @@ fn parse_partial_with_env( } out.errors.extend(group_errors); + // Declarative value validation is deliberately post-binding. Defaults and environment + // fallbacks have landed by here, delimiters have already split values, and the binder stays + // unaware of both expr and value types. Like clap's value parsers, a declaration judges each + // resulting raw value independently. + for (arg, parsed) in &out.args { + validate_expression( + &arg.name, + arg.validate.as_deref(), + arg.validate_error.as_deref(), + parsed, + &mut out.errors, + ); + } + for (flag, parsed) in &out.flags { + if let Some(arg) = &flag.arg { + validate_expression( + &flag.name, + arg.validate.as_deref(), + arg.validate_error.as_deref(), + parsed, + &mut out.errors, + ); + } + } + if !exclusive_present { for flag in unique_flags(out.available_flags.values()) { if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) { @@ -1650,6 +1675,37 @@ fn parse_partial_with_env( Ok((out, overridden_flags)) } +fn validate_expression( + name: &str, + expression: Option<&str>, + message: Option<&str>, + parsed: &ParseValue, + errors: &mut Vec, +) { + let Some(expression) = expression else { + return; + }; + let values: &[String] = match parsed { + ParseValue::String(value) => std::slice::from_ref(value), + ParseValue::MultiString(values) => values, + ParseValue::Bool(_) | ParseValue::MultiBool(_) => return, + }; + for value in values { + let reason = match usage_validation::validate(expression, value) { + Ok(true) => continue, + Ok(false) => message + .unwrap_or("does not satisfy the validation expression") + .to_string(), + Err(error) => format!("validation expression failed: {error}"), + }; + errors.push(UsageErr::InvalidValue { + name: name.to_string(), + value: value.clone(), + reason, + }); + } +} + fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool { flag.name == selector || flag_keys(flag).iter().any(|key| key == selector) } diff --git a/lib/src/spec/arg.rs b/lib/src/spec/arg.rs index 24e722050..7eb4eece6 100644 --- a/lib/src/spec/arg.rs +++ b/lib/src/spec/arg.rs @@ -90,6 +90,12 @@ pub struct SpecArg { /// Valid choices for this argument #[serde(skip_serializing_if = "Option::is_none")] pub choices: Option, + /// A portable expr expression that must return true for each raw value. + #[serde(skip_serializing_if = "Option::is_none")] + pub validate: Option, + /// Message reported when [`SpecArg::validate`] returns false. + #[serde(skip_serializing_if = "Option::is_none")] + pub validate_error: Option, /// Raises the effect of the command when this argument is supplied. /// See [`crate::spec::effect::SpecCommandEffect`]; never lowers it. #[serde(skip_serializing_if = "Option::is_none")] @@ -159,6 +165,8 @@ impl SpecArg { } } "env" => arg.env = v.ensure_string().map(Some)?, + "validate" => arg.validate = v.ensure_string().map(Some)?, + "validate_error" => arg.validate_error = v.ensure_string().map(Some)?, "help_heading" => arg.help_heading = v.ensure_string().map(Some)?, k => bail_parse!(ctx, v.entry.span(), "unsupported arg key {k}"), } @@ -182,6 +190,10 @@ impl SpecArg { } } "env" => arg.env = child.arg(0)?.ensure_string().map(Some)?, + "validate" => arg.validate = child.arg(0)?.ensure_string().map(Some)?, + "validate_error" => { + arg.validate_error = child.arg(0)?.ensure_string().map(Some)?; + } "help_heading" => { arg.help_heading = child.arg(0)?.ensure_string().map(Some)?; } @@ -212,6 +224,22 @@ impl SpecArg { k => bail_parse!(ctx, child.node.name().span(), "unsupported arg child {k}"), } } + if arg.validate_error.is_some() && arg.validate.is_none() { + bail_parse!( + ctx, + node.node.name().span(), + "validate_error requires a validate expression" + ); + } + if let Some(expression) = &arg.validate { + if let Err(error) = usage_validation::check(expression) { + bail_parse!( + ctx, + node.node.name().span(), + "invalid validation expression: {error}" + ); + } + } arg.usage = arg.usage(); if let Some(help) = &arg.help { arg.help_first_line = Some(string::first_line(help)); @@ -301,6 +329,12 @@ impl From<&SpecArg> for KdlNode { if let Some(env) = &arg.env { node.push(string_entry(Some("env"), env)); } + if let Some(validate) = &arg.validate { + node.push(string_entry(Some("validate"), validate)); + } + if let Some(error) = &arg.validate_error { + node.push(string_entry(Some("validate_error"), error)); + } if let Some(help_heading) = &arg.help_heading { node.push(string_entry(Some("help_heading"), help_heading)); } @@ -512,6 +546,8 @@ impl From<&clap::Arg> for SpecArg { hide, default: default_values(arg), choices: None, + validate: None, + validate_error: None, effect: None, env: None, help_heading: arg.get_help_heading().map(|s| s.to_string()), @@ -541,6 +577,60 @@ impl Hash for SpecArg { } } +#[cfg(test)] +mod validation_tests { + use crate::{parse, Spec}; + + fn spec() -> Spec { + r#" +name "ex" +bin "ex" +arg "" validate="int(value) >= 1 && int(value) <= 65535" validate_error="must be a valid port" + "# + .parse() + .unwrap() + } + + #[test] + fn validation_round_trips_through_kdl() { + let spec = spec(); + let kdl = spec.to_string(); + let reparsed: Spec = kdl.parse().unwrap(); + let arg = &reparsed.cmd.args[0]; + assert_eq!( + arg.validate.as_deref(), + Some("int(value) >= 1 && int(value) <= 65535") + ); + assert_eq!(arg.validate_error.as_deref(), Some("must be a valid port")); + } + + #[test] + fn invalid_validation_declarations_are_rejected_with_the_spec() { + let missing_expression = r#"name "demo" +bin "demo" +arg "" validate_error="must be a port" +"#; + assert!(missing_expression.parse::().is_err()); + + let invalid_expression = r#"name "demo" +bin "demo" +arg "" validate="int(value) >" +"#; + assert!(invalid_expression.parse::().is_err()); + } + + #[test] + fn reference_parser_validates_each_raw_value() { + parse(&spec(), &["ex".to_string(), "9229".to_string()]).unwrap(); + + let error = parse(&spec(), &["ex".to_string(), "0".to_string()]).unwrap_err(); + assert!( + error.to_string().contains("must be a valid port"), + "{error:?}" + ); + } +} + #[cfg(test)] mod delimiter_tests { use crate::Spec; diff --git a/lib/src/spec/builder.rs b/lib/src/spec/builder.rs index 436bec27d..cc402497a 100644 --- a/lib/src/spec/builder.rs +++ b/lib/src/spec/builder.rs @@ -529,6 +529,18 @@ impl SpecArgBuilder { self } + /// Set a portable expr expression that must accept each raw value. + pub fn validate(mut self, expression: impl Into) -> Self { + self.inner.validate = Some(expression.into()); + self + } + + /// Set the message reported when validation returns false. + pub fn validate_error(mut self, message: impl Into) -> Self { + self.inner.validate_error = Some(message.into()); + self + } + /// Set choices from an environment variable #[cfg(feature = "unstable_choices_env")] pub fn choices_env(mut self, env: impl Into) -> Self { diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 4396ac617..396f1b943 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -13,18 +13,21 @@ license = { workspace = true } [dependencies] usage-argv = { workspace = true } usage-derive = { workspace = true, optional = true } +usage-validation = { workspace = true, optional = true } [features] -# Applications get a usable CLI out of the box: parse tables, help, and -# clap-shaped errors. Completions stay opt-in. Low-level adopters that want only -# the binding runtime keep depending on `usage-argv` directly (no defaults). -default = ["spec", "help", "diagnostics"] +# Applications get a usable CLI out of the box: parse tables, help, clap-shaped +# errors, and portable validation. Completions stay opt-in. Low-level adopters +# that want only the binding runtime keep depending on `usage-argv` directly +# (no defaults). +default = ["spec", "help", "diagnostics", "validation"] spec = ["usage-argv/spec", "dep:usage-derive"] help = ["spec"] completions = ["spec", "usage-argv/complete"] # Kept as a spelling close to the runtime feature for low-level adopters. complete = ["completions"] diagnostics = ["spec", "usage-argv/diagnostics"] +validation = ["dep:usage-validation"] [package.metadata.release] shared-version = true diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index 361b005e1..455134b76 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -42,6 +42,8 @@ pub use usage_argv as argv; pub use usage_argv::*; #[cfg(feature = "spec")] pub use usage_derive::{Args, Cli, Subcommands, ValueEnum}; +#[cfg(feature = "validation")] +pub use usage_validation as validation; #[cfg(all(test, feature = "spec"))] mod tests { @@ -49,8 +51,43 @@ mod tests { #[usage(bin = "internal")] struct Internal {} + #[derive(Debug, crate::Cli)] + #[usage(bin = "validated")] + struct Validated { + #[usage( + long, + validate = "int(value) >= 1 && int(value) <= 65535", + validate_error = "must be a valid port" + )] + port: Option, + } + #[test] fn derives_resolve_the_facade_from_inside_the_facade() { assert_eq!(Internal::spec().bin, Some("internal")); } + + #[test] + fn derives_evaluate_portable_validation_expressions() { + let valid = [ + ::std::ffi::OsStr::new("--port"), + ::std::ffi::OsStr::new("9229"), + ]; + assert_eq!(Validated::parse_from(&valid).unwrap().port, Some(9229)); + + let invalid = [ + ::std::ffi::OsStr::new("--port"), + ::std::ffi::OsStr::new("0"), + ]; + let crate::Error::InvalidValue(error) = Validated::parse_from(&invalid).unwrap_err() else { + panic!("expected invalid value"); + }; + assert_eq!(error.reason, "must be a valid port"); + + let kdl = Validated::to_kdl(); + assert!( + kdl.contains(r#"validate="int(value) >= 1 && int(value) <= 65535""#), + "{kdl}" + ); + } } diff --git a/validation/Cargo.toml b/validation/Cargo.toml new file mode 100644 index 000000000..fc77b81ed --- /dev/null +++ b/validation/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "usage-validation" +description = "Portable expression validation for usage specs" +version = "5.1.0" +edition = "2021" +rust-version = "1.91" +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +authors = { workspace = true } +license = { workspace = true } + +[dependencies] +expr-lang = { git = "https://github.com/jdx/expr.rs", rev = "2c6cecc8b38eaeba4aae01bbe055305f1e14e422" } + +[package.metadata.release] +shared-version = true +release = true diff --git a/validation/src/lib.rs b/validation/src/lib.rs new file mode 100644 index 000000000..ba1e43f6d --- /dev/null +++ b/validation/src/lib.rs @@ -0,0 +1,57 @@ +//! Cold-path, portable validation of values declared by a usage spec. +//! +//! Expressions use the language implemented by both `jdx/expr.rs` and the original +//! `expr-lang/expr` Go package. The spec supplies a raw CLI value as `value`, so explicit +//! conversions such as `int(value)` have the same meaning in every runtime. + +#![forbid(unsafe_code)] + +use expr::{Context, Value}; + +/// Check that a validation declaration is syntactically valid without evaluating it. +pub fn check(expression: &str) -> Result<(), String> { + expr::compile(expression) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +/// Evaluate a validation expression for one raw CLI value. +/// +/// A valid expression must return a boolean. `false` means the value does not satisfy the +/// declaration; an evaluator error or a non-boolean result means the declaration itself is +/// invalid. +pub fn validate(expression: &str, value: &str) -> Result { + let mut context = Context::default(); + context.insert("value", value); + match expr::eval(expression, &context).map_err(|error| error.to_string())? { + Value::Bool(valid) => Ok(valid), + result => Err(format!( + "validation expression must return a boolean, got {result}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::{check, validate}; + + #[test] + fn checks_syntax_without_needing_a_sample_value() { + assert_eq!(check("int(value) > 0"), Ok(())); + assert!(check("int(value) >").is_err()); + } + + #[test] + fn validates_raw_values_with_expr_conversions() { + let expression = "int(value) >= 1 && int(value) <= 65535"; + assert_eq!(validate(expression, "9229"), Ok(true)); + assert_eq!(validate(expression, "0"), Ok(false)); + assert!(validate(expression, "not-a-port").is_err()); + } + + #[test] + fn requires_a_boolean_result() { + let error = validate("int(value)", "42").unwrap_err(); + assert!(error.contains("must return a boolean"), "{error}"); + } +} From e41884d92ee5dce93a2fc08499a5c88d06256ae6 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:28:14 +0000 Subject: [PATCH 2/6] ci: check validation crate at its MSRV --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df3d1f087..31a19b192 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,11 +114,11 @@ jobs: # was checking it. It said 1.80 — set in 2024 when `LazyLock` landed and never revisited — # and usage-argv had not built at 1.80 for a long time. A promise nobody checks is a guess. # - # Two floors, because the crates genuinely differ: usage-argv, usage-derive and usage-config - # take no dependency on KDL and hold mise's own 1.91, which is the floor that matters for the - # fleet. usage-lib and everything that reads a spec through it need 1.95, because `kdl` says - # so. Each crate is checked at the version *it* declares rather than at one shared guess, - # which is how a lower floor stays real instead of aspirational. + # Two floors, because the crates genuinely differ: usage-argv, usage-derive, usage-config and + # usage-validation take no dependency on KDL and hold mise's own 1.91, which is the floor that + # matters for the fleet. usage-lib and everything that reads a spec through it need 1.95, + # because `kdl` says so. Each crate is checked at the version *it* declares rather than at one + # shared guess, which is how a lower floor stays real instead of aspirational. # # `cargo check` rather than `cargo test`: dev-dependencies are not part of what an adopter # compiles, and holding them to the MSRV would pin the toolchain past what the library needs. @@ -132,7 +132,7 @@ jobs: matrix: include: - version: "1.91" - crates: usage-argv usage-derive usage-config usage-rs + crates: usage-argv usage-derive usage-config usage-validation usage-rs - version: "1.95" crates: usage-lib usage-config-build clap_usage usage-cli steps: From 777ee05c486d20c2df58cae15290adfa051f2cab Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:37:54 +0000 Subject: [PATCH 3/6] style: format validation fixtures --- PLAN.md | 2 +- conformance/validation.json | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index ed4a9aaad..be5f340de 100644 --- a/PLAN.md +++ b/PLAN.md @@ -401,7 +401,7 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and getter, so the bridge cannot read it — same hole as `requires`. **Used by:** mise `bin_paths` (`default_value_if("json", IsPresent, "true")`). - [x] **Portable value validation** — `validate="int(value) >= 1 && int(value) <= - 65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and + 65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and generated Rust and Go parsers. `validate_error` supplies the user-facing failure. This covers clap's common range-validation use case without embedding a Rust parser function in the spec. clap's arbitrary `value_parser` remains inherently diff --git a/conformance/validation.json b/conformance/validation.json index 492fedbfe..5b5706ca4 100644 --- a/conformance/validation.json +++ b/conformance/validation.json @@ -1,6 +1,22 @@ [ - {"expression":"int(value) >= 1 && int(value) <= 65535","value":"9229","valid":true}, - {"expression":"int(value) >= 1 && int(value) <= 65535","value":"0","valid":false}, - {"expression":"value startsWith 'release-'","value":"release-5.1.0","valid":true}, - {"expression":"value startsWith 'release-'","value":"nightly","valid":false} + { + "expression": "int(value) >= 1 && int(value) <= 65535", + "value": "9229", + "valid": true + }, + { + "expression": "int(value) >= 1 && int(value) <= 65535", + "value": "0", + "valid": false + }, + { + "expression": "value startsWith 'release-'", + "value": "release-5.1.0", + "valid": true + }, + { + "expression": "value startsWith 'release-'", + "value": "nightly", + "valid": false + } ] From f771218973e1d4ed9de5f51a5f5384bd2ab48172 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:38:31 +0000 Subject: [PATCH 4/6] style: format validation plan entry --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index be5f340de..bd29955d1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -401,7 +401,7 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and getter, so the bridge cannot read it — same hole as `requires`. **Used by:** mise `bin_paths` (`default_value_if("json", IsPresent, "true")`). - [x] **Portable value validation** — `validate="int(value) >= 1 && int(value) <= - 65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and +65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and generated Rust and Go parsers. `validate_error` supplies the user-facing failure. This covers clap's common range-validation use case without embedding a Rust parser function in the spec. clap's arbitrary `value_parser` remains inherently From 924f8af99564985fe5620351484b21ad459af04f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:39:11 +0000 Subject: [PATCH 5/6] fix(lib): make expression validation opt-in --- Cargo.lock | 46 +++++++++++++++++++++++-- Cargo.toml | 2 +- lib/Cargo.toml | 3 +- lib/src/parse.rs | 79 +++++++++++++++++++++++++++++-------------- lib/src/spec/arg.rs | 29 ++++++++++++++-- usage-rs/Cargo.toml | 10 +++--- usage-rs/src/lib.rs | 8 +++++ validation/Cargo.toml | 2 +- 8 files changed, 142 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13655fd4e..02a2e50dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,11 +314,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", + "serde", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -725,9 +738,14 @@ dependencies = [ [[package]] name = "expr-lang" -version = "1.1.1" -source = "git+https://github.com/jdx/expr.rs?rev=2c6cecc8b38eaeba4aae01bbe055305f1e14e422#2c6cecc8b38eaeba4aae01bbe055305f1e14e422" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86283664be0dc99b1247e957cf8234bb0e98fe76096bd36767d8f87ac98f00b7" dependencies = [ + "base64 0.23.1", + "chrono", + "chrono-tz", + "iana-time-zone", "indexmap 2.14.0", "log", "once_cell", @@ -1370,6 +1388,24 @@ dependencies = [ "pest", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2018,6 +2054,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index d5e6337ff..8328d4c8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ usage-cli = { path = "./cli" } usage-argv = { path = "./argv", version = "5.1.0" } usage-config = { path = "./config", version = "5.1.0" } usage-derive = { path = "./derive", version = "5.1.0" } -usage-lib = { path = "./lib", version = "5.1.0", features = ["clap"] } +usage-lib = { path = "./lib", version = "5.1.0", features = ["clap", "validation"] } usage-rs = { path = "./usage-rs", version = "5.1.0" } usage-validation = { path = "./validation", version = "5.1.0" } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 29192ac03..f23e05882 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -38,11 +38,12 @@ tera = { version = "2", optional = true } thiserror = "2" versions = "7" xx = "2" -usage-validation = { workspace = true } +usage-validation = { workspace = true, optional = true } [features] default = ["docs"] docs = ["tera", "roff"] +validation = ["dep:usage-validation"] unstable_choices_env = [] [dev-dependencies] diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 8c591b4a1..642739ea4 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -520,6 +520,30 @@ impl<'a> Parser<'a> { } } } + // Declarative value validation is deliberately post-binding. Defaults and + // environment fallbacks have landed by here, and delimiters were already split + // while binding. Like clap's value parsers, a declaration judges each resulting + // raw value independently. + for (arg, parsed) in &out.args { + validate_expression( + &arg.name, + arg.validate.as_deref(), + arg.validate_error.as_deref(), + parsed, + &mut out.errors, + ); + } + for (flag, parsed) in &out.flags { + if let Some(arg) = &flag.arg { + validate_expression( + &flag.name, + arg.validate.as_deref(), + arg.validate_error.as_deref(), + parsed, + &mut out.errors, + ); + } + } if let Some(err) = out.errors.iter().find(|e| matches!(e, UsageErr::Help(_))) { bail!("{err}"); } @@ -1569,31 +1593,6 @@ fn parse_partial_with_env( } out.errors.extend(group_errors); - // Declarative value validation is deliberately post-binding. Defaults and environment - // fallbacks have landed by here, delimiters have already split values, and the binder stays - // unaware of both expr and value types. Like clap's value parsers, a declaration judges each - // resulting raw value independently. - for (arg, parsed) in &out.args { - validate_expression( - &arg.name, - arg.validate.as_deref(), - arg.validate_error.as_deref(), - parsed, - &mut out.errors, - ); - } - for (flag, parsed) in &out.flags { - if let Some(arg) = &flag.arg { - validate_expression( - &flag.name, - arg.validate.as_deref(), - arg.validate_error.as_deref(), - parsed, - &mut out.errors, - ); - } - } - if !exclusive_present { for flag in unique_flags(out.available_flags.values()) { if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) { @@ -1685,12 +1684,17 @@ fn validate_expression( let Some(expression) = expression else { return; }; + #[cfg(not(feature = "validation"))] + let _ = expression; let values: &[String] = match parsed { ParseValue::String(value) => std::slice::from_ref(value), ParseValue::MultiString(values) => values, ParseValue::Bool(_) | ParseValue::MultiBool(_) => return, }; for value in values { + #[cfg(not(feature = "validation"))] + let _ = message; + #[cfg(feature = "validation")] let reason = match usage_validation::validate(expression, value) { Ok(true) => continue, Ok(false) => message @@ -1698,6 +1702,8 @@ fn validate_expression( .to_string(), Err(error) => format!("validation expression failed: {error}"), }; + #[cfg(not(feature = "validation"))] + let reason = "expression validation requires the `validation` feature".to_string(); errors.push(UsageErr::InvalidValue { name: name.to_string(), value: value.clone(), @@ -1706,6 +1712,29 @@ fn validate_expression( } } +#[cfg(all(test, not(feature = "validation")))] +mod optional_validation_tests { + use crate::{parse, Spec}; + + #[test] + fn validation_declarations_require_the_opt_in_runtime_feature() { + let spec: Spec = r#" +name "ex" +bin "ex" +arg "" validate="int(value) > 0" + "# + .parse() + .unwrap(); + let error = parse(&spec, &["ex".to_string(), "1".to_string()]).unwrap_err(); + assert!( + error + .to_string() + .contains("requires the `validation` feature"), + "{error:?}" + ); + } +} + fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool { flag.name == selector || flag_keys(flag).iter().any(|key| key == selector) } diff --git a/lib/src/spec/arg.rs b/lib/src/spec/arg.rs index 7eb4eece6..c8f0d7597 100644 --- a/lib/src/spec/arg.rs +++ b/lib/src/spec/arg.rs @@ -231,6 +231,7 @@ impl SpecArg { "validate_error requires a validate expression" ); } + #[cfg(feature = "validation")] if let Some(expression) = &arg.validate { if let Err(error) = usage_validation::check(expression) { bail_parse!( @@ -577,9 +578,11 @@ impl Hash for SpecArg { } } -#[cfg(test)] +#[cfg(all(test, feature = "validation"))] mod validation_tests { - use crate::{parse, Spec}; + use std::collections::HashMap; + + use crate::{parse, parse::Parser, Spec}; fn spec() -> Spec { r#" @@ -629,6 +632,28 @@ arg "" validate="int(value) >" "{error:?}" ); } + + #[test] + fn reference_parser_validates_environment_and_default_fallbacks() { + let spec: Spec = r#" +name "ex" +bin "ex" +arg "[port]" env="PORT" validate="int(value) > 0" validate_error="port must be positive" +flag "--mode" default="bad" { + arg "" validate="value == 'good'" validate_error="mode must be good" +} + "# + .parse() + .unwrap(); + let env = HashMap::from([("PORT".to_string(), "0".to_string())]); + let error = Parser::new(&spec) + .with_env(env) + .parse(&["ex".to_string()]) + .unwrap_err(); + let error = error.to_string(); + assert!(error.contains("port must be positive"), "{error}"); + assert!(error.contains("mode must be good"), "{error}"); + } } #[cfg(test)] diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 396f1b943..eceae9253 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -16,11 +16,11 @@ usage-derive = { workspace = true, optional = true } usage-validation = { workspace = true, optional = true } [features] -# Applications get a usable CLI out of the box: parse tables, help, clap-shaped -# errors, and portable validation. Completions stay opt-in. Low-level adopters -# that want only the binding runtime keep depending on `usage-argv` directly -# (no defaults). -default = ["spec", "help", "diagnostics", "validation"] +# Applications get a usable CLI out of the box: parse tables, help, and +# clap-shaped errors. Completions and expression validation stay opt-in. +# Low-level adopters that want only the binding runtime keep depending on +# `usage-argv` directly (no defaults). +default = ["spec", "help", "diagnostics"] spec = ["usage-argv/spec", "dep:usage-derive"] help = ["spec"] completions = ["spec", "usage-argv/complete"] diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index 455134b76..b27839e2a 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -10,6 +10,12 @@ //! usage = { package = "usage-rs", version = "5.1" } //! ``` //! +//! Enable portable expression validation only when a CLI declares `validate` rules: +//! +//! ```toml +//! usage = { package = "usage-rs", version = "5.1", features = ["validation"] } +//! ``` +//! //! ``` //! use usage_rs as usage; //! # #[cfg(feature = "spec")] @@ -51,6 +57,7 @@ mod tests { #[usage(bin = "internal")] struct Internal {} + #[cfg(feature = "validation")] #[derive(Debug, crate::Cli)] #[usage(bin = "validated")] struct Validated { @@ -67,6 +74,7 @@ mod tests { assert_eq!(Internal::spec().bin, Some("internal")); } + #[cfg(feature = "validation")] #[test] fn derives_evaluate_portable_validation_expressions() { let valid = [ diff --git a/validation/Cargo.toml b/validation/Cargo.toml index fc77b81ed..2d6438ac3 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -11,7 +11,7 @@ authors = { workspace = true } license = { workspace = true } [dependencies] -expr-lang = { git = "https://github.com/jdx/expr.rs", rev = "2c6cecc8b38eaeba4aae01bbe055305f1e14e422" } +expr-lang = "2.0.0" [package.metadata.release] shared-version = true From 2ba46c67e94855a706e80976867707d45d77885a Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:01:28 +0000 Subject: [PATCH 6/6] fix(spec): address expression validation review --- argv/src/spec.rs | 12 ++++++---- conformance/tests/validation.rs | 1 + derive/src/codegen.rs | 7 ++++++ go/argv/post.go | 2 +- go/argv/post_test.go | 12 ++++++++++ go/conformance/validation_test.go | 3 +++ lib/src/parse.rs | 1 + lib/src/spec/arg.rs | 21 +++++++++++++++-- lib/src/spec/builder.rs | 13 +++++++++++ usage-rs/src/lib.rs | 39 +++++++++++++++++++++++++++++++ 10 files changed, 104 insertions(+), 7 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 1458ab001..e3a5db3be 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1327,8 +1327,10 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: if let Some(validate) = meta.validate { write!(out, " validate={}", quoted(validate))?; } - if let Some(error) = meta.validate_error { - write!(out, " validate_error={}", quoted(error))?; + if meta.validate.is_some() { + if let Some(error) = meta.validate_error { + write!(out, " validate_error={}", quoted(error))?; + } } if meta.choices.is_empty() { out.push('\n'); @@ -1402,8 +1404,10 @@ fn write_arg(out: &mut String, meta: &ArgMeta<'_>, depth: usize) -> core::fmt::R if let Some(validate) = meta.validate { write!(out, " validate={}", quoted(validate))?; } - if let Some(error) = meta.validate_error { - write!(out, " validate_error={}", quoted(error))?; + if meta.validate.is_some() { + if let Some(error) = meta.validate_error { + write!(out, " validate_error={}", quoted(error))?; + } } write_single_default(out, meta.default)?; diff --git a/conformance/tests/validation.rs b/conformance/tests/validation.rs index efab1d19d..496daf4ed 100644 --- a/conformance/tests/validation.rs +++ b/conformance/tests/validation.rs @@ -11,6 +11,7 @@ struct Vector { fn rust_matches_the_portable_validation_vectors() { let vectors: Vec = serde_json::from_str(include_str!("../validation.json")).expect("valid vectors"); + assert!(!vectors.is_empty(), "validation fixture must not be empty"); for vector in vectors { let actual = usage_validation::validate(&vector.expression, &vector.value) .unwrap_or_else(|err| panic!("{} with {:?}: {err}", vector.expression, vector.value)); diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index adad9d646..0ca308457 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2674,6 +2674,12 @@ fn subcommand_parts(cli: &Cli) -> Option { pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); + let validation = validation_path(); + let validation_import = cli + .fields + .iter() + .any(|field| field.validate.is_some()) + .then(|| quote!(use #validation as usage_validation;)); let presence = presence_methods(cli); let apply_defaults = declared_defaults(cli); let apply_env = env_fallbacks(cli); @@ -2801,6 +2807,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { )] const _: () = { use #runtime as usage_argv; + #validation_import #flatten_checks #keys diff --git a/go/argv/post.go b/go/argv/post.go index e87d1dfa6..efb145db0 100644 --- a/go/argv/post.go +++ b/go/argv/post.go @@ -335,7 +335,7 @@ func Check(m *Meta, values []string, occurrences int) *Error { valid, ok := result.(bool) if !ok { return &Error{Code: CodeInvalidValue, Name: m.Name, Spelling: m.Spelling, - Value: value, Reason: fmt.Sprintf("validation expression must return a boolean, got %v", result)} + Value: value, Reason: fmt.Sprintf("validation expression must return a boolean, got %T", result)} } if !valid { reason := m.ValidateError diff --git a/go/argv/post_test.go b/go/argv/post_test.go index 256656d12..649bd3258 100644 --- a/go/argv/post_test.go +++ b/go/argv/post_test.go @@ -173,6 +173,18 @@ func TestCheckPortableValidation(t *testing.T) { if err := Check(meta, []string{"not-a-number"}, 1); err == nil || err.Code != CodeInvalidValue { t.Fatalf("an expression evaluation error should reject the value: %+v", err) } + + meta.Validate = "value" + err := Check(meta, []string{"bad\x1b[31m"}, 1) + if err == nil || err.Reason != "validation expression must return a boolean, got string" { + t.Fatalf("a non-boolean result should report only its type: %+v", err) + } + + meta.Validate = "int(value) > 0" + err = Check(meta, []string{"0", "-1"}, 2) + if err == nil || err.Value != "0" { + t.Fatalf("multi-value validation should stop at the first failure: %+v", err) + } } // TestEnvTruth pins the allow-list, including what it deliberately leaves out. diff --git a/go/conformance/validation_test.go b/go/conformance/validation_test.go index e9b1381bd..8a2f8745d 100644 --- a/go/conformance/validation_test.go +++ b/go/conformance/validation_test.go @@ -22,6 +22,9 @@ func TestGoMatchesThePortableValidationVectors(t *testing.T) { if err := json.Unmarshal(data, &vectors); err != nil { t.Fatal(err) } + if len(vectors) == 0 { + t.Fatal("validation fixture must not be empty") + } for _, vector := range vectors { got, err := expr.Eval(vector.Expression, map[string]any{"value": vector.Value}) if err != nil { diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 642739ea4..90970e211 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1709,6 +1709,7 @@ fn validate_expression( value: value.clone(), reason, }); + break; } } diff --git a/lib/src/spec/arg.rs b/lib/src/spec/arg.rs index c8f0d7597..6cc960057 100644 --- a/lib/src/spec/arg.rs +++ b/lib/src/spec/arg.rs @@ -333,8 +333,10 @@ impl From<&SpecArg> for KdlNode { if let Some(validate) = &arg.validate { node.push(string_entry(Some("validate"), validate)); } - if let Some(error) = &arg.validate_error { - node.push(string_entry(Some("validate_error"), error)); + if arg.validate.is_some() { + if let Some(error) = &arg.validate_error { + node.push(string_entry(Some("validate_error"), error)); + } } if let Some(help_heading) = &arg.help_heading { node.push(string_entry(Some("help_heading"), help_heading)); @@ -631,6 +633,21 @@ arg "" validate="int(value) >" error.to_string().contains("must be a valid port"), "{error:?}" ); + + let variadic: Spec = r#" +name "ex" +bin "ex" +arg "" var=#true validate="int(value) > 0" validate_error="port must be positive" + "# + .parse() + .unwrap(); + let error = parse( + &variadic, + &["ex".to_string(), "0".to_string(), "-1".to_string()], + ) + .unwrap_err() + .to_string(); + assert_eq!(error.matches("port must be positive").count(), 1, "{error}"); } #[test] diff --git a/lib/src/spec/builder.rs b/lib/src/spec/builder.rs index cc402497a..186e944c7 100644 --- a/lib/src/spec/builder.rs +++ b/lib/src/spec/builder.rs @@ -570,6 +570,9 @@ impl SpecArgBuilder { /// Build the final SpecArg #[must_use] pub fn build(mut self) -> SpecArg { + if self.inner.validate.is_none() { + self.inner.validate_error = None; + } self.inner.usage = self.inner.usage(); self.inner } @@ -938,6 +941,16 @@ mod tests { assert!(!arg.required); } + #[test] + fn test_arg_builder_drops_validation_error_without_expression() { + let arg = SpecArgBuilder::new() + .name("port") + .validate_error("must be a valid port") + .build(); + + assert!(arg.validate_error.is_none()); + } + #[test] fn test_command_builder_basic() { let cmd = SpecCommandBuilder::new() diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index b27839e2a..7c9e8a3cd 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -69,6 +69,21 @@ mod tests { port: Option, } + #[cfg(feature = "validation")] + #[derive(Debug, crate::Args)] + struct ValidatedArgs { + #[usage(long, validate = "value == 'ok'", validate_error = "must be ok")] + token: Option, + } + + #[cfg(feature = "validation")] + #[derive(Debug, crate::Cli)] + #[usage(bin = "validated-args")] + struct ValidatedArgsCli { + #[usage(flatten)] + args: ValidatedArgs, + } + #[test] fn derives_resolve_the_facade_from_inside_the_facade() { assert_eq!(Internal::spec().bin, Some("internal")); @@ -92,6 +107,30 @@ mod tests { }; assert_eq!(error.reason, "must be a valid port"); + let invalid_args = [ + ::std::ffi::OsStr::new("--token"), + ::std::ffi::OsStr::new("bad"), + ]; + let crate::Error::InvalidValue(error) = + ValidatedArgsCli::parse_from(&invalid_args).unwrap_err() + else { + panic!("expected invalid value from flattened Args"); + }; + assert_eq!(error.reason, "must be ok"); + + let valid_args = [ + ::std::ffi::OsStr::new("--token"), + ::std::ffi::OsStr::new("ok"), + ]; + assert_eq!( + ValidatedArgsCli::parse_from(&valid_args) + .unwrap() + .args + .token + .as_deref(), + Some("ok") + ); + let kdl = Validated::to_kdl(); assert!( kdl.contains(r#"validate="int(value) >= 1 && int(value) <= 65535""#),