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