1use std::fmt;
5
6use miette::{Diagnostic, LabeledSpan, NamedSource, SourceSpan};
7use serde::{Deserialize, Serialize};
8
9use super::codes::ErrorCode;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Severity {
19 Error,
20 Warning,
21 Info,
22}
23
24impl Severity {
25 #[must_use]
26 pub fn to_miette(self) -> miette::Severity {
27 match self {
28 Self::Error => miette::Severity::Error,
29 Self::Warning => miette::Severity::Warning,
30 Self::Info => miette::Severity::Advice,
31 }
32 }
33}
34
35impl fmt::Display for Severity {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 let s = match self {
38 Self::Error => "error",
39 Self::Warning => "warning",
40 Self::Info => "info",
41 };
42 write!(f, "{s}")
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
51pub struct ErrorLocation {
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub file: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub line: Option<usize>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub node: Option<String>,
58}
59
60impl ErrorLocation {
61 #[must_use]
62 pub fn new(file: Option<String>, line: Option<usize>, node: Option<String>) -> Self {
63 Self { file, line, node }
64 }
65
66 #[must_use]
67 pub fn file_line(file: impl Into<String>, line: usize) -> Self {
68 Self {
69 file: Some(file.into()),
70 line: Some(line),
71 node: None,
72 }
73 }
74
75 #[must_use]
76 pub fn full(file: impl Into<String>, line: usize, node: impl Into<String>) -> Self {
77 Self {
78 file: Some(file.into()),
79 line: Some(line),
80 node: Some(node.into()),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, thiserror::Error)]
90#[error("[{code}] {severity}: {message}")]
91pub struct AgmError {
92 pub code: ErrorCode,
93 pub severity: Severity,
94 pub message: String,
95 pub location: ErrorLocation,
96}
97
98impl AgmError {
99 #[must_use]
100 pub fn new(code: ErrorCode, message: impl Into<String>, location: ErrorLocation) -> Self {
101 Self {
102 severity: code.default_severity(),
103 code,
104 message: message.into(),
105 location,
106 }
107 }
108
109 #[must_use]
110 pub fn with_severity(
111 code: ErrorCode,
112 severity: Severity,
113 message: impl Into<String>,
114 location: ErrorLocation,
115 ) -> Self {
116 Self {
117 code,
118 severity,
119 message: message.into(),
120 location,
121 }
122 }
123
124 #[must_use]
125 pub fn is_error(&self) -> bool {
126 self.severity == Severity::Error
127 }
128
129 #[must_use]
130 pub fn is_warning(&self) -> bool {
131 self.severity == Severity::Warning
132 }
133
134 #[must_use]
135 pub fn is_info(&self) -> bool {
136 self.severity == Severity::Info
137 }
138}
139
140impl Diagnostic for AgmError {
141 fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
142 Some(Box::new(self.code))
143 }
144
145 fn severity(&self) -> Option<miette::Severity> {
146 Some(self.severity.to_miette())
147 }
148
149 fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
150 None
151 }
152}
153
154#[derive(Debug, thiserror::Error)]
159#[error("{inner}")]
160struct SourcedAgmError {
161 inner: AgmError,
162 source_code: NamedSource<String>,
163 label_span: SourceSpan,
164}
165
166impl Diagnostic for SourcedAgmError {
167 fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
168 self.inner.code()
169 }
170
171 fn severity(&self) -> Option<miette::Severity> {
172 self.inner.severity()
173 }
174
175 fn source_code(&self) -> Option<&dyn miette::SourceCode> {
176 Some(&self.source_code)
177 }
178
179 fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
180 Some(Box::new(std::iter::once(LabeledSpan::new_with_span(
181 Some(self.inner.message.clone()),
182 self.label_span,
183 ))))
184 }
185}
186
187#[derive(Debug, Clone)]
192pub struct DiagnosticCollection {
193 diagnostics: Vec<AgmError>,
194 source_code: String,
195 file_name: String,
196}
197
198impl DiagnosticCollection {
199 #[must_use]
200 pub fn new(file_name: impl Into<String>, source_code: impl Into<String>) -> Self {
201 Self {
202 diagnostics: Vec::new(),
203 source_code: source_code.into(),
204 file_name: file_name.into(),
205 }
206 }
207
208 pub fn push(&mut self, error: AgmError) {
209 self.diagnostics.push(error);
210 }
211
212 pub fn extend(&mut self, errors: impl IntoIterator<Item = AgmError>) {
213 self.diagnostics.extend(errors);
214 }
215
216 #[must_use]
217 pub fn diagnostics(&self) -> &[AgmError] {
218 &self.diagnostics
219 }
220
221 #[must_use]
222 pub fn into_diagnostics(self) -> Vec<AgmError> {
223 self.diagnostics
224 }
225
226 #[must_use]
227 pub fn has_errors(&self) -> bool {
228 self.diagnostics.iter().any(|d| d.is_error())
229 }
230
231 #[must_use]
232 pub fn is_empty(&self) -> bool {
233 self.diagnostics.is_empty()
234 }
235
236 #[must_use]
237 pub fn len(&self) -> usize {
238 self.diagnostics.len()
239 }
240
241 #[must_use]
242 pub fn error_count(&self) -> usize {
243 self.diagnostics.iter().filter(|d| d.is_error()).count()
244 }
245
246 #[must_use]
247 pub fn warning_count(&self) -> usize {
248 self.diagnostics.iter().filter(|d| d.is_warning()).count()
249 }
250
251 #[must_use]
252 pub fn info_count(&self) -> usize {
253 self.diagnostics.iter().filter(|d| d.is_info()).count()
254 }
255
256 #[must_use]
257 pub fn file_name(&self) -> &str {
258 &self.file_name
259 }
260
261 #[must_use]
262 pub fn source_text(&self) -> &str {
263 &self.source_code
264 }
265
266 fn line_byte_offset(&self, line: usize) -> Option<usize> {
267 if line == 0 {
268 return None;
269 }
270 let mut current_line = 1usize;
271 if current_line == line {
272 return Some(0);
273 }
274 for (offset, ch) in self.source_code.char_indices() {
275 if ch == '\n' {
276 current_line += 1;
277 if current_line == line {
278 return Some(offset + 1);
279 }
280 }
281 }
282 None
283 }
284
285 fn line_byte_len(&self, line: usize) -> usize {
286 if let Some(start) = self.line_byte_offset(line) {
287 let rest = &self.source_code[start..];
288 rest.find('\n').unwrap_or(rest.len())
289 } else {
290 0
291 }
292 }
293
294 #[must_use]
295 pub fn render_miette(&self) -> String {
296 use miette::GraphicalReportHandler;
297
298 let handler = GraphicalReportHandler::new();
299 let mut output = String::new();
300
301 for diag in &self.diagnostics {
302 let sourced = self.wrap_with_source(diag);
303 let _ = handler.render_report(&mut output, sourced.as_ref());
304 output.push('\n');
305 }
306
307 output
308 }
309
310 fn wrap_with_source(&self, error: &AgmError) -> Box<dyn Diagnostic + '_> {
311 let (offset, len) = if let Some(line) = error.location.line {
312 let off = self.line_byte_offset(line).unwrap_or(0);
313 let length = self.line_byte_len(line);
314 (off, length)
315 } else {
316 (0, 0)
317 };
318
319 Box::new(SourcedAgmError {
320 inner: error.clone(),
321 source_code: NamedSource::new(self.file_name.clone(), self.source_code.clone()),
322 label_span: SourceSpan::new(offset.into(), len),
323 })
324 }
325}
326
327impl fmt::Display for DiagnosticCollection {
328 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329 write!(
330 f,
331 "{}: {} error(s), {} warning(s), {} info(s)",
332 self.file_name,
333 self.error_count(),
334 self.warning_count(),
335 self.info_count(),
336 )
337 }
338}
339
340impl std::error::Error for DiagnosticCollection {}
341
342impl Diagnostic for DiagnosticCollection {
343 fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
344 None
345 }
346
347 fn severity(&self) -> Option<miette::Severity> {
348 if self.has_errors() {
349 Some(miette::Severity::Error)
350 } else if self.warning_count() > 0 {
351 Some(miette::Severity::Warning)
352 } else if self.info_count() > 0 {
353 Some(miette::Severity::Advice)
354 } else {
355 None
356 }
357 }
358
359 fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn Diagnostic> + 'a>> {
360 if self.diagnostics.is_empty() {
361 return None;
362 }
363 Some(Box::new(
364 self.diagnostics.iter().map(|d| d as &dyn Diagnostic),
365 ))
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::error::codes::ErrorCode;
373
374 fn sample_error() -> AgmError {
375 AgmError::new(
376 ErrorCode::V003,
377 "Duplicate node ID: `auth.login`".to_string(),
378 ErrorLocation::full("file.agm", 42, "auth.login"),
379 )
380 }
381
382 fn sample_warning() -> AgmError {
383 AgmError::new(
384 ErrorCode::V010,
385 "Node type `workflow` typically includes field `steps` (missing)".to_string(),
386 ErrorLocation::full("file.agm", 87, "deploy.step3"),
387 )
388 }
389
390 fn sample_info() -> AgmError {
391 AgmError::new(
392 ErrorCode::P010,
393 "File spec version `2.0` newer than parser version `1.0`".to_string(),
394 ErrorLocation::file_line("file.agm", 1),
395 )
396 }
397
398 #[test]
399 fn test_severity_display_lowercase() {
400 assert_eq!(Severity::Error.to_string(), "error");
401 assert_eq!(Severity::Warning.to_string(), "warning");
402 assert_eq!(Severity::Info.to_string(), "info");
403 }
404
405 #[test]
406 fn test_severity_to_miette_mapping() {
407 assert_eq!(Severity::Error.to_miette(), miette::Severity::Error);
408 assert_eq!(Severity::Warning.to_miette(), miette::Severity::Warning);
409 assert_eq!(Severity::Info.to_miette(), miette::Severity::Advice);
410 }
411
412 #[test]
413 fn test_error_location_full_sets_all_fields() {
414 let loc = ErrorLocation::full("test.agm", 10, "node.id");
415 assert_eq!(loc.file.as_deref(), Some("test.agm"));
416 assert_eq!(loc.line, Some(10));
417 assert_eq!(loc.node.as_deref(), Some("node.id"));
418 }
419
420 #[test]
421 fn test_error_location_file_line_no_node() {
422 let loc = ErrorLocation::file_line("test.agm", 5);
423 assert_eq!(loc.file.as_deref(), Some("test.agm"));
424 assert_eq!(loc.line, Some(5));
425 assert_eq!(loc.node, None);
426 }
427
428 #[test]
429 fn test_error_location_default_all_none() {
430 let loc = ErrorLocation::default();
431 assert_eq!(loc.file, None);
432 assert_eq!(loc.line, None);
433 assert_eq!(loc.node, None);
434 }
435
436 #[test]
437 fn test_agm_error_new_uses_default_severity() {
438 let err = AgmError::new(ErrorCode::V003, "test", ErrorLocation::default());
439 assert_eq!(err.severity, Severity::Error);
440 assert_eq!(err.code, ErrorCode::V003);
441 }
442
443 #[test]
444 fn test_agm_error_with_severity_overrides_default() {
445 let err = AgmError::with_severity(
446 ErrorCode::V003,
447 Severity::Warning,
448 "test",
449 ErrorLocation::default(),
450 );
451 assert_eq!(err.severity, Severity::Warning);
452 }
453
454 #[test]
455 fn test_agm_error_is_error_true_for_errors() {
456 let err = sample_error();
457 assert!(err.is_error());
458 assert!(!err.is_warning());
459 assert!(!err.is_info());
460 }
461
462 #[test]
463 fn test_agm_error_is_warning_true_for_warnings() {
464 let warn = sample_warning();
465 assert!(!warn.is_error());
466 assert!(warn.is_warning());
467 assert!(!warn.is_info());
468 }
469
470 #[test]
471 fn test_agm_error_is_info_true_for_info() {
472 let info = sample_info();
473 assert!(!info.is_error());
474 assert!(!info.is_warning());
475 assert!(info.is_info());
476 }
477
478 #[test]
479 fn test_agm_error_display_format() {
480 let err = sample_error();
481 let display = err.to_string();
482 assert_eq!(display, "[AGM-V003] error: Duplicate node ID: `auth.login`");
483 }
484
485 #[test]
486 fn test_agm_error_display_warning_format() {
487 let warn = sample_warning();
488 let display = warn.to_string();
489 assert!(display.starts_with("[AGM-V010] warning:"));
490 }
491
492 #[test]
493 fn test_agm_error_diagnostic_code() {
494 let err = sample_error();
495 let code = Diagnostic::code(&err).unwrap();
496 assert_eq!(code.to_string(), "AGM-V003");
497 }
498
499 #[test]
500 fn test_agm_error_diagnostic_severity() {
501 let err = sample_error();
502 assert_eq!(Diagnostic::severity(&err), Some(miette::Severity::Error));
503 }
504
505 #[test]
506 fn test_collection_empty_has_no_errors() {
507 let coll = DiagnosticCollection::new("test.agm", "");
508 assert!(!coll.has_errors());
509 assert!(coll.is_empty());
510 assert_eq!(coll.len(), 0);
511 assert_eq!(coll.error_count(), 0);
512 assert_eq!(coll.warning_count(), 0);
513 assert_eq!(coll.info_count(), 0);
514 }
515
516 #[test]
517 fn test_collection_has_errors_with_error() {
518 let mut coll = DiagnosticCollection::new("test.agm", "");
519 coll.push(sample_error());
520 assert!(coll.has_errors());
521 assert_eq!(coll.error_count(), 1);
522 }
523
524 #[test]
525 fn test_collection_has_errors_false_with_only_warnings() {
526 let mut coll = DiagnosticCollection::new("test.agm", "");
527 coll.push(sample_warning());
528 assert!(!coll.has_errors());
529 assert_eq!(coll.warning_count(), 1);
530 assert_eq!(coll.error_count(), 0);
531 }
532
533 #[test]
534 fn test_collection_mixed_counts() {
535 let mut coll = DiagnosticCollection::new("test.agm", "");
536 coll.push(sample_error());
537 coll.push(sample_warning());
538 coll.push(sample_info());
539 assert_eq!(coll.len(), 3);
540 assert_eq!(coll.error_count(), 1);
541 assert_eq!(coll.warning_count(), 1);
542 assert_eq!(coll.info_count(), 1);
543 assert!(coll.has_errors());
544 }
545
546 #[test]
547 fn test_collection_extend_adds_multiple() {
548 let mut coll = DiagnosticCollection::new("test.agm", "");
549 coll.extend(vec![sample_error(), sample_warning()]);
550 assert_eq!(coll.len(), 2);
551 }
552
553 #[test]
554 fn test_collection_into_diagnostics_returns_vec() {
555 let mut coll = DiagnosticCollection::new("test.agm", "");
556 coll.push(sample_error());
557 let diags = coll.into_diagnostics();
558 assert_eq!(diags.len(), 1);
559 assert_eq!(diags[0].code, ErrorCode::V003);
560 }
561
562 #[test]
563 fn test_collection_display_format() {
564 let mut coll = DiagnosticCollection::new("test.agm", "");
565 coll.push(sample_error());
566 coll.push(sample_warning());
567 let display = coll.to_string();
568 assert_eq!(display, "test.agm: 1 error(s), 1 warning(s), 0 info(s)");
569 }
570
571 #[test]
572 fn test_collection_diagnostic_severity_worst() {
573 let mut coll = DiagnosticCollection::new("test.agm", "");
574 coll.push(sample_warning());
575 assert_eq!(Diagnostic::severity(&coll), Some(miette::Severity::Warning));
576 coll.push(sample_error());
577 assert_eq!(Diagnostic::severity(&coll), Some(miette::Severity::Error));
578 }
579
580 #[test]
581 fn test_collection_line_byte_offset_line_1() {
582 let coll = DiagnosticCollection::new("test.agm", "hello\nworld\n");
583 assert_eq!(coll.line_byte_offset(1), Some(0));
584 assert_eq!(coll.line_byte_offset(2), Some(6));
585 }
586
587 #[test]
588 fn test_collection_line_byte_offset_zero_returns_none() {
589 let coll = DiagnosticCollection::new("test.agm", "hello");
590 assert_eq!(coll.line_byte_offset(0), None);
591 }
592
593 #[test]
594 fn test_collection_line_byte_offset_beyond_end_returns_none() {
595 let coll = DiagnosticCollection::new("test.agm", "hello");
596 assert_eq!(coll.line_byte_offset(2), None);
597 }
598
599 #[test]
600 fn test_collection_line_byte_len() {
601 let coll = DiagnosticCollection::new("test.agm", "hello\nworld\n");
602 assert_eq!(coll.line_byte_len(1), 5);
603 assert_eq!(coll.line_byte_len(2), 5);
604 }
605
606 #[test]
607 fn test_collection_render_miette_produces_output() {
608 let source = "agm: 1\npackage: test\nversion: 0.1.0\n\nnode auth.login\ntype: facts\nsummary: first\n\nnode auth.login\ntype: facts\nsummary: duplicate\n";
609 let mut coll = DiagnosticCollection::new("file.agm", source);
610 coll.push(AgmError::new(
611 ErrorCode::V003,
612 "Duplicate node ID: `auth.login`",
613 ErrorLocation::full("file.agm", 9, "auth.login"),
614 ));
615 let rendered = coll.render_miette();
616 assert!(rendered.contains("AGM-V003"));
617 assert!(rendered.contains("Duplicate node ID"));
618 }
619}