Skip to main content

agm_core/error/
diagnostic.rs

1//! Core diagnostic types: `Severity`, `ErrorLocation`, `AgmError`,
2//! and `DiagnosticCollection`.
3
4use std::fmt;
5
6use miette::{Diagnostic, LabeledSpan, NamedSource, SourceSpan};
7use serde::{Deserialize, Serialize};
8
9use super::codes::ErrorCode;
10
11// ---------------------------------------------------------------------------
12// Severity
13// ---------------------------------------------------------------------------
14
15/// Diagnostic severity level (spec section 21.2).
16#[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// ---------------------------------------------------------------------------
47// ErrorLocation
48// ---------------------------------------------------------------------------
49
50#[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// ---------------------------------------------------------------------------
86// AgmError
87// ---------------------------------------------------------------------------
88
89#[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// ---------------------------------------------------------------------------
155// SourcedAgmError (internal)
156// ---------------------------------------------------------------------------
157
158#[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// ---------------------------------------------------------------------------
188// DiagnosticCollection
189// ---------------------------------------------------------------------------
190
191#[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}