Skip to main content

lintel_validation_cache/
validation_error.rs

1use core::fmt::Write;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// A single validation error with its location, typed kind, and pre-computed span.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct ValidationError {
9    /// JSON Pointer to the failing instance (e.g. `/jobs/build`).
10    pub instance_path: String,
11    /// JSON Schema path that triggered the error (e.g. `/properties/jobs/oneOf`).
12    pub schema_path: String,
13    /// The typed error kind with structured fields.
14    pub kind: ValidationErrorKind,
15    /// Byte offset and length in the source file for the error span.
16    pub span: (usize, usize),
17}
18
19/// Typed validation error kinds mirroring `jsonschema::ValidationErrorKind`.
20///
21/// Non-serializable nested errors (e.g. `AnyOf`, `OneOf*` context) drop their
22/// sub-error context. Non-serializable error types store a `message: String`.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "type")]
25pub enum ValidationErrorKind {
26    AdditionalItems {
27        limit: usize,
28    },
29    /// A single unexpected property — split from `AdditionalProperties` (plural).
30    AdditionalProperty {
31        property: String,
32    },
33    AnyOf,
34    BacktrackLimitExceeded {
35        message: String,
36    },
37    Constant {
38        expected_value: Value,
39    },
40    Contains,
41    ContentEncoding {
42        content_encoding: String,
43    },
44    ContentMediaType {
45        content_media_type: String,
46    },
47    Custom {
48        keyword: String,
49        message: String,
50    },
51    Enum {
52        options: Value,
53    },
54    ExclusiveMaximum {
55        limit: Value,
56    },
57    ExclusiveMinimum {
58        limit: Value,
59    },
60    FalseSchema,
61    Format {
62        format: String,
63    },
64    FromUtf8 {
65        message: String,
66    },
67    MaxItems {
68        limit: u64,
69    },
70    Maximum {
71        limit: Value,
72    },
73    MaxLength {
74        limit: u64,
75    },
76    MaxProperties {
77        limit: u64,
78    },
79    MinItems {
80        limit: u64,
81    },
82    Minimum {
83        limit: Value,
84    },
85    MinLength {
86        limit: u64,
87    },
88    MinProperties {
89        limit: u64,
90    },
91    MultipleOf {
92        multiple_of: f64,
93    },
94    Not,
95    OneOfMultipleValid,
96    OneOfNotValid,
97    Pattern {
98        pattern: String,
99    },
100    PropertyNames {
101        message: String,
102    },
103    Required {
104        property: String,
105    },
106    Type {
107        expected: String,
108    },
109    UnevaluatedItems {
110        unexpected: Vec<String>,
111    },
112    UnevaluatedProperties {
113        unexpected: Vec<String>,
114    },
115    UniqueItems,
116    Referencing {
117        message: String,
118    },
119}
120
121impl ValidationErrorKind {
122    /// Produce a human-readable error message from the structured fields.
123    #[allow(clippy::match_same_arms)]
124    pub fn message(&self) -> String {
125        match self {
126            Self::AdditionalItems { limit } => {
127                format!("Additional items are not allowed (limit: {limit})")
128            }
129            Self::AdditionalProperty { property } => {
130                format!("Additional properties are not allowed ('{property}' was unexpected)")
131            }
132            Self::AnyOf => {
133                "not valid under any of the schemas listed in the 'anyOf' keyword".to_string()
134            }
135            Self::BacktrackLimitExceeded { message }
136            | Self::Custom { message, .. }
137            | Self::FromUtf8 { message }
138            | Self::PropertyNames { message }
139            | Self::Referencing { message } => message.clone(),
140            Self::Constant { expected_value } => format!("{expected_value} was expected"),
141            Self::Contains => "None of the items are valid under the given schema".to_string(),
142            Self::ContentEncoding { content_encoding } => {
143                format!(r#"not compliant with "{content_encoding}" content encoding"#)
144            }
145            Self::ContentMediaType { content_media_type } => {
146                format!(r#"not compliant with "{content_media_type}" media type"#)
147            }
148            Self::Enum { options } => {
149                let mut msg = String::new();
150                if let Value::Array(arr) = options {
151                    let _ = write!(msg, "value is not one of: ");
152                    for (i, opt) in arr.iter().enumerate() {
153                        if i > 0 {
154                            let _ = write!(msg, ", ");
155                        }
156                        let _ = write!(msg, "{opt}");
157                    }
158                } else {
159                    let _ = write!(msg, "{options} was expected");
160                }
161                msg
162            }
163            Self::ExclusiveMaximum { limit } => {
164                format!("value is greater than or equal to the maximum of {limit}")
165            }
166            Self::ExclusiveMinimum { limit } => {
167                format!("value is less than or equal to the minimum of {limit}")
168            }
169            Self::FalseSchema => "False schema does not allow any value".to_string(),
170            Self::Format { format } => format!(r#"value is not a "{format}""#),
171            Self::MaxItems { limit } => {
172                let s = if *limit == 1 { "" } else { "s" };
173                format!("array has more than {limit} item{s}")
174            }
175            Self::Maximum { limit } => format!("value is greater than the maximum of {limit}"),
176            Self::MaxLength { limit } => {
177                let s = if *limit == 1 { "" } else { "s" };
178                format!("string is longer than {limit} character{s}")
179            }
180            Self::MaxProperties { limit } => {
181                let s = if *limit == 1 { "y" } else { "ies" };
182                format!("object has more than {limit} propert{s}")
183            }
184            Self::MinItems { limit } => {
185                let s = if *limit == 1 { "" } else { "s" };
186                format!("array has less than {limit} item{s}")
187            }
188            Self::Minimum { limit } => format!("value is less than the minimum of {limit}"),
189            Self::MinLength { limit } => {
190                let s = if *limit == 1 { "" } else { "s" };
191                format!("string is shorter than {limit} character{s}")
192            }
193            Self::MinProperties { limit } => {
194                let s = if *limit == 1 { "y" } else { "ies" };
195                format!("object has less than {limit} propert{s}")
196            }
197            Self::MultipleOf { multiple_of } => {
198                format!("value is not a multiple of {multiple_of}")
199            }
200            Self::Not => "value should not be valid under the given schema".to_string(),
201            Self::OneOfMultipleValid => {
202                "valid under more than one of the schemas listed in the 'oneOf' keyword".to_string()
203            }
204            Self::OneOfNotValid => {
205                "not valid under any of the schemas listed in the 'oneOf' keyword".to_string()
206            }
207            Self::Pattern { pattern } => format!(r#"value does not match "{pattern}""#),
208            Self::Required { property } => format!("{property} is a required property"),
209            Self::Type { expected } => format!(r#"value is not of type "{expected}""#),
210            Self::UnevaluatedItems { unexpected } => {
211                let mut msg = "Unevaluated items are not allowed (".to_string();
212                write_quoted_list(&mut msg, unexpected);
213                write_unexpected_suffix(&mut msg, unexpected.len());
214                msg
215            }
216            Self::UnevaluatedProperties { unexpected } => {
217                let mut msg = "Unevaluated properties are not allowed (".to_string();
218                write_quoted_list(&mut msg, unexpected);
219                write_unexpected_suffix(&mut msg, unexpected.len());
220                msg
221            }
222            Self::UniqueItems => "array has non-unique elements".to_string(),
223        }
224    }
225}
226
227fn write_quoted_list(buf: &mut String, items: &[String]) {
228    for (i, item) in items.iter().enumerate() {
229        if i > 0 {
230            let _ = write!(buf, ", ");
231        }
232        let _ = write!(buf, "'{item}'");
233    }
234}
235
236fn write_unexpected_suffix(buf: &mut String, count: usize) {
237    if count == 1 {
238        let _ = write!(buf, " was unexpected)");
239    } else {
240        let _ = write!(buf, " were unexpected)");
241    }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    #[test]
251    fn additional_property_message() {
252        let kind = ValidationErrorKind::AdditionalProperty {
253            property: "foo".to_string(),
254        };
255        assert_eq!(
256            kind.message(),
257            "Additional properties are not allowed ('foo' was unexpected)"
258        );
259    }
260
261    #[test]
262    fn required_message() {
263        let kind = ValidationErrorKind::Required {
264            property: "\"name\"".to_string(),
265        };
266        assert_eq!(kind.message(), "\"name\" is a required property");
267    }
268
269    #[test]
270    fn type_message() {
271        let kind = ValidationErrorKind::Type {
272            expected: "string".to_string(),
273        };
274        assert_eq!(kind.message(), r#"value is not of type "string""#);
275    }
276
277    #[test]
278    fn enum_message() {
279        let kind = ValidationErrorKind::Enum {
280            options: json!(["a", "b", "c"]),
281        };
282        assert_eq!(kind.message(), r#"value is not one of: "a", "b", "c""#);
283    }
284
285    #[test]
286    fn serialization_roundtrip() {
287        let error = ValidationError {
288            instance_path: "/name".to_string(),
289            schema_path: "/properties/name/type".to_string(),
290            kind: ValidationErrorKind::Type {
291                expected: "string".to_string(),
292            },
293            span: (10, 5),
294        };
295        let json = serde_json::to_string(&error).unwrap();
296        let deserialized: ValidationError = serde_json::from_str(&json).unwrap();
297        assert_eq!(error, deserialized);
298    }
299
300    #[test]
301    fn additional_property_serialization() {
302        let error = ValidationError {
303            instance_path: "/foo".to_string(),
304            schema_path: "/additionalProperties".to_string(),
305            kind: ValidationErrorKind::AdditionalProperty {
306                property: "foo".to_string(),
307            },
308            span: (5, 3),
309        };
310        let json = serde_json::to_string(&error).unwrap();
311        let deserialized: ValidationError = serde_json::from_str(&json).unwrap();
312        assert_eq!(error, deserialized);
313    }
314}