1use std::borrow::Cow;
14use std::fmt;
15
16#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct FieldPath {
23 segments: Vec<Segment>,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub enum Segment {
29 Key(Box<str>),
31 Index(usize),
33}
34
35impl FieldPath {
36 pub const ROOT: Self = Self {
38 segments: Vec::new(),
39 };
40
41 #[must_use]
43 pub fn key(name: &str) -> Self {
44 Self {
45 segments: vec![Segment::Key(name.into())],
46 }
47 }
48
49 #[must_use]
51 pub fn index(i: usize) -> Self {
52 Self {
53 segments: vec![Segment::Index(i)],
54 }
55 }
56
57 #[must_use]
59 pub fn join(&self, seg: Segment) -> Self {
60 let mut segments = self.segments.clone();
61 segments.push(seg);
62 Self { segments }
63 }
64}
65
66fn write_key(out: &mut String, k: &str) {
74 use std::fmt::Write as _;
75 if k.is_empty() || k.contains(['.', '[', ']', '`']) || k.chars().any(char::is_control) {
76 out.push('`');
77 for ch in k.chars() {
78 match ch {
79 '`' => out.push_str("``"),
80 '\\' => out.push_str("\\\\"),
81 c if c.is_control() => {
83 let _ = write!(out, "\\u{{{:02x}}}", c as u32);
84 }
85 c => out.push(c),
86 }
87 }
88 out.push('`');
89 } else {
90 out.push_str(k);
91 }
92}
93
94impl fmt::Display for FieldPath {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 let mut out = String::new();
97 for seg in &self.segments {
98 match seg {
99 Segment::Key(k) => {
100 if !out.is_empty() {
101 out.push('.');
102 }
103 write_key(&mut out, k);
104 }
105 Segment::Index(i) => {
106 out.push('[');
107 out.push_str(&i.to_string());
108 out.push(']');
109 }
110 }
111 }
112 f.write_str(&out)
113 }
114}
115
116#[derive(Clone, Debug, PartialEq, Eq)]
122pub enum IssueCode {
123 Min,
125 Max,
127 Length,
129 Email,
131 Url,
133 Uuid,
135 Empty,
137 Int,
139 Positive,
141 Finite,
143 BoolEquals,
145 Coerce,
147 Refine,
149 TypeMismatch,
151 Required,
154 Custom(Box<str>),
163}
164
165pub type ParamKey = Box<str>;
167
168#[derive(Clone, Debug, PartialEq)]
170pub enum ParamValue {
171 I64(i64),
173 F64(f64),
175 Bool(bool),
177 Str(Box<str>),
179}
180
181pub type IssueParams = Vec<(ParamKey, ParamValue)>;
183
184#[derive(Clone, Debug, PartialEq)]
187pub struct FormaIssue {
188 pub path: FieldPath,
191 pub code: IssueCode,
193 pub message: Cow<'static, str>,
195 pub params: IssueParams,
197}
198
199#[derive(Clone, Debug, Default, PartialEq)]
205pub struct FormaError {
206 pub issues: Vec<FormaIssue>,
208}
209
210impl FormaError {
211 #[must_use]
213 pub fn first(&self) -> Option<&FormaIssue> {
214 self.issues.first()
215 }
216
217 pub fn issues_for<'a>(&'a self, path: &'a FieldPath) -> impl Iterator<Item = &'a FormaIssue> {
219 self.issues.iter().filter(move |i| &i.path == path)
220 }
221}
222
223impl fmt::Display for FormaError {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 match self.issues.first() {
226 Some(first) => {
227 write!(
228 f,
229 "{} validation issue(s); first: {}",
230 self.issues.len(),
231 first.message
232 )
233 }
234 None => f.write_str("0 validation issues"),
235 }
236 }
237}
238
239impl std::error::Error for FormaError {}
240
241pub(crate) struct Sink<'p> {
243 path: &'p FieldPath,
244 fail_fast: bool,
245 pub issues: Vec<FormaIssue>,
247}
248
249impl<'p> Sink<'p> {
250 pub(crate) fn new(path: &'p FieldPath, fail_fast: bool) -> Self {
251 Self {
252 path,
253 fail_fast,
254 issues: Vec::new(),
255 }
256 }
257
258 pub(crate) fn push(&mut self, mut issue: FormaIssue) -> bool {
262 issue.path = self.path.clone();
263 self.issues.push(issue);
264 !self.fail_fast
265 }
266
267 pub(crate) fn finish(self) -> FormaError {
269 FormaError {
270 issues: self.issues,
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::{FieldPath, FormaError, FormaIssue, IssueCode, ParamValue, Segment, Sink};
278
279 fn issue(path: FieldPath, code: IssueCode, msg: &'static str) -> FormaIssue {
280 FormaIssue {
281 path,
282 code,
283 message: msg.into(),
284 params: Vec::new(),
285 }
286 }
287
288 #[test]
289 fn display_root_is_empty() {
290 assert_eq!(FieldPath::ROOT.to_string(), "");
291 }
292
293 #[test]
294 fn display_single_key() {
295 assert_eq!(FieldPath::key("email").to_string(), "email");
296 }
297
298 #[test]
299 fn display_nested_keys() {
300 let p = FieldPath::key("user").join(Segment::Key("email".into()));
301 assert_eq!(p.to_string(), "user.email");
302 }
303
304 #[test]
305 fn display_mixed_key_index() {
306 let p = FieldPath::key("items")
307 .join(Segment::Index(2))
308 .join(Segment::Key("qty".into()));
309 assert_eq!(p.to_string(), "items[2].qty");
310 }
311
312 #[test]
313 fn join_does_not_mutate_receiver() {
314 let base = FieldPath::key("user");
315 let child = base.join(Segment::Key("email".into()));
316 assert_eq!(base.to_string(), "user");
317 assert_eq!(child.to_string(), "user.email");
318 }
319
320 #[test]
321 fn paths_with_equal_segments_are_equal() {
322 assert_eq!(FieldPath::key("a"), FieldPath::key("a"));
323 assert_ne!(FieldPath::key("a"), FieldPath::index(0));
324 }
325
326 #[test]
327 fn er2_first_returns_earliest() {
328 let e = FormaError {
329 issues: vec![
330 issue(FieldPath::ROOT, IssueCode::Min, "min"),
331 issue(FieldPath::ROOT, IssueCode::Email, "email"),
332 ],
333 };
334 assert_eq!(e.first().unwrap().code, IssueCode::Min);
335 assert_eq!(e.first().unwrap().message, "min");
336 }
337
338 #[test]
339 fn er2_issues_for_path_lookup() {
340 let email_path = FieldPath::key("email");
341 let e = FormaError {
342 issues: vec![
343 issue(email_path.clone(), IssueCode::Email, "bad email"),
344 issue(FieldPath::key("age"), IssueCode::Min, "too young"),
345 issue(email_path.clone(), IssueCode::Max, "long"),
346 ],
347 };
348 let got: Vec<_> = e.issues_for(&email_path).map(|i| i.code.clone()).collect();
349 assert_eq!(got, vec![IssueCode::Email, IssueCode::Max]);
350 }
351
352 #[test]
353 fn first_is_none_for_empty_error() {
354 assert!(FormaError { issues: Vec::new() }.first().is_none());
355 }
356
357 #[test]
358 fn error_display_mentions_count_and_first_message() {
359 let e = FormaError {
360 issues: vec![issue(FieldPath::ROOT, IssueCode::Min, "too short")],
361 };
362 let d = e.to_string();
363 assert!(d.contains('1'), "count missing: {d}");
364 assert!(d.contains("too short"), "first message missing: {d}");
365 }
366
367 #[test]
368 fn error_display_none_safe_when_empty() {
369 let d = FormaError { issues: Vec::new() }.to_string();
370 assert!(!d.is_empty());
371 }
372
373 #[test]
374 fn issue_params_preserve_order_and_values() {
375 let i = FormaIssue {
376 path: FieldPath::ROOT,
377 code: IssueCode::Min,
378 message: "min".into(),
379 params: vec![
380 ("min".into(), ParamValue::I64(3)),
381 ("got".into(), ParamValue::Str("a".into())),
382 ],
383 };
384 assert_eq!(i.params[0].0.as_ref(), "min");
385 assert_eq!(i.params[1].1, ParamValue::Str("a".into()));
386 }
387
388 #[test]
389 fn issue_code_is_matchable_and_comparable() {
390 let c = IssueCode::Coerce;
391 assert_eq!(c, IssueCode::Coerce);
392 assert_ne!(c, IssueCode::Refine);
393 let name = match c {
394 IssueCode::Coerce => "coerce",
395 _ => "other",
396 };
397 assert_eq!(name, "coerce");
398 }
399
400 #[test]
401 fn sink_accumulate_collects_all() {
402 let root = FieldPath::ROOT;
403 let mut s = Sink::new(&root, false);
404 assert!(s.push(issue(FieldPath::ROOT, IssueCode::Min, "a")));
405 assert!(s.push(issue(FieldPath::ROOT, IssueCode::Email, "b")));
406 assert_eq!(s.issues.len(), 2);
407 assert_eq!(s.finish().issues.len(), 2);
408 }
409
410 #[test]
411 fn er6_required_code_is_matchable_and_displays_sensibly() {
412 let c = IssueCode::Required;
413 assert_eq!(c, IssueCode::Required);
414 assert_ne!(c, IssueCode::Min);
415 let name = match c {
416 IssueCode::Required => "required",
417 _ => "other",
418 };
419 assert_eq!(name, "required");
420 }
421
422 #[test]
423 fn er6_required_carries_joined_path_in_issue() {
424 let p = FieldPath::key("email");
425 let i = issue(p.clone(), IssueCode::Required, "missing");
426 assert_eq!(i.path, p);
427 assert_eq!(i.path.to_string(), "email");
428 }
429
430 #[test]
431 fn custom_codes_stay_distinguishable_from_each_other_and_from_refine() {
432 let decimal = IssueCode::Custom("decimal".into());
433 let currency = IssueCode::Custom("currency".into());
434 assert_ne!(decimal, currency);
435 assert_ne!(decimal, IssueCode::Refine);
436 let key = match &decimal {
437 IssueCode::Custom(name) => format!("validation.{name}"),
438 _ => "validation.generic".to_owned(),
439 };
440 assert_eq!(key, "validation.decimal");
441 }
442
443 #[test]
444 fn sink_fail_fast_signals_stop_after_first_push() {
445 let root = FieldPath::ROOT;
446 let mut s = Sink::new(&root, true);
447 assert!(!s.push(issue(FieldPath::ROOT, IssueCode::Min, "a")));
448 assert_eq!(s.finish().issues.len(), 1);
449 }
450}