1use enumset::{EnumSet, EnumSetType};
15use std::collections::BTreeMap;
16use std::fmt::{self, Display, Write};
17
18#[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 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#[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#[derive(EnumSetType, Debug)]
93#[non_exhaustive]
94pub enum ValidationOptions {
95 IgnoreEmptyInfoTitle,
97 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
123pub trait Validate {
125 fn validate(&self, options: EnumSet<ValidationOptions>) -> Result<(), Error>;
126}
127
128pub(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 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 pub fn error(&mut self, message: impl Into<String>) {
157 self.errors
158 .push(ValidationError::new(self.path.clone(), message.into()));
159 }
160
161 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 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 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 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 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 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
242pub(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
251pub(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 ctx.error("here");
317 });
318 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}