Skip to main content

pipeline_service/parser/
error.rs

1// Parser error types with helpful error messages
2// Provides context, line/column info, and suggestions for common mistakes
3
4use std::fmt;
5
6/// Detailed parse error with location and context
7#[derive(Debug, Clone)]
8pub struct ParseError {
9    /// Error message
10    pub message: String,
11    /// Line number (1-indexed)
12    pub line: usize,
13    /// Column number (1-indexed)
14    pub column: usize,
15    /// Surrounding context (a few lines around the error)
16    pub context: String,
17    /// Optional suggestion for fixing the error
18    pub suggestion: Option<String>,
19    /// The kind of error
20    pub kind: ParseErrorKind,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ParseErrorKind {
25    /// YAML syntax error
26    YamlSyntax,
27    /// Invalid schema (wrong types, missing fields)
28    InvalidSchema,
29    /// Unknown field
30    UnknownField,
31    /// Invalid value
32    InvalidValue,
33    /// Template resolution error
34    TemplateError,
35    /// Expression syntax error
36    ExpressionError,
37    /// IO error (file not found, etc.)
38    IoError,
39    /// Validation error (semantic)
40    ValidationError,
41}
42
43impl ParseError {
44    pub fn new(message: impl Into<String>, line: usize, column: usize) -> Self {
45        Self {
46            message: message.into(),
47            line,
48            column,
49            context: String::new(),
50            suggestion: None,
51            kind: ParseErrorKind::InvalidSchema,
52        }
53    }
54
55    pub fn yaml_error(message: impl Into<String>, line: usize, column: usize) -> Self {
56        Self {
57            message: message.into(),
58            line,
59            column,
60            context: String::new(),
61            suggestion: None,
62            kind: ParseErrorKind::YamlSyntax,
63        }
64    }
65
66    pub fn with_context(mut self, context: impl Into<String>) -> Self {
67        self.context = context.into();
68        self
69    }
70
71    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
72        self.suggestion = Some(suggestion.into());
73        self
74    }
75
76    pub fn with_kind(mut self, kind: ParseErrorKind) -> Self {
77        self.kind = kind;
78        self
79    }
80
81    /// Create context from source content
82    pub fn with_source_context(mut self, source: &str, context_lines: usize) -> Self {
83        let lines: Vec<&str> = source.lines().collect();
84        let start = self.line.saturating_sub(context_lines + 1);
85        let end = (self.line + context_lines).min(lines.len());
86
87        let mut context = String::new();
88        for (i, line) in lines.iter().enumerate().take(end).skip(start) {
89            let line_num = i + 1;
90            let prefix = if line_num == self.line { ">" } else { " " };
91            context.push_str(&format!("{} {:4} | {}\n", prefix, line_num, line));
92
93            // Add column indicator for error line
94            if line_num == self.line && self.column > 0 {
95                let indicator = " ".repeat(self.column + 7) + "^";
96                context.push_str(&format!("       | {}\n", indicator));
97            }
98        }
99
100        self.context = context;
101        self
102    }
103
104    /// Create from serde_yaml error
105    pub fn from_yaml_error(err: &serde_yaml::Error, source: &str) -> Self {
106        let location = err.location();
107        let (line, column) = location
108            .map(|loc| (loc.line(), loc.column()))
109            .unwrap_or((1, 1));
110
111        let message = format_yaml_error_message(err);
112        let suggestion = suggest_yaml_fix(err, source, line);
113
114        ParseError::yaml_error(message, line, column)
115            .with_source_context(source, 2)
116            .with_suggestion_opt(suggestion)
117    }
118
119    fn with_suggestion_opt(mut self, suggestion: Option<String>) -> Self {
120        self.suggestion = suggestion;
121        self
122    }
123}
124
125impl fmt::Display for ParseError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        writeln!(f, "error: {}", self.message)?;
128        writeln!(f, "  --> line {}:{}", self.line, self.column)?;
129
130        if !self.context.is_empty() {
131            writeln!(f)?;
132            write!(f, "{}", self.context)?;
133        }
134
135        if let Some(suggestion) = &self.suggestion {
136            writeln!(f)?;
137            writeln!(f, "help: {}", suggestion)?;
138        }
139
140        Ok(())
141    }
142}
143
144impl std::error::Error for ParseError {}
145
146/// Format serde_yaml error message into something more readable
147fn format_yaml_error_message(err: &serde_yaml::Error) -> String {
148    let msg = err.to_string();
149
150    // Clean up common serde_yaml error patterns
151    if msg.contains("missing field") {
152        if let Some(field) = extract_field_name(&msg, "missing field `", "`") {
153            return format!("missing required field '{}'", field);
154        }
155    }
156
157    if msg.contains("unknown field") {
158        if let Some(field) = extract_field_name(&msg, "unknown field `", "`") {
159            if let Some(expected) = extract_expected_fields(&msg) {
160                return format!(
161                    "unknown field '{}', expected one of: {}",
162                    field,
163                    expected.join(", ")
164                );
165            }
166            return format!("unknown field '{}'", field);
167        }
168    }
169
170    if msg.contains("invalid type") {
171        return format_invalid_type_error(&msg);
172    }
173
174    // Return original if no pattern matched
175    msg
176}
177
178fn extract_field_name(msg: &str, prefix: &str, suffix: &str) -> Option<String> {
179    let start = msg.find(prefix)? + prefix.len();
180    let end = msg[start..].find(suffix)? + start;
181    Some(msg[start..end].to_string())
182}
183
184fn extract_expected_fields(msg: &str) -> Option<Vec<String>> {
185    let start = msg.find("expected one of ")? + "expected one of ".len();
186    let fields_str = &msg[start..];
187    let end = fields_str.find(" at").unwrap_or(fields_str.len());
188    let fields: Vec<String> = fields_str[..end]
189        .split(", ")
190        .map(|s| s.trim_matches('`').to_string())
191        .collect();
192    Some(fields)
193}
194
195fn format_invalid_type_error(msg: &str) -> String {
196    // Extract what was expected and what was found
197    if let (Some(expected), Some(found)) = (
198        extract_field_name(msg, "expected ", ","),
199        extract_field_name(msg, "found ", " at"),
200    ) {
201        return format!("expected {}, but found {}", expected, found);
202    }
203    msg.to_string()
204}
205
206/// Suggest fixes for common YAML errors
207fn suggest_yaml_fix(err: &serde_yaml::Error, source: &str, line: usize) -> Option<String> {
208    let msg = err.to_string();
209    let lines: Vec<&str> = source.lines().collect();
210    let error_line = lines.get(line.saturating_sub(1)).unwrap_or(&"");
211
212    // Suggest fixes for common mistakes
213    if msg.contains("missing field `steps`") {
214        return Some(
215            "jobs must have a 'steps' field. Add steps to define what the job should do."
216                .to_string(),
217        );
218    }
219
220    if msg.contains("missing field `job`") && msg.contains("missing field `deployment`") {
221        return Some(
222            "each job needs either 'job:' or 'deployment:' to define its identifier".to_string(),
223        );
224    }
225
226    if msg.contains("unknown field `script`") && error_line.contains("script:") {
227        return Some("'script:' should be at the step level, not nested inside another key. Check your indentation.".to_string());
228    }
229
230    // Indentation errors
231    if msg.contains("expected") && msg.contains("found") && error_line.starts_with('\t') {
232        return Some(
233            "YAML prefers spaces over tabs for indentation. Replace tabs with spaces.".to_string(),
234        );
235    }
236
237    // Common typos
238    let typo_suggestions = [
239        ("dependson", "dependsOn"),
240        ("displayname", "displayName"),
241        ("vmimage", "vmImage"),
242        ("workingdirectory", "workingDirectory"),
243        (
244            "continueOnError",
245            "continueOnError (note: lowercase 'n' in 'on')",
246        ),
247        ("timeout", "timeoutInMinutes"),
248    ];
249
250    let lower_line = error_line.to_lowercase();
251    for (typo, correct) in typo_suggestions {
252        if lower_line.contains(typo) {
253            return Some(format!("did you mean '{}'?", correct));
254        }
255    }
256
257    None
258}
259
260/// Result type for parser operations
261pub type ParseResult<T> = Result<T, ParseError>;
262
263/// Validation error for semantic checks
264#[derive(Debug, Clone)]
265pub struct ValidationError {
266    pub message: String,
267    pub path: String,
268    pub suggestion: Option<String>,
269}
270
271impl ValidationError {
272    pub fn new(message: impl Into<String>, path: impl Into<String>) -> Self {
273        Self {
274            message: message.into(),
275            path: path.into(),
276            suggestion: None,
277        }
278    }
279
280    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
281        self.suggestion = Some(suggestion.into());
282        self
283    }
284}
285
286impl fmt::Display for ValidationError {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        write!(f, "validation error at '{}': {}", self.path, self.message)?;
289        if let Some(suggestion) = &self.suggestion {
290            write!(f, " ({})", suggestion)?;
291        }
292        Ok(())
293    }
294}
295
296impl std::error::Error for ValidationError {}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_parse_error_display() {
304        let err = ParseError::new("missing required field 'steps'", 10, 5)
305            .with_context("   9 | jobs:\n> 10 |   - job: Build\n  11 |     pool: ubuntu-latest")
306            .with_suggestion("add 'steps:' to define what the job should do");
307
308        let output = format!("{}", err);
309        assert!(output.contains("missing required field"));
310        assert!(output.contains("line 10:5"));
311        assert!(output.contains("help:"));
312    }
313
314    #[test]
315    fn test_parse_error_with_source_context() {
316        let source = r#"trigger:
317  - main
318
319pool:
320  vmImage: ubuntu-latest
321
322jobs:
323  - job: Build
324    displayName: Build Job"#;
325
326        let err =
327            ParseError::new("missing required field 'steps'", 8, 5).with_source_context(source, 2);
328
329        assert!(err.context.contains("> "));
330        assert!(err.context.contains("job: Build"));
331    }
332
333    #[test]
334    fn test_extract_field_name() {
335        let msg = "missing field `steps` at line 10";
336        assert_eq!(
337            extract_field_name(msg, "missing field `", "`"),
338            Some("steps".to_string())
339        );
340    }
341}