usage_validation/lib.rs
1//! Cold-path, portable validation of values declared by a usage spec.
2//!
3//! Expressions use the language implemented by both `jdx/expr.rs` and the original
4//! `expr-lang/expr` Go package. The spec supplies a raw CLI value as `value`, so explicit
5//! conversions such as `int(value)` have the same meaning in every runtime.
6//!
7//! # What this build of the language carries
8//!
9//! A `validate=` expression reads one string and returns a boolean, so this depends on
10//! `expr-lang` with its default features off — the dates, JSON codec and base64 it ships by
11//! default are twelve crates a CLI would compile to evaluate `int(value) > 0`. The operators,
12//! the string and array builtins, the numeric conversions and `matches` are all here.
13//!
14//! `matches` is kept deliberately: the Go runtime has it, and a spec is supposed to mean the
15//! same thing in both.
16//!
17//! A spec that does want the rest — `date(value) > now()`, `fromJSON(value)` — gets it by
18//! naming the feature in the *application's* manifest, which cargo unifies with this crate's
19//! copy:
20//!
21//! ```toml
22//! expr-lang = { version = "2.1", features = ["temporal"] }
23//! ```
24//!
25//! Without that, such an expression fails the same way `nosuchfn(value)` always has: not at
26//! [`check`] time, which only parses, but when a value reaches it — with a message naming the
27//! feature to add.
28
29#![forbid(unsafe_code)]
30
31use expr::{Context, Value};
32
33/// Check that a validation declaration is syntactically valid without evaluating it.
34pub fn check(expression: &str) -> Result<(), String> {
35 expr::compile(expression)
36 .map(|_| ())
37 .map_err(|error| error.to_string())
38}
39
40/// Evaluate a validation expression for one raw CLI value.
41///
42/// A valid expression must return a boolean. `false` means the value does not satisfy the
43/// declaration; an evaluator error or a non-boolean result means the declaration itself is
44/// invalid.
45pub fn validate(expression: &str, value: &str) -> Result<bool, String> {
46 let mut context = Context::default();
47 context.insert("value", value);
48 match expr::eval(expression, &context).map_err(|error| error.to_string())? {
49 Value::Bool(valid) => Ok(valid),
50 result => Err(format!(
51 "validation expression must return a boolean, got {result}"
52 )),
53 }
54}
55
56#[cfg(test)]
57mod tests {
58 use super::{check, validate};
59
60 #[test]
61 fn checks_syntax_without_needing_a_sample_value() {
62 assert_eq!(check("int(value) > 0"), Ok(()));
63 assert!(check("int(value) >").is_err());
64 }
65
66 #[test]
67 fn validates_raw_values_with_expr_conversions() {
68 let expression = "int(value) >= 1 && int(value) <= 65535";
69 assert_eq!(validate(expression, "9229"), Ok(true));
70 assert_eq!(validate(expression, "0"), Ok(false));
71 assert!(validate(expression, "not-a-port").is_err());
72 }
73
74 #[test]
75 fn requires_a_boolean_result() {
76 let error = validate("int(value)", "42").unwrap_err();
77 assert!(error.contains("must return a boolean"), "{error}");
78 }
79}