Skip to main content

roas_arazzo/
validation.rs

1//! Validation framework for Arazzo documents.
2//!
3//! Modeled after [`roas::validation`] / `roas-overlay`: a public
4//! [`Validate`] trait drives a recursive descent through a
5//! crate-internal trait. The current location is held as a single
6//! mutable path buffer on the [`Context`]; nodes `enter` a child
7//! segment, recurse, and the segment is truncated on the way out. The
8//! path string is cloned only when an error is actually recorded, so a
9//! valid document allocates no per-node path strings. Errors collect
10//! rather than fail fast.
11//!
12//! [`roas::validation`]: https://docs.rs/roas/latest/roas/validation/index.html
13
14use enumset::{EnumSet, EnumSetType};
15use std::collections::BTreeMap;
16use std::fmt::{self, Display, Write};
17
18/// A single validation finding.
19///
20/// `path` is a human-readable locator (e.g. `#.workflows[0].steps[1].stepId`),
21/// not an RFC 6901 JSON Pointer.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub struct ValidationError {
25    pub path: String,
26    pub message: String,
27}
28
29impl ValidationError {
30    pub(crate) fn new(path: String, message: String) -> Self {
31        Self { path, message }
32    }
33
34    /// Substring search across path and message (and the rendered
35    /// boundary). Mirrors the helper of the same name in `roas` for
36    /// consistency.
37    pub fn contains(&self, needle: &str) -> bool {
38        if self.path.contains(needle) || self.message.contains(needle) {
39            return true;
40        }
41        self.to_string().contains(needle)
42    }
43}
44
45impl Display for ValidationError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "{}: {}", self.path, self.message)
48    }
49}
50
51impl PartialEq<str> for ValidationError {
52    fn eq(&self, other: &str) -> bool {
53        let plen = self.path.len();
54        let sep = ": ";
55        other.len() == plen + sep.len() + self.message.len()
56            && other.starts_with(&self.path)
57            && other[plen..].starts_with(sep)
58            && other[plen + sep.len()..] == self.message
59    }
60}
61
62impl PartialEq<&str> for ValidationError {
63    fn eq(&self, other: &&str) -> bool {
64        <ValidationError as PartialEq<str>>::eq(self, other)
65    }
66}
67
68/// The accumulated outcome of a validation pass.
69#[derive(Debug, Clone, PartialEq)]
70#[non_exhaustive]
71pub struct Error {
72    pub errors: Vec<ValidationError>,
73}
74
75impl Display for Error {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        writeln!(f, "{} errors found:", self.errors.len())?;
78        for error in &self.errors {
79            writeln!(f, "- {error}")?;
80        }
81        Ok(())
82    }
83}
84
85impl std::error::Error for Error {}
86
87/// Per-call validation toggles.
88///
89/// Each option suppresses one shallow check so callers can opt out of
90/// individual diagnostics without disabling the whole validator. Marked
91/// `#[non_exhaustive]` so future toggles are non-breaking additions.
92#[derive(EnumSetType, Debug)]
93#[non_exhaustive]
94pub enum ValidationOptions {
95    /// Allow `info.title` to be empty (still required to be present).
96    IgnoreEmptyInfoTitle,
97    /// Allow `info.version` to be empty (still required to be present).
98    IgnoreEmptyInfoVersion,
99}
100
101#[cfg(feature = "clap")]
102impl clap::ValueEnum for ValidationOptions {
103    fn value_variants<'a>() -> &'a [Self] {
104        &[
105            ValidationOptions::IgnoreEmptyInfoTitle,
106            ValidationOptions::IgnoreEmptyInfoVersion,
107        ]
108    }
109
110    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
111        let (name, help) = match self {
112            ValidationOptions::IgnoreEmptyInfoTitle => {
113                ("empty-info-title", "Allow empty `info.title`")
114            }
115            ValidationOptions::IgnoreEmptyInfoVersion => {
116                ("empty-info-version", "Allow empty `info.version`")
117            }
118        };
119        Some(clap::builder::PossibleValue::new(name).help(help))
120    }
121}
122
123/// Validate an Arazzo document, collecting every diagnostic.
124pub trait Validate {
125    fn validate(&self, options: EnumSet<ValidationOptions>) -> Result<(), Error>;
126}
127
128/// Crate-internal: implemented by every component type. The location is
129/// carried by [`Context`]'s path buffer rather than a per-call string.
130pub(crate) trait ValidateWithContext {
131    fn validate_with_context(&self, ctx: &mut Context);
132}
133
134pub(crate) struct Context {
135    options: EnumSet<ValidationOptions>,
136    pub errors: Vec<ValidationError>,
137    /// The current location, e.g. `#.workflows[0].steps[1]`. Mutated in
138    /// place via `in_*`; only cloned when an error is recorded.
139    path: String,
140}
141
142impl Context {
143    pub fn new(options: EnumSet<ValidationOptions>) -> Self {
144        Self {
145            options,
146            errors: Vec::new(),
147            path: "#".to_owned(),
148        }
149    }
150
151    pub fn is_option(&self, option: ValidationOptions) -> bool {
152        self.options.contains(option)
153    }
154
155    /// Record an error at the current path.
156    pub fn error(&mut self, message: impl Into<String>) {
157        self.errors
158            .push(ValidationError::new(self.path.clone(), message.into()));
159    }
160
161    /// Record an error at `<current>.<field>` without descending into it.
162    pub fn error_field(&mut self, field: &str, message: impl Into<String>) {
163        let mark = self.path.len();
164        self.push_field(field);
165        self.error(message);
166        self.path.truncate(mark);
167    }
168
169    /// Push `.<field>` for the duration of `f`.
170    pub fn in_field<R>(&mut self, field: &str, f: impl FnOnce(&mut Self) -> R) -> R {
171        let mark = self.path.len();
172        self.push_field(field);
173        let result = f(self);
174        self.path.truncate(mark);
175        result
176    }
177
178    /// Push `.<field>[<index>]` for the duration of `f`.
179    pub fn in_index<R>(&mut self, field: &str, index: usize, f: impl FnOnce(&mut Self) -> R) -> R {
180        let mark = self.path.len();
181        self.push_field(field);
182        let _ = write!(self.path, "[{index}]");
183        let result = f(self);
184        self.path.truncate(mark);
185        result
186    }
187
188    /// Push `.<field>.<key>` for the duration of `f` (for map entries).
189    pub fn in_key<R>(&mut self, field: &str, key: &str, f: impl FnOnce(&mut Self) -> R) -> R {
190        let mark = self.path.len();
191        self.push_field(field);
192        self.push_field(key);
193        let result = f(self);
194        self.path.truncate(mark);
195        result
196    }
197
198    /// Error at `<current>.<field>` if the required string is empty.
199    pub fn require_non_empty(&mut self, field: &str, value: &str) {
200        if value.is_empty() {
201            self.error_field(field, "must not be empty");
202        }
203    }
204
205    /// Validate that every key in a map matches the component / output
206    /// key pattern `^[a-zA-Z0-9\.\-_]+$`, reported under `.<field>`.
207    pub fn validate_map_keys<V>(&mut self, field: &str, map: &BTreeMap<String, V>) {
208        self.in_field(field, |ctx| {
209            for key in map.keys() {
210                if !is_valid_key(key) {
211                    ctx.error_field(key, r"key must match `^[a-zA-Z0-9\.\-_]+$`");
212                }
213            }
214        });
215    }
216
217    fn push_field(&mut self, field: &str) {
218        self.path.push('.');
219        self.path.push_str(field);
220    }
221
222    pub fn into_result(self) -> Result<(), Error> {
223        if self.errors.is_empty() {
224            Ok(())
225        } else {
226            Err(Error {
227                errors: self.errors,
228            })
229        }
230    }
231
232    #[cfg(test)]
233    pub fn with_path(options: EnumSet<ValidationOptions>, path: &str) -> Self {
234        Self {
235            options,
236            errors: Vec::new(),
237            path: path.to_owned(),
238        }
239    }
240}
241
242/// Source-description `name` pattern `^[A-Za-z0-9_\-]+$` (non-empty,
243/// ASCII alphanumerics plus `_` and `-`). Hand-rolled to avoid pulling
244/// in a regex engine for one check.
245pub(crate) fn is_valid_name(s: &str) -> bool {
246    !s.is_empty()
247        && s.bytes()
248            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
249}
250
251/// Component / output key pattern `^[a-zA-Z0-9\.\-_]+$` (non-empty,
252/// ASCII alphanumerics plus `.`, `-`, `_`).
253pub(crate) fn is_valid_key(s: &str) -> bool {
254    !s.is_empty()
255        && s.bytes()
256            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_')
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn error_display_renders_with_count_and_bullets() {
265        let err = Error {
266            errors: vec![
267                ValidationError::new("#.a".into(), "first".into()),
268                ValidationError::new("#.b".into(), "second".into()),
269            ],
270        };
271        assert_eq!(
272            format!("{err}"),
273            "2 errors found:\n- #.a: first\n- #.b: second\n",
274        );
275    }
276
277    #[test]
278    fn error_zero_count_still_renders_header() {
279        let err = Error { errors: vec![] };
280        assert_eq!(format!("{err}"), "0 errors found:\n");
281    }
282
283    #[test]
284    fn validation_error_partial_eq_against_str_matches_display_form() {
285        let e = ValidationError::new("#.info.title".into(), "must not be empty".into());
286        assert!(e == "#.info.title: must not be empty");
287        let owned = String::from("#.info.title: must not be empty");
288        assert!(e == *owned.as_str());
289        assert!(e != "different");
290    }
291
292    #[test]
293    fn validation_error_contains_matches_across_boundary() {
294        let e = ValidationError::new("#.info.title".into(), "must not be empty".into());
295        assert!(e.contains("title: must"));
296        assert!(e.contains("#.info"));
297        assert!(e.contains("must not"));
298        assert!(!e.contains("nowhere"));
299    }
300
301    #[test]
302    fn error_records_at_current_path() {
303        let mut ctx = Context::new(EnumSet::empty());
304        ctx.error("kaboom");
305        assert!(ctx.errors[0] == "#: kaboom");
306    }
307
308    #[test]
309    fn in_scopes_compose_and_truncate() {
310        let mut ctx = Context::new(EnumSet::empty());
311        ctx.in_index("workflows", 0, |ctx| {
312            ctx.in_index("steps", 1, |ctx| {
313                ctx.error_field("stepId", "bad");
314            });
315            // back to #.workflows[0] after inner scope
316            ctx.error("here");
317        });
318        // back to # after outer scope
319        ctx.error("root");
320        assert!(ctx.errors[0] == "#.workflows[0].steps[1].stepId: bad");
321        assert!(ctx.errors[1] == "#.workflows[0]: here");
322        assert!(ctx.errors[2] == "#: root");
323    }
324
325    #[test]
326    fn in_key_appends_dotted_key() {
327        let mut ctx = Context::new(EnumSet::empty());
328        ctx.in_key("parameters", "petId", |ctx| ctx.error("oops"));
329        assert!(ctx.errors[0] == "#.parameters.petId: oops");
330    }
331
332    #[test]
333    fn context_with_no_errors_returns_ok() {
334        let ctx = Context::new(EnumSet::empty());
335        assert!(ctx.into_result().is_ok());
336    }
337
338    #[test]
339    fn context_is_option_reflects_set_membership() {
340        let opts = EnumSet::only(ValidationOptions::IgnoreEmptyInfoTitle);
341        let ctx = Context::new(opts);
342        assert!(ctx.is_option(ValidationOptions::IgnoreEmptyInfoTitle));
343        assert!(!ctx.is_option(ValidationOptions::IgnoreEmptyInfoVersion));
344    }
345
346    #[test]
347    fn require_non_empty_pushes_error_for_empty_only() {
348        let mut ctx = Context::new(EnumSet::empty());
349        ctx.in_field("info", |ctx| {
350            ctx.require_non_empty("title", "");
351            ctx.require_non_empty("version", "ok");
352        });
353        assert_eq!(ctx.errors.len(), 1);
354        assert!(ctx.errors[0] == "#.info.title: must not be empty");
355    }
356
357    #[test]
358    fn is_valid_name_accepts_word_chars_and_rejects_others() {
359        assert!(is_valid_name("petStore"));
360        assert!(is_valid_name("pet_store-1"));
361        assert!(!is_valid_name(""));
362        assert!(!is_valid_name("pet.store"));
363        assert!(!is_valid_name("pet store"));
364    }
365
366    #[test]
367    fn is_valid_key_allows_dots_and_rejects_others() {
368        assert!(is_valid_key("my.output_value-1"));
369        assert!(!is_valid_key(""));
370        assert!(!is_valid_key("has space"));
371        assert!(!is_valid_key("slash/key"));
372    }
373}
374
375#[cfg(all(test, feature = "clap"))]
376mod clap_tests {
377    use super::*;
378    use clap::ValueEnum;
379
380    #[test]
381    fn value_variants_round_trip_through_kebab_case_names() {
382        for v in <ValidationOptions as ValueEnum>::value_variants() {
383            let pv = v.to_possible_value().expect("possible value");
384            let name = pv.get_name();
385            let parsed = <ValidationOptions as ValueEnum>::from_str(name, false).expect("parses");
386            assert_eq!(parsed, *v);
387            assert!(
388                name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
389                "name `{name}` must be kebab-case",
390            );
391        }
392    }
393}