Skip to main content

assura_diagnostics/
lib.rs

1//! Unified diagnostic types for the Assura compiler.
2//!
3//! All compiler passes (parser, resolver, type checker, SMT verifier)
4//! emit `Diagnostic` values. The CLI renders these uniformly via
5//! ariadne (human mode) or serde (JSON mode).
6
7use std::ops::Range;
8
9mod catalog;
10mod render;
11
12pub use catalog::{error_catalog, explain};
13pub use render::{render_diagnostic, report_diagnostics_human};
14
15/// Source location span (byte offsets into the source file).
16pub type Span = Range<usize>;
17
18/// A strongly-typed error code from the Assura specification.
19///
20/// Wraps the raw code string (e.g. `"A03001"`) so that error code
21/// fields are distinguishable from arbitrary strings at the type level.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
23#[serde(transparent)]
24pub struct ErrorCode(String);
25
26impl ErrorCode {
27    /// Return the code as a string slice.
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33impl std::fmt::Display for ErrorCode {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.write_str(&self.0)
36    }
37}
38
39impl AsRef<str> for ErrorCode {
40    fn as_ref(&self) -> &str {
41        &self.0
42    }
43}
44
45impl From<&str> for ErrorCode {
46    fn from(s: &str) -> Self {
47        Self(s.to_owned())
48    }
49}
50
51impl From<String> for ErrorCode {
52    fn from(s: String) -> Self {
53        Self(s)
54    }
55}
56
57impl PartialEq<str> for ErrorCode {
58    fn eq(&self, other: &str) -> bool {
59        self.0 == other
60    }
61}
62
63impl PartialEq<&str> for ErrorCode {
64    fn eq(&self, other: &&str) -> bool {
65        self.0 == *other
66    }
67}
68
69impl PartialEq<String> for ErrorCode {
70    fn eq(&self, other: &String) -> bool {
71        self.0 == *other
72    }
73}
74
75/// Diagnostic severity level.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
77#[serde(rename_all = "lowercase")]
78pub enum Severity {
79    /// Informational message, not an error.
80    Info,
81    /// Potential problem that does not prevent compilation.
82    Warning,
83    /// Error that prevents compilation or verification.
84    Error,
85}
86
87/// A secondary span with a label, used for additional context in diagnostics.
88#[derive(Debug, Clone, PartialEq, serde::Serialize)]
89pub struct SecondaryLabel {
90    /// The source span for this secondary label.
91    pub span: Span,
92    /// A description of what this secondary location refers to.
93    pub message: String,
94}
95
96/// A suggested fix for a diagnostic.
97#[derive(Debug, Clone, PartialEq, serde::Serialize)]
98pub struct Suggestion {
99    /// Human-readable description of what the fix does.
100    pub message: String,
101    /// The span to replace.
102    pub span: Span,
103    /// The replacement text.
104    pub replacement: String,
105}
106
107/// A compiler diagnostic with structured location and severity.
108///
109/// This is the unified error type emitted by all compiler passes.
110/// The CLI consumes `Vec<Diagnostic>` and renders them via ariadne
111/// (for human-readable output) or serializes them (for JSON output).
112#[derive(Debug, Clone, PartialEq, serde::Serialize)]
113pub struct Diagnostic {
114    /// Error code from the spec (e.g., "A01001", "A03005").
115    pub code: ErrorCode,
116    /// Severity level.
117    pub severity: Severity,
118    /// Human-readable error message.
119    pub message: String,
120    /// Source file name (may be empty for in-memory compilations).
121    pub file: String,
122    /// Primary source location where the error was detected.
123    pub primary: Span,
124    /// Secondary spans with labels (e.g., "expected type declared here").
125    pub secondary: Vec<SecondaryLabel>,
126    /// Optional suggested fix.
127    pub suggestion: Option<Suggestion>,
128}
129
130impl Diagnostic {
131    /// Create a new error diagnostic with a code, message, and span.
132    pub fn error(code: impl Into<ErrorCode>, message: impl Into<String>, span: Span) -> Self {
133        Self {
134            code: code.into(),
135            severity: Severity::Error,
136            message: message.into(),
137            file: String::new(),
138            primary: span,
139            secondary: Vec::new(),
140            suggestion: None,
141        }
142    }
143
144    /// Create a new warning diagnostic.
145    pub fn warning(code: impl Into<ErrorCode>, message: impl Into<String>, span: Span) -> Self {
146        Self {
147            code: code.into(),
148            severity: Severity::Warning,
149            message: message.into(),
150            file: String::new(),
151            primary: span,
152            secondary: Vec::new(),
153            suggestion: None,
154        }
155    }
156
157    /// Set the source file name for this diagnostic.
158    pub fn with_file(mut self, file: impl Into<String>) -> Self {
159        self.file = file.into();
160        self
161    }
162
163    /// Add a secondary span with a label.
164    pub fn with_secondary(mut self, span: Span, label: impl Into<String>) -> Self {
165        self.secondary.push(SecondaryLabel {
166            span,
167            message: label.into(),
168        });
169        self
170    }
171
172    /// Add a suggested fix.
173    pub fn with_suggestion(
174        mut self,
175        message: impl Into<String>,
176        span: Span,
177        replacement: impl Into<String>,
178    ) -> Self {
179        self.suggestion = Some(Suggestion {
180            message: message.into(),
181            span,
182            replacement: replacement.into(),
183        });
184        self
185    }
186
187    /// Check if this diagnostic is an error.
188    pub fn is_error(&self) -> bool {
189        self.severity == Severity::Error
190    }
191}
192
193impl std::fmt::Display for Diagnostic {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        write!(f, "[{}] {}", self.code, self.message)
196    }
197}
198
199impl std::fmt::Display for Severity {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            Severity::Info => write!(f, "info"),
203            Severity::Warning => write!(f, "warning"),
204            Severity::Error => write!(f, "error"),
205        }
206    }
207}
208
209/// A human-readable explanation of a specific error code.
210#[derive(Debug, Clone, PartialEq)]
211pub struct ErrorInfo {
212    /// The error code (e.g. "A01001").
213    pub code: &'static str,
214    /// Short descriptive name.
215    pub name: &'static str,
216    /// Multi-line explanation of the error.
217    pub description: &'static str,
218    /// Example source code that triggers the error.
219    pub example: &'static str,
220    /// How to fix the error.
221    pub fix: &'static str,
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn error_diagnostic_creation() {
230        let d = Diagnostic::error("A03001", "type mismatch", 10..20);
231        assert_eq!(d.code, "A03001");
232        assert_eq!(d.severity, Severity::Error);
233        assert_eq!(d.primary, 10..20);
234        assert!(d.is_error());
235    }
236
237    #[test]
238    fn warning_diagnostic_creation() {
239        let d = Diagnostic::warning("A05001", "unused variable", 5..10);
240        assert_eq!(d.severity, Severity::Warning);
241        assert!(!d.is_error());
242    }
243
244    #[test]
245    fn diagnostic_with_secondary() {
246        let d = Diagnostic::error("A03002", "expected Int", 10..20)
247            .with_secondary(30..40, "declared here");
248        assert_eq!(d.secondary.len(), 1);
249        assert_eq!(d.secondary[0].message, "declared here");
250    }
251
252    #[test]
253    fn diagnostic_with_suggestion() {
254        let d = Diagnostic::error("A01001", "unexpected token", 5..8).with_suggestion(
255            "try adding a semicolon",
256            7..8,
257            ";",
258        );
259        let s = d.suggestion.unwrap();
260        assert_eq!(s.replacement, ";");
261    }
262
263    #[test]
264    fn diagnostic_display() {
265        let d = Diagnostic::error("A03001", "type mismatch", 0..1);
266        assert_eq!(format!("{d}"), "[A03001] type mismatch");
267    }
268
269    #[test]
270    fn severity_ordering() {
271        assert!(Severity::Info < Severity::Warning);
272        assert!(Severity::Warning < Severity::Error);
273    }
274
275    #[test]
276    fn test_error_diagnostic_is_error() {
277        let d = Diagnostic::error("A01001", "syntax error", 0..5);
278        assert!(d.is_error());
279        assert_eq!(d.severity, Severity::Error);
280    }
281
282    #[test]
283    fn test_warning_diagnostic_is_not_error() {
284        let d = Diagnostic::warning("A02007", "unused import", 10..20);
285        assert!(!d.is_error());
286        assert_eq!(d.severity, Severity::Warning);
287    }
288
289    #[test]
290    fn test_severity_display() {
291        assert_eq!(format!("{}", Severity::Info), "info");
292        assert_eq!(format!("{}", Severity::Warning), "warning");
293        assert_eq!(format!("{}", Severity::Error), "error");
294    }
295
296    #[test]
297    fn test_diagnostic_with_file() {
298        let d = Diagnostic::error("A03001", "type mismatch", 0..10).with_file("test.assura");
299        assert_eq!(d.file, "test.assura");
300    }
301
302    #[test]
303    fn test_diagnostic_multiple_secondary_spans() {
304        let d = Diagnostic::error("A03001", "type mismatch", 10..20)
305            .with_secondary(30..40, "expected type here")
306            .with_secondary(50..60, "found type here");
307        assert_eq!(d.secondary.len(), 2);
308        assert_eq!(d.secondary[0].message, "expected type here");
309        assert_eq!(d.secondary[0].span, 30..40);
310        assert_eq!(d.secondary[1].message, "found type here");
311        assert_eq!(d.secondary[1].span, 50..60);
312    }
313
314    #[test]
315    fn test_diagnostic_suggestion_fields() {
316        let d = Diagnostic::error("A01002", "unexpected token", 5..8).with_suggestion(
317            "add a colon",
318            7..8,
319            ":",
320        );
321        let s = d.suggestion.as_ref().unwrap();
322        assert_eq!(s.message, "add a colon");
323        assert_eq!(s.span, 7..8);
324        assert_eq!(s.replacement, ":");
325    }
326
327    #[test]
328    fn test_diagnostic_json_serialization() {
329        let d = Diagnostic::error("A03001", "type mismatch", 10..20)
330            .with_file("main.assura")
331            .with_secondary(30..40, "declared here");
332        let json = serde_json::to_string(&d).unwrap();
333        assert!(json.contains("A03001"));
334        assert!(json.contains("type mismatch"));
335        assert!(json.contains("main.assura"));
336        assert!(json.contains("declared here"));
337        let val: serde_json::Value = serde_json::from_str(&json).unwrap();
338        assert_eq!(val["code"], "A03001");
339        assert_eq!(val["severity"], "error");
340        assert_eq!(val["message"], "type mismatch");
341    }
342
343    #[test]
344    fn test_diagnostic_collection() {
345        let diags = vec![
346            Diagnostic::error("A01001", "unexpected char", 0..1),
347            Diagnostic::warning("A02007", "unused import", 10..20),
348            Diagnostic::error("A03001", "type mismatch", 30..40),
349        ];
350        assert_eq!(diags.len(), 3);
351        let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
352        assert_eq!(errors.len(), 2);
353        let warnings: Vec<_> = diags
354            .iter()
355            .filter(|d| d.severity == Severity::Warning)
356            .collect();
357        assert_eq!(warnings.len(), 1);
358    }
359
360    #[test]
361    fn test_diagnostic_empty_secondary_spans() {
362        let d = Diagnostic::error("A03001", "error", 0..5);
363        assert!(d.secondary.is_empty());
364        assert!(d.suggestion.is_none());
365    }
366
367    #[test]
368    fn test_error_code_formatting_display() {
369        let d = Diagnostic::error("A05001", "linear variable used twice", 0..10);
370        let display = format!("{d}");
371        assert_eq!(display, "[A05001] linear variable used twice");
372    }
373
374    #[test]
375    fn test_error_catalog_not_empty() {
376        let catalog = error_catalog();
377        assert!(!catalog.is_empty());
378        for entry in &catalog {
379            assert!(!entry.code.is_empty());
380            assert!(!entry.name.is_empty());
381            assert!(!entry.description.is_empty());
382            assert!(!entry.example.is_empty());
383            assert!(!entry.fix.is_empty());
384        }
385    }
386
387    #[test]
388    fn test_explain_known_code() {
389        let info = explain("A01001");
390        let info = info.unwrap();
391        assert_eq!(info.code, "A01001");
392        assert_eq!(info.name, "Unexpected character");
393    }
394
395    #[test]
396    fn test_explain_unknown_code() {
397        let info = explain("A00000");
398        assert!(info.is_none());
399    }
400
401    /// Every code in the `docs/error-codes.md` high-traffic routing table must
402    /// resolve via `explain()`. Catalog-only placeholders live in a separate
403    /// section of that doc and are intentionally omitted here (do not treat
404    /// them as implement backlog; see #1489 class fix).
405    #[test]
406    fn high_traffic_index_codes_are_in_catalog() {
407        const CODES: &[&str] = &[
408            "A01001", "A01002", "A02001", "A02003", "A02005", "A03001", "A03002", "A03005",
409            "A03006", "A05001", "A05002", "A05003", "A05004", "A06001", "A06002", "A06003",
410            "A06004", "A07001", "A07002", "A07003", "A08001", "A08002", "A08003", "A08004",
411            "A08005", "A09001", "A09002", "A09003", "A09004", "A11001", "A11002", "A11003",
412            "A11004", "A12001", "A12002", "A12003", "A13001", "A13002", "A13003", "A16001",
413            "A16002", "A16003", "A17001", "A17002", "A17003", "A21001", "A21002", "A21003",
414            "A22001", "A22002", "A22003", "A05100", "A05101", "A05102", "A05103", "A10002",
415            "A01000", "A02006", "A02007", "A02008", "A02010", "A03007", "A03010", "A08102",
416            "A10001", "A10101", "A11005", "A14001", "A14002", "A04008", "A05025", "A05026",
417            "A08101", "A09101", "A23003", "A26001", "A43005", "A17004", "A23016", "A24001",
418            "A27003", "A28001", "A33001", "A37003", "A38001", "A42003", "A43001", "A43002",
419            "A44001", "A45001", "A47001", "A48002", "A49001", "A49002", "A50001", "A52001",
420            "A54001", "A55001", "A64001", "A31006", "A31007", "A32002", "A36003", "A52002",
421            "A46002", "A29001", "A25003", "A09103", "A53006", "A49003", "A35003", "A34003",
422            "A30002", "A23001", "A10104", "A09102", "A08103", "A51003", "A46003", "A36001",
423            "A35001", "A10102", "A10103", "A42001", "A20001", "A20002", "A18001", "A18003",
424            "A24003", "A25001", "A22004", "A44003", "A46001", "A55003", "A32001", "A48001",
425            "A34001", "A37001", "A30003", "A15004", "A15001", "A18002", "A33003", "A03012",
426            "A23002", "A45003", "A42002", "A31001", "A31003", "A32003", "A51001", "A48003",
427            "A54003", "A30001", "A29003", "A28003", "A27001", "A26004", "A26003", "A15002",
428            "A15003", "A33002", "A03011", "A03008", "A25002", "A24002", "A23019", "A47002",
429            "A47003", "A45002", "A38002", "A44002", "A55002", "A54002", "A43003", "A43004",
430            "A31002", "A53003", "A53001", "A53002", "A52003", "A50002", "A50003", "A36002",
431            "A38003", "A35002", "A34002", "A29002", "A28002", "A27002", "A05200", "A51002",
432            "A37002", "A03009",
433        ];
434        for code in CODES {
435            let info = explain(code).unwrap_or_else(|| {
436                panic!(
437                    "{code}: listed in docs/error-codes.md high-traffic table but missing from catalog"
438                )
439            });
440            assert_eq!(info.code, *code);
441            assert!(
442                !info.name.is_empty(),
443                "{code}: catalog entry must have a non-empty name"
444            );
445        }
446    }
447
448    #[test]
449    fn test_explain_all_catalog_codes() {
450        let catalog = error_catalog();
451        for entry in &catalog {
452            let found = explain(entry.code).unwrap_or_else(|| {
453                panic!("should find {}", entry.code);
454            });
455            assert_eq!(found.code, entry.code);
456        }
457    }
458
459    #[test]
460    fn test_warning_serialization() {
461        let d = Diagnostic::warning("A02007", "unused import", 5..15);
462        let json = serde_json::to_string(&d).unwrap();
463        let val: serde_json::Value = serde_json::from_str(&json).unwrap();
464        assert_eq!(val["severity"], "warning");
465    }
466
467    #[test]
468    fn test_suggestion_serialization() {
469        let s = Suggestion {
470            message: "add semicolon".to_string(),
471            span: 10..11,
472            replacement: ";".to_string(),
473        };
474        let json = serde_json::to_string(&s).unwrap();
475        assert!(json.contains("add semicolon"));
476    }
477
478    #[test]
479    fn test_secondary_label_equality() {
480        let a = SecondaryLabel {
481            span: 0..5,
482            message: "here".to_string(),
483        };
484        let b = SecondaryLabel {
485            span: 0..5,
486            message: "here".to_string(),
487        };
488        assert_eq!(a, b);
489    }
490
491    /// Every error code in the catalog must be unique.
492    #[test]
493    fn test_no_duplicate_error_codes() {
494        let catalog = error_catalog();
495        let mut seen = std::collections::HashSet::new();
496        for entry in &catalog {
497            assert!(
498                seen.insert(entry.code),
499                "duplicate error code in catalog: {}",
500                entry.code
501            );
502        }
503    }
504
505    /// Regression #903: A03005 catalog is unknown-field, not "Not callable".
506    #[test]
507    fn test_a03005_catalog_is_unknown_field() {
508        let info = explain("A03005").expect("A03005 should exist");
509        assert_eq!(info.name, "Unknown field");
510        assert!(
511            info.description.to_lowercase().contains("field"),
512            "A03005 description should mention fields, got: {}",
513            info.description
514        );
515        let fix_lower = info.fix.to_lowercase();
516        assert!(
517            !fix_lower.contains("calling a function"),
518            "A03005 fix/Help must not mention calling a function (that was the bug): {}",
519            info.fix
520        );
521        assert!(
522            fix_lower.contains("field") || fix_lower.contains("tuple"),
523            "A03005 fix should be field-oriented, got: {}",
524            info.fix
525        );
526        // Example should show field/tuple access, not type-as-call
527        assert!(
528            info.example.contains(".z") || info.example.contains("t.2"),
529            "A03005 example should show unknown field or OOB tuple index"
530        );
531        assert!(
532            !info.example.contains("Foo(42)"),
533            "A03005 example must not be the old type-as-call snippet"
534        );
535    }
536
537    #[test]
538    fn test_render_diagnostic_does_not_panic() {
539        // Ensure render_diagnostic does not panic on valid and edge-case inputs
540        let d = Diagnostic::error("A01001", "unexpected char", 0..1);
541        render_diagnostic(&d, "test.assura", "x");
542
543        let d = Diagnostic::warning("A02007", "unused import", 0..5)
544            .with_secondary(6..10, "imported here");
545        render_diagnostic(&d, "test.assura", "import std.math;");
546    }
547
548    #[test]
549    fn test_report_diagnostics_human_multiple() {
550        let diags = vec![
551            Diagnostic::error("A01001", "bad char", 0..1),
552            Diagnostic::warning("A02007", "unused", 2..5),
553        ];
554        // Must not panic
555        report_diagnostics_human(&diags, "multi.assura", "x = 42;");
556    }
557
558    #[test]
559    fn test_error_code_as_str() {
560        let code = ErrorCode::from("A03001");
561        assert_eq!(code.as_str(), "A03001");
562    }
563
564    #[test]
565    fn test_error_code_from_string() {
566        let code = ErrorCode::from(String::from("A05001"));
567        assert_eq!(code, "A05001");
568    }
569
570    #[test]
571    fn test_error_code_partial_eq_str() {
572        let code = ErrorCode::from("A07003");
573        assert!(code == "A07003");
574        assert!(code == *"A07003");
575    }
576
577    #[test]
578    fn test_error_code_as_ref() {
579        let code = ErrorCode::from("A01002");
580        let s: &str = code.as_ref();
581        assert_eq!(s, "A01002");
582    }
583
584    #[test]
585    fn test_error_code_display() {
586        let code = ErrorCode::from("A03005");
587        assert_eq!(format!("{code}"), "A03005");
588    }
589
590    #[test]
591    fn test_error_code_ordering() {
592        let a = ErrorCode::from("A01001");
593        let b = ErrorCode::from("A03001");
594        assert!(a < b);
595    }
596
597    #[test]
598    fn test_error_catalog_entries_have_fields() {
599        let catalog = error_catalog();
600        for entry in &catalog {
601            assert!(!entry.code.is_empty(), "code must not be empty");
602            assert!(
603                !entry.name.is_empty(),
604                "name must not be empty for {}",
605                entry.code
606            );
607            assert!(
608                !entry.description.is_empty(),
609                "description must not be empty for {}",
610                entry.code
611            );
612            assert!(
613                !entry.fix.is_empty(),
614                "fix must not be empty for {}",
615                entry.code
616            );
617        }
618    }
619
620    #[test]
621    fn test_diagnostic_chaining() {
622        let d = Diagnostic::error("A03001", "mismatch", 10..20)
623            .with_file("test.assura")
624            .with_secondary(30..40, "defined here")
625            .with_suggestion("use Int", 10..20, "Int");
626        assert_eq!(d.file, "test.assura");
627        assert_eq!(d.secondary.len(), 1);
628        d.suggestion.unwrap();
629    }
630
631    #[test]
632    fn test_severity_serde() {
633        let json = serde_json::to_string(&Severity::Error).unwrap();
634        assert_eq!(json, "\"error\"");
635        let json = serde_json::to_string(&Severity::Warning).unwrap();
636        assert_eq!(json, "\"warning\"");
637        let json = serde_json::to_string(&Severity::Info).unwrap();
638        assert_eq!(json, "\"info\"");
639    }
640
641    // ---- ErrorCode edge cases ----
642
643    #[test]
644    fn test_error_code_eq_string_owned() {
645        let code = ErrorCode::from("A03001");
646        assert!(code == String::from("A03001"));
647    }
648
649    #[test]
650    fn test_error_code_ne() {
651        let a = ErrorCode::from("A01001");
652        let b = ErrorCode::from("A03001");
653        assert_ne!(a, b);
654    }
655
656    #[test]
657    fn test_error_code_clone_eq() {
658        let code = ErrorCode::from("A05001");
659        let cloned = code.clone();
660        assert_eq!(code, cloned);
661    }
662
663    #[test]
664    fn test_error_code_hash_consistent() {
665        use std::collections::HashSet;
666        let mut set = HashSet::new();
667        set.insert(ErrorCode::from("A01001"));
668        set.insert(ErrorCode::from("A01001")); // duplicate
669        set.insert(ErrorCode::from("A03001"));
670        assert_eq!(set.len(), 2);
671    }
672
673    #[test]
674    fn test_error_code_empty() {
675        let code = ErrorCode::from("");
676        assert_eq!(code.as_str(), "");
677        assert_eq!(format!("{code}"), "");
678    }
679
680    // ---- Catalog validation ----
681
682    #[test]
683    fn test_error_catalog_all_codes_valid_format() {
684        let catalog = error_catalog();
685        for entry in &catalog {
686            assert_eq!(
687                entry.code.len(),
688                6,
689                "error code '{}' should be 6 chars (Axxxxx)",
690                entry.code
691            );
692            assert!(
693                entry.code.starts_with('A'),
694                "error code '{}' should start with 'A'",
695                entry.code
696            );
697            assert!(
698                entry.code[1..].chars().all(|c| c.is_ascii_digit()),
699                "error code '{}' should have 5 digits after 'A'",
700                entry.code
701            );
702        }
703    }
704
705    #[test]
706    fn test_error_catalog_has_major_categories() {
707        let catalog = error_catalog();
708        let codes: Vec<&str> = catalog.iter().map(|e| e.code).collect();
709        // Must have at least one code in each major category
710        assert!(
711            codes.iter().any(|c| c.starts_with("A01")),
712            "missing A01xxx (syntax)"
713        );
714        assert!(
715            codes.iter().any(|c| c.starts_with("A02")),
716            "missing A02xxx (resolve)"
717        );
718        assert!(
719            codes.iter().any(|c| c.starts_with("A03")),
720            "missing A03xxx (type)"
721        );
722        assert!(
723            codes.iter().any(|c| c.starts_with("A05")),
724            "missing A05xxx (linear)"
725        );
726        assert!(
727            codes.iter().any(|c| c.starts_with("A07")),
728            "missing A07xxx (effect)"
729        );
730    }
731
732    #[test]
733    fn test_error_catalog_size_reasonable() {
734        let catalog = error_catalog();
735        assert!(
736            catalog.len() >= 150,
737            "catalog should have 150+ entries (emitted + wired codes), got {}",
738            catalog.len()
739        );
740    }
741
742    #[test]
743    fn test_explain_empty_string() {
744        assert!(explain("").is_none());
745    }
746
747    #[test]
748    fn test_explain_partial_code() {
749        assert!(explain("A01").is_none());
750        assert!(explain("A").is_none());
751    }
752
753    #[test]
754    fn test_explain_nonexistent_category() {
755        assert!(explain("A88888").is_none());
756    }
757
758    // ---- Diagnostic edge cases ----
759
760    #[test]
761    fn test_diagnostic_zero_length_span() {
762        let d = Diagnostic::error("A01001", "at position", 5..5);
763        assert_eq!(d.primary, 5..5);
764        assert!(d.primary.is_empty());
765    }
766
767    #[test]
768    fn test_diagnostic_large_span() {
769        let d = Diagnostic::error("A01001", "whole file", 0..100_000);
770        assert_eq!(d.primary, 0..100_000);
771    }
772
773    #[test]
774    fn test_diagnostic_empty_message() {
775        let d = Diagnostic::error("A01001", "", 0..1);
776        assert_eq!(d.message, "");
777        assert_eq!(format!("{d}"), "[A01001] ");
778    }
779
780    #[test]
781    fn test_diagnostic_default_file_empty() {
782        let d = Diagnostic::error("A01001", "err", 0..1);
783        assert!(d.file.is_empty());
784    }
785
786    #[test]
787    fn test_diagnostic_with_file_overwrites() {
788        let d = Diagnostic::error("A01001", "err", 0..1)
789            .with_file("first.assura")
790            .with_file("second.assura");
791        assert_eq!(d.file, "second.assura");
792    }
793
794    #[test]
795    fn test_render_diagnostic_with_suggestion() {
796        let d = Diagnostic::error("A01002", "missing colon", 8..9).with_suggestion(
797            "add colon",
798            8..9,
799            ":",
800        );
801        // Must not panic
802        render_diagnostic(&d, "test.assura", "requires x > 0");
803    }
804
805    #[test]
806    fn test_render_advice_only_suggestion_no_empty_backticks() {
807        // Advice-only suggestions use empty replacement text. Rendering must
808        // not produce "Help: message: ``".
809        let d = Diagnostic::error("A03006", "requires clause must be Bool", 0..1).with_suggestion(
810            "Ensure clauses are boolean expressions",
811            0..1,
812            "",
813        );
814        render_diagnostic(&d, "test.assura", "x");
815        assert_eq!(d.suggestion.as_ref().unwrap().replacement, "");
816    }
817
818    #[test]
819    fn test_report_diagnostics_human_empty() {
820        // Empty list should not panic
821        report_diagnostics_human(&[], "empty.assura", "");
822    }
823
824    #[test]
825    fn test_render_diagnostic_info_severity() {
826        let d = Diagnostic {
827            code: ErrorCode::from("A99999"),
828            severity: Severity::Info,
829            message: "informational".into(),
830            file: String::new(),
831            primary: 0..1,
832            secondary: Vec::new(),
833            suggestion: None,
834        };
835        // Must not panic
836        render_diagnostic(&d, "test.assura", "x");
837    }
838
839    // ---- Severity edge cases ----
840
841    #[test]
842    fn test_severity_equality() {
843        assert_eq!(Severity::Error, Severity::Error);
844        assert_ne!(Severity::Error, Severity::Warning);
845        assert_ne!(Severity::Warning, Severity::Info);
846    }
847
848    #[test]
849    fn test_severity_copy() {
850        let s = Severity::Error;
851        let s2 = s; // Copy
852        assert_eq!(s, s2);
853    }
854
855    // ---- SecondaryLabel ----
856
857    #[test]
858    fn test_secondary_label_inequality() {
859        let a = SecondaryLabel {
860            span: 0..5,
861            message: "here".to_string(),
862        };
863        let b = SecondaryLabel {
864            span: 0..5,
865            message: "there".to_string(),
866        };
867        assert_ne!(a, b);
868    }
869
870    #[test]
871    fn test_secondary_label_serialization() {
872        let label = SecondaryLabel {
873            span: 10..20,
874            message: "declared here".to_string(),
875        };
876        let json = serde_json::to_string(&label).unwrap();
877        assert!(json.contains("declared here"));
878        assert!(json.contains("\"start\":10"), "span start: {json}");
879        assert!(json.contains("\"end\":20"), "span end: {json}");
880    }
881
882    // ---- Suggestion ----
883
884    #[test]
885    fn test_suggestion_equality() {
886        let a = Suggestion {
887            message: "fix".into(),
888            span: 0..1,
889            replacement: ";".into(),
890        };
891        let b = Suggestion {
892            message: "fix".into(),
893            span: 0..1,
894            replacement: ";".into(),
895        };
896        assert_eq!(a, b);
897    }
898
899    #[test]
900    fn test_suggestion_inequality() {
901        let a = Suggestion {
902            message: "fix".into(),
903            span: 0..1,
904            replacement: ";".into(),
905        };
906        let b = Suggestion {
907            message: "fix".into(),
908            span: 0..1,
909            replacement: ":".into(),
910        };
911        assert_ne!(a, b);
912    }
913
914    // ---- Full diagnostic JSON roundtrip ----
915
916    #[test]
917    fn test_diagnostic_full_json_structure() {
918        let d = Diagnostic::error("A03001", "type mismatch", 10..20)
919            .with_file("test.assura")
920            .with_secondary(30..40, "expected here")
921            .with_secondary(50..60, "found here")
922            .with_suggestion("change type", 10..20, "Int");
923        let json = serde_json::to_string_pretty(&d).unwrap();
924        let val: serde_json::Value = serde_json::from_str(&json).unwrap();
925        // Check top-level fields
926        assert_eq!(val["code"], "A03001");
927        assert_eq!(val["severity"], "error");
928        assert_eq!(val["file"], "test.assura");
929        // Check secondary array
930        assert!(val["secondary"].is_array());
931        assert_eq!(val["secondary"].as_array().unwrap().len(), 2);
932        // Check suggestion
933        assert!(val["suggestion"].is_object());
934        assert_eq!(val["suggestion"]["replacement"], "Int");
935    }
936
937    #[test]
938    fn test_diagnostic_json_no_suggestion() {
939        let d = Diagnostic::warning("A02007", "unused", 0..5);
940        let json = serde_json::to_string(&d).unwrap();
941        let val: serde_json::Value = serde_json::from_str(&json).unwrap();
942        assert!(val["suggestion"].is_null());
943    }
944
945    // ---- ErrorInfo ----
946
947    #[test]
948    fn test_error_info_equality() {
949        let a = ErrorInfo {
950            code: "A01001",
951            name: "Unexpected character",
952            description: "desc",
953            example: "ex",
954            fix: "fix",
955        };
956        let b = ErrorInfo {
957            code: "A01001",
958            name: "Unexpected character",
959            description: "desc",
960            example: "ex",
961            fix: "fix",
962        };
963        assert_eq!(a, b);
964    }
965
966    #[test]
967    fn test_error_info_clone() {
968        let a = ErrorInfo {
969            code: "A01001",
970            name: "test",
971            description: "desc",
972            example: "ex",
973            fix: "fix",
974        };
975        let b = a.clone();
976        assert_eq!(a, b);
977    }
978
979    // ---- Catalog code lookup coverage ----
980
981    #[test]
982    fn test_explain_returns_same_as_catalog_entry() {
983        let catalog = error_catalog();
984        // Spot-check several specific codes
985        for code in &["A01001", "A02001", "A03001", "A05001", "A07003", "A10001"] {
986            let from_explain = explain(code).expect(&format!("{code} should exist"));
987            let from_catalog = catalog
988                .iter()
989                .find(|e| e.code == *code)
990                .expect("in catalog");
991            assert_eq!(from_explain.name, from_catalog.name);
992            assert_eq!(from_explain.description, from_catalog.description);
993        }
994    }
995}