Skip to main content

roas_overlay/
validation.rs

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