1#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum ModelError {
13 #[error("Validation error: {0}")]
15 DecodeValidation(String),
16
17 #[error("Model validation error: {0}")]
21 ModelValidation(ValidationErrors),
22
23 #[error("{}", format_string_error(.message, .input, .start, .end))]
25 FormatStringError {
26 message: String,
27 input: Option<String>,
29 start: Option<usize>,
31 end: Option<usize>,
33 },
34
35 #[error("Expression error: {0}")]
39 Expression(#[source] openjd_expr::ExpressionError),
40
41 #[error("Compatibility error: {0}")]
42 Compatibility(String),
43
44 #[error("Unsupported schema version: {0}")]
45 UnsupportedSchema(String),
46}
47
48fn format_string_error(
49 message: &str,
50 input: &Option<String>,
51 start: &Option<usize>,
52 end: &Option<usize>,
53) -> String {
54 match (input, start, end) {
55 (Some(input), Some(s), Some(e)) => {
56 format!("Failed to parse interpolation expression at [{s}, {e}]. {message}\n {input}")
57 }
58 _ => format!("Format string error: {message}"),
59 }
60}
61
62impl From<openjd_expr::SymbolTableError> for ModelError {
63 fn from(e: openjd_expr::SymbolTableError) -> Self {
64 ModelError::Expression(openjd_expr::ExpressionError::from(e))
65 }
66}
67
68impl From<openjd_expr::FormatStringValidationError> for ModelError {
69 fn from(e: openjd_expr::FormatStringValidationError) -> Self {
70 ModelError::FormatStringError {
71 message: e.message,
72 input: Some(e.input),
73 start: Some(e.start),
74 end: Some(e.end),
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PathElement {
82 Field(String),
83 Index(usize),
84}
85
86#[derive(Debug, Clone)]
88pub struct ValidationError {
89 pub path: Vec<PathElement>,
93 pub message: String,
96 pub detail: Option<ErrorDetail>,
100}
101
102#[derive(Debug, Clone)]
104pub struct ErrorDetail {
105 pub summary: String,
107 pub spans: Vec<DiagnosticSpan>,
110}
111
112#[derive(Debug, Clone)]
114pub struct DiagnosticSpan {
115 pub summary: String,
117 pub source: String,
119 pub start: usize,
121 pub end: usize,
123 pub caret: usize,
125}
126
127#[derive(Debug, Default)]
129pub struct ValidationErrors {
130 pub errors: Vec<ValidationError>,
131 model_name: Option<String>,
133}
134
135impl std::fmt::Display for ValidationErrors {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 let name = self.model_name.as_deref().unwrap_or("Template");
138 write!(f, "{}", self.format(name))
139 }
140}
141
142impl ValidationErrors {
143 pub fn single(msg: impl Into<String>) -> Self {
145 let mut ve = Self::default();
146 ve.add(&[], msg);
147 ve
148 }
149
150 pub fn add(&mut self, path: &[PathElement], msg: impl Into<String>) {
152 self.errors.push(ValidationError {
153 path: path.to_vec(),
154 message: msg.into(),
155 detail: None,
156 });
157 }
158
159 pub fn add_with_detail(
161 &mut self,
162 path: &[PathElement],
163 msg: impl Into<String>,
164 detail: ErrorDetail,
165 ) {
166 self.errors.push(ValidationError {
167 path: path.to_vec(),
168 message: msg.into(),
169 detail: Some(detail),
170 });
171 }
172
173 #[must_use]
174 pub fn is_empty(&self) -> bool {
175 self.errors.is_empty()
176 }
177
178 #[must_use]
179 pub fn len(&self) -> usize {
180 self.errors.len()
181 }
182
183 pub fn into_result(self, model_name: &str) -> Result<(), ModelError> {
184 if self.errors.is_empty() {
185 Ok(())
186 } else {
187 Err(ModelError::ModelValidation(
190 self.with_model_name(model_name),
191 ))
192 }
193 }
194
195 fn with_model_name(mut self, name: &str) -> Self {
197 self.model_name = Some(name.to_string());
198 self
199 }
200
201 pub fn format(&self, model_name: &str) -> String {
203 let n = self.errors.len();
204 let word = if n == 1 { "error" } else { "errors" };
205 let mut out = format!("{n} validation {word} for {model_name}");
206 for err in &self.errors {
207 out.push('\n');
208 if err.path.is_empty() {
209 out.push_str(&format!("{model_name}: {}", err.message));
211 } else {
212 format_path(&err.path, &mut out);
213 out.push_str(":\n\t");
214 out.push_str(&err.message);
215 }
216 }
217 out
218 }
219}
220
221fn format_path(path: &[PathElement], out: &mut String) {
223 let mut first = true;
224 for elem in path {
225 match elem {
226 PathElement::Field(name) => {
227 if !first {
228 out.push_str(" -> ");
229 }
230 out.push_str(name);
231 first = false;
232 }
233 PathElement::Index(i) => {
234 out.push_str(&format!("[{i}]"));
235 }
236 }
237 }
238}
239
240#[must_use]
242pub fn path_field(base: &[PathElement], field: &str) -> Vec<PathElement> {
243 let mut p = base.to_vec();
244 p.push(PathElement::Field(field.to_string()));
245 p
246}
247
248#[must_use]
250pub fn path_index(base: &[PathElement], index: usize) -> Vec<PathElement> {
251 let mut p = base.to_vec();
252 p.push(PathElement::Index(index));
253 p
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_unsupported_schema_msg() {
262 let e = ModelError::UnsupportedSchema("version".into());
263 assert_eq!(e.to_string(), "Unsupported schema version: version");
264 }
265
266 #[test]
267 fn test_model_validation_msg() {
268 let mut ve = ValidationErrors::default();
269 ve.add(&[PathElement::Field("name".into())], "bad template");
270 let e = ModelError::ModelValidation(ve.with_model_name("JobTemplate"));
271 assert_eq!(
272 e.to_string(),
273 "Model validation error: 1 validation error for JobTemplate\nname:\n\tbad template"
274 );
275 }
276
277 #[test]
278 fn test_format_string_error_with_position() {
279 let e = ModelError::FormatStringError {
280 message: "Undefined variable 'Param.X'".into(),
281 input: Some("Hello {{Param.X}}".into()),
282 start: Some(6),
283 end: Some(17),
284 };
285 let s = e.to_string();
286 assert!(s.contains("Failed to parse interpolation expression at [6, 17]"));
287 assert!(s.contains("Undefined variable 'Param.X'"));
288 assert!(s.contains("Hello {{Param.X}}"));
289 }
290
291 #[test]
292 fn test_format_string_error_without_position() {
293 let e = ModelError::FormatStringError {
294 message: "something went wrong".into(),
295 input: None,
296 start: None,
297 end: None,
298 };
299 assert_eq!(e.to_string(), "Format string error: something went wrong");
300 }
301
302 #[test]
303 fn test_empty_errors_ok() {
304 let ve = ValidationErrors::default();
305 assert!(ve.into_result("JobTemplate").is_ok());
306 }
307
308 #[test]
309 fn test_single_field_error() {
310 let mut ve = ValidationErrors::default();
311 ve.add(&[PathElement::Field("name".into())], "must not be empty");
312 let s = ve.format("JobTemplate");
313 assert_eq!(
314 s,
315 "1 validation error for JobTemplate\nname:\n\tmust not be empty"
316 );
317 }
318
319 #[test]
320 fn test_into_result_uses_model_validation() {
321 let mut ve = ValidationErrors::default();
322 ve.add(&[PathElement::Field("name".into())], "too long");
323 let result = ve.into_result("JobTemplate");
324 assert!(matches!(result, Err(ModelError::ModelValidation(_))));
325 }
326
327 #[test]
328 fn test_nested_path_error() {
329 let mut ve = ValidationErrors::default();
330 ve.add(
331 &[
332 PathElement::Field("steps".into()),
333 PathElement::Index(0),
334 PathElement::Field("parameterSpace".into()),
335 PathElement::Field("combination".into()),
336 ],
337 "missing operator",
338 );
339 let s = ve.format("JobTemplate");
340 assert!(s.contains("steps[0] -> parameterSpace -> combination:\n\tmissing operator"));
341 }
342
343 #[test]
344 fn test_root_level_error() {
345 let mut ve = ValidationErrors::default();
346 ve.add(&[], "must have at least one step");
347 let s = ve.format("JobTemplate");
348 assert!(s.contains("JobTemplate: must have at least one step"));
349 }
350
351 #[test]
352 fn test_multiple_errors() {
353 let mut ve = ValidationErrors::default();
354 ve.add(&[PathElement::Field("name".into())], "too long");
355 ve.add(
356 &[
357 PathElement::Field("steps".into()),
358 PathElement::Index(0),
359 PathElement::Field("name".into()),
360 ],
361 "empty",
362 );
363 assert_eq!(ve.len(), 2);
364 let result = ve.into_result("JobTemplate");
365 assert!(result.is_err());
366 let msg = result.unwrap_err().to_string();
367 assert!(msg.contains("2 validation errors"));
368 assert!(msg.contains("name:\n\ttoo long"));
369 assert!(msg.contains("steps[0] -> name:\n\tempty"));
370 }
371
372 #[test]
373 fn test_path_helpers() {
374 let base = vec![PathElement::Field("steps".into()), PathElement::Index(0)];
375 let with_field = path_field(&base, "script");
376 assert_eq!(with_field.len(), 3);
377 let with_index = path_index(&base, 1);
378 assert_eq!(with_index.len(), 3);
379 }
380
381 #[test]
382 fn test_model_validation_structured_access() {
383 let mut ve = ValidationErrors::default();
384 ve.add(
385 &[PathElement::Field("steps".into()), PathElement::Index(0)],
386 "missing script",
387 );
388 ve.add(&[PathElement::Field("name".into())], "too long");
389 let err = ve.into_result("JobTemplate").unwrap_err();
390 let errors = match &err {
391 ModelError::ModelValidation(e) => e,
392 other => panic!("expected ModelValidation, got: {other}"),
393 };
394 assert_eq!(errors.len(), 2);
395 assert_eq!(
396 errors.errors[0].path,
397 vec![PathElement::Field("steps".into()), PathElement::Index(0)]
398 );
399 assert_eq!(errors.errors[0].message, "missing script");
400 assert_eq!(
401 errors.errors[1].path,
402 vec![PathElement::Field("name".into())]
403 );
404 assert_eq!(errors.errors[1].message, "too long");
405 assert_eq!(
406 err.to_string(),
407 "Model validation error: 2 validation errors for JobTemplate\n\
408 steps[0]:\n\tmissing script\n\
409 name:\n\ttoo long"
410 );
411 }
412}