1use std::fmt;
5
6#[derive(Debug, Clone)]
8pub struct ParseError {
9 pub message: String,
11 pub line: usize,
13 pub column: usize,
15 pub context: String,
17 pub suggestion: Option<String>,
19 pub kind: ParseErrorKind,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ParseErrorKind {
25 YamlSyntax,
27 InvalidSchema,
29 UnknownField,
31 InvalidValue,
33 TemplateError,
35 ExpressionError,
37 IoError,
39 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 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 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 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
146fn format_yaml_error_message(err: &serde_yaml::Error) -> String {
148 let msg = err.to_string();
149
150 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 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 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
206fn 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 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 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 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
260pub type ParseResult<T> = Result<T, ParseError>;
262
263#[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}