Skip to main content

openjd_model/
error.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Error types for the OpenJD model library.
6//!
7//! The primary error type is [`ModelError`], which covers all failure modes
8//! during template parsing, validation, and job creation.
9
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum ModelError {
13    /// Structural deserialization failure (bad YAML/JSON, missing fields, wrong types).
14    #[error("Validation error: {0}")]
15    DecodeValidation(String),
16
17    /// Semantic validation failure (template parsed but violates spec rules).
18    /// Contains structured [`ValidationErrors`] with per-field paths.
19    /// Use `Display` / `.to_string()` for the formatted message.
20    #[error("Model validation error: {0}")]
21    ModelValidation(ValidationErrors),
22
23    /// Format string interpolation error with optional position info.
24    #[error("{}", format_string_error(.message, .input, .start, .end))]
25    FormatStringError {
26        message: String,
27        /// The raw format string that failed, if available.
28        input: Option<String>,
29        /// Byte offset of the failing interpolation start.
30        start: Option<usize>,
31        /// Byte offset of the failing interpolation end.
32        end: Option<usize>,
33    },
34
35    /// Expression evaluation or symbol table error, preserving the full
36    /// [`ExpressionError`](openjd_expr::ExpressionError) with its kind and
37    /// source-location context.
38    #[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/// An element in a validation error path.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PathElement {
82    Field(String),
83    Index(usize),
84}
85
86/// A single validation error with its location in the template.
87#[derive(Debug, Clone)]
88pub struct ValidationError {
89    /// Location of the error in the template structure (e.g., which field
90    /// in the JSON/YAML tree). Used by consumers to navigate to or annotate
91    /// the affected node.
92    pub path: Vec<PathElement>,
93    /// Complete human-readable error text, suitable for direct display.
94    /// Includes source pointers and span context where available.
95    pub message: String,
96    /// Structured diagnostic data decomposing the error into a summary
97    /// and source spans. `None` for errors without source position info
98    /// (e.g., duplicate names, missing required fields, limit violations).
99    pub detail: Option<ErrorDetail>,
100}
101
102/// Structured diagnostic data for a validation error.
103#[derive(Debug, Clone)]
104pub struct ErrorDetail {
105    /// Human-readable error summary without source pointers.
106    pub summary: String,
107    /// Diagnostic spans identifying specific character ranges in the
108    /// source text where the error occurs.
109    pub spans: Vec<DiagnosticSpan>,
110}
111
112/// A diagnostic span identifying a specific character range in source text.
113#[derive(Debug, Clone)]
114pub struct DiagnosticSpan {
115    /// Human-readable description of the diagnostic at this source location.
116    pub summary: String,
117    /// The source text containing the error.
118    pub source: String,
119    /// Byte offset of the error start within source.
120    pub start: usize,
121    /// Byte offset of the error end within source.
122    pub end: usize,
123    /// Position of the caret (most relevant character) relative to start.
124    pub caret: usize,
125}
126
127/// Collects multiple validation errors with structured paths.
128#[derive(Debug, Default)]
129pub struct ValidationErrors {
130    pub errors: Vec<ValidationError>,
131    /// Model name used for Display formatting (set by `into_result`).
132    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    /// Create a `ValidationErrors` with a single root-level message.
144    pub fn single(msg: impl Into<String>) -> Self {
145        let mut ve = Self::default();
146        ve.add(&[], msg);
147        ve
148    }
149
150    /// Add an error at the given path.
151    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    /// Add an error at the given path with structured diagnostic detail.
160    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            // Store the model name in the formatted output via Display
188            // The ValidationErrors struct is moved into the enum variant
189            Err(ModelError::ModelValidation(
190                self.with_model_name(model_name),
191            ))
192        }
193    }
194
195    /// Set the model name for Display formatting and return self.
196    fn with_model_name(mut self, name: &str) -> Self {
197        self.model_name = Some(name.to_string());
198        self
199    }
200
201    /// Format errors matching the Python Pydantic output format.
202    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                // Root-level error
210                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
221/// Format a path as `field[index] -> nested -> leaf`.
222fn 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/// Helper: extend a path with a field name.
241#[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/// Helper: extend a path with an index.
249#[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}