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