Skip to main content

mib_rs/types/
diagnostic.rs

1//! Diagnostic reporting and configuration.
2//!
3//! [`Diagnostic`] represents a single issue found during parsing or resolution.
4//! [`DiagnosticConfig`] controls diagnostic collection, presentation, severity
5//! overrides, and load failure thresholds, with preset configurations for
6//! common use cases.
7
8use std::cmp::Ordering;
9use std::collections::HashMap;
10use std::fmt;
11use std::sync::Arc;
12
13use crate::source::{
14    BytePosition, Position, PositionEncoding, PositionError, SourceDocument, SourceId,
15    SourceOrigin, SourceRange, SourceRangeError, SourceSet,
16};
17
18use super::{DiagCode, ReportingLevel, Severity};
19
20/// An issue found during parsing or resolution.
21///
22/// Its [`severity`](Self::severity) is the effective severity after applying
23/// [`DiagnosticConfig::overrides`]. Source locations remain checked,
24/// source-qualified byte ranges; line and column values are derived by a
25/// report-owned [`DiagnosticEntry`] handles when needed for presentation.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Diagnostic {
28    /// Effective severity after applying diagnostic configuration overrides.
29    pub severity: Severity,
30    /// Diagnostic code identifying the issue category.
31    pub code: DiagCode,
32    /// Human-readable description of the issue.
33    pub message: String,
34    /// Module name where the issue was found, if applicable.
35    pub module: Option<String>,
36    /// Exact half-open source range, or `None` for a generated/source-less issue.
37    pub range: Option<SourceRange>,
38}
39
40impl fmt::Display for Diagnostic {
41    /// Formats without a source position; use [`DiagnosticEntry::render`] to
42    /// include a checked, derived location.
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "[{}]", self.severity)?;
45        if let Some(module) = &self.module {
46            write!(f, " {module}:")?;
47        }
48        write!(f, " {}", self.message)
49    }
50}
51
52/// An ordered diagnostic collection that retains all referenced source documents.
53///
54/// Cloning a report shares immutable source documents; diagnostic values are
55/// cloned because they are small presentation records.
56/// Reports are returned by [`Mib::diagnostic_report`](crate::Mib::diagnostic_report),
57/// the lossless CST entry points [`cst::parse`](crate::cst::parse) and
58/// [`cst::parse_with_config`](crate::cst::parse_with_config), and
59/// [`LoadError::DiagnosticThreshold`](crate::LoadError::DiagnosticThreshold).
60/// Each report keeps every diagnostic tied to the exact source arena that
61/// allocated its IDs.
62///
63/// ```compile_fail
64/// use std::sync::Arc;
65/// use mib_rs::{DiagnosticReport, SourceSet};
66///
67/// // Arbitrary diagnostic/source association is intentionally unavailable.
68/// let report = DiagnosticReport::new(Vec::new(), Arc::new(SourceSet::new()));
69/// ```
70///
71/// Checked operations belong to report-owned entries rather than accepting a
72/// free [`Diagnostic`] reference:
73///
74/// ```compile_fail
75/// # fn reports() -> (mib_rs::DiagnosticReport, mib_rs::DiagnosticReport) { todo!() }
76/// let (first, second) = reports();
77/// let foreign = &first.diagnostics()[0];
78/// let location = second.range(foreign);
79/// ```
80#[derive(Debug, Clone)]
81pub struct DiagnosticReport {
82    diagnostics: Vec<Diagnostic>,
83    sources: Arc<SourceSet>,
84}
85
86impl DiagnosticReport {
87    pub(crate) fn new(mut diagnostics: Vec<Diagnostic>, sources: Arc<SourceSet>) -> Self {
88        sort_diagnostics(&mut diagnostics, &sources);
89        Self {
90            diagnostics,
91            sources,
92        }
93    }
94
95    /// Return the number of diagnostics in this report.
96    pub fn len(&self) -> usize {
97        self.diagnostics.len()
98    }
99
100    /// Return whether this report contains no diagnostics.
101    pub fn is_empty(&self) -> bool {
102        self.diagnostics.is_empty()
103    }
104
105    /// Return diagnostics in canonical deterministic order.
106    ///
107    /// This slice exposes metadata only. Use [`Self::iter`] or [`Self::get`] to
108    /// obtain a report-owned [`DiagnosticEntry`] for checked source operations.
109    pub fn diagnostics(&self) -> &[Diagnostic] {
110        &self.diagnostics
111    }
112
113    /// Iterate over report-owned diagnostic entries in canonical order.
114    pub fn iter(&self) -> impl ExactSizeIterator<Item = DiagnosticEntry<'_>> + DoubleEndedIterator {
115        (0..self.diagnostics.len()).map(|index| DiagnosticEntry {
116            report: self,
117            index,
118        })
119    }
120
121    /// Return a report-owned diagnostic entry by canonical-order index.
122    pub fn get(&self, index: usize) -> Option<DiagnosticEntry<'_>> {
123        (index < self.diagnostics.len()).then_some(DiagnosticEntry {
124            report: self,
125            index,
126        })
127    }
128
129    #[cfg(test)]
130    pub(crate) fn shared_sources(&self) -> &Arc<SourceSet> {
131        &self.sources
132    }
133}
134
135/// A diagnostic tied to the report that owns its source arena.
136///
137/// Entries are created only by [`DiagnosticReport::iter`] and
138/// [`DiagnosticReport::get`]. Checked range and position methods therefore
139/// cannot accidentally resolve a diagnostic through another report whose
140/// compilation-local source IDs happen to have the same numeric value.
141#[derive(Clone, Copy, Debug)]
142pub struct DiagnosticEntry<'report> {
143    report: &'report DiagnosticReport,
144    index: usize,
145}
146
147impl<'report> DiagnosticEntry<'report> {
148    /// Return the diagnostic metadata owned by this entry's report.
149    pub fn diagnostic(&self) -> &'report Diagnostic {
150        &self.report.diagnostics[self.index]
151    }
152
153    /// Resolve and validate a diagnostic's optional source range.
154    pub fn range(
155        &self,
156    ) -> Result<Option<(&'report SourceDocument, SourceRange)>, DiagnosticReportError> {
157        let diagnostic = self.diagnostic();
158        let Some(range) = diagnostic.range else {
159            return Ok(None);
160        };
161        let source = self
162            .report
163            .sources
164            .get(range.source())
165            .ok_or(DiagnosticReportError::SourceNotRetained(range.source()))?;
166        source.slice(range)?;
167        Ok(Some((source, range)))
168    }
169
170    /// Return the checked bytes covered by a diagnostic's range.
171    pub fn slice(&self) -> Result<Option<&'report [u8]>, DiagnosticReportError> {
172        self.range()?
173            .map(|(source, range)| source.slice(range).map_err(Into::into))
174            .transpose()
175    }
176
177    /// Derive zero-based byte positions for a diagnostic's half-open range.
178    pub fn byte_positions(
179        &self,
180    ) -> Result<Option<(BytePosition, BytePosition)>, DiagnosticReportError> {
181        self.range()?
182            .map(|(source, range)| {
183                Ok((
184                    source.byte_position(range.start())?,
185                    source.byte_position(range.end())?,
186                ))
187            })
188            .transpose()
189    }
190
191    /// Derive zero-based editor positions in an explicit encoding.
192    pub fn positions(
193        &self,
194        encoding: PositionEncoding,
195    ) -> Result<Option<(Position, Position)>, DiagnosticReportError> {
196        self.range()?
197            .map(|(source, range)| {
198                Ok((
199                    source.position(range.start(), encoding)?,
200                    source.position(range.end(), encoding)?,
201                ))
202            })
203            .transpose()
204    }
205
206    /// Render a diagnostic with its source label and checked one-based byte range.
207    ///
208    /// The displayed range is half-open. Source-less diagnostics omit the
209    /// location, as does [`Diagnostic`]'s standalone display implementation.
210    pub fn render(&self) -> Result<String, DiagnosticReportError> {
211        let diagnostic = self.diagnostic();
212        let mut rendered = format!("[{}]", diagnostic.severity);
213        if let Some((source, _)) = self.range()? {
214            let (start, end) = self
215                .byte_positions()?
216                .expect("a checked source range has byte positions");
217            use std::fmt::Write;
218            write!(
219                rendered,
220                " {}:{}:{}-{}:{}",
221                source.label(),
222                u64::from(start.line()) + 1,
223                u64::from(start.column()) + 1,
224                u64::from(end.line()) + 1,
225                u64::from(end.column()) + 1
226            )
227            .expect("writing to String cannot fail");
228        }
229        if let Some(module) = &diagnostic.module {
230            rendered.push(' ');
231            rendered.push_str(module);
232        }
233        if diagnostic.module.is_some() || diagnostic.range.is_some() {
234            rendered.push(':');
235        }
236        rendered.push(' ');
237        rendered.push_str(&diagnostic.message);
238        Ok(rendered)
239    }
240}
241
242fn sort_diagnostics(diagnostics: &mut [Diagnostic], sources: &SourceSet) {
243    diagnostics.sort_by(|left, right| {
244        left.code
245            .phase()
246            .cmp(right.code.phase())
247            .then_with(|| left.code.as_code().cmp(right.code.as_code()))
248            .then(left.severity.cmp(&right.severity))
249            .then(left.module.cmp(&right.module))
250            .then_with(|| compare_ranges(left.range, right.range, sources))
251            .then(left.message.cmp(&right.message))
252    });
253}
254
255fn compare_ranges(
256    left: Option<SourceRange>,
257    right: Option<SourceRange>,
258    sources: &SourceSet,
259) -> Ordering {
260    match (left, right) {
261        (None, None) => Ordering::Equal,
262        (None, Some(_)) => Ordering::Less,
263        (Some(_), None) => Ordering::Greater,
264        (Some(left), Some(right)) => {
265            let left_source = sources.get(left.source());
266            let right_source = sources.get(right.source());
267            match (left_source, right_source) {
268                (Some(left_source), Some(right_source)) => {
269                    compare_origins(left_source.origin(), right_source.origin())
270                        .then_with(|| left_source.label().cmp(right_source.label()))
271                        .then(left.start().cmp(&right.start()))
272                        .then(left.end().cmp(&right.end()))
273                }
274                (Some(_), None) => Ordering::Less,
275                (None, Some(_)) => Ordering::Greater,
276                (None, None) => left
277                    .start()
278                    .cmp(&right.start())
279                    .then(left.end().cmp(&right.end())),
280            }
281        }
282    }
283}
284
285fn compare_origins(left: &SourceOrigin, right: &SourceOrigin) -> Ordering {
286    fn rank(origin: &SourceOrigin) -> u8 {
287        match origin {
288            SourceOrigin::File { .. } => 0,
289            SourceOrigin::Embedded { .. } => 1,
290            SourceOrigin::Memory { .. } => 2,
291            SourceOrigin::Custom { .. } => 3,
292        }
293    }
294
295    rank(left)
296        .cmp(&rank(right))
297        .then_with(|| match (left, right) {
298            (SourceOrigin::File { path: left }, SourceOrigin::File { path: right }) => {
299                left.cmp(right)
300            }
301            (
302                SourceOrigin::Embedded { identity: left },
303                SourceOrigin::Embedded { identity: right },
304            )
305            | (
306                SourceOrigin::Memory { identity: left },
307                SourceOrigin::Memory { identity: right },
308            ) => left.cmp(right),
309            (
310                SourceOrigin::Custom {
311                    provider: left_provider,
312                    identity: left_identity,
313                },
314                SourceOrigin::Custom {
315                    provider: right_provider,
316                    identity: right_identity,
317                },
318            ) => left_provider
319                .cmp(right_provider)
320                .then(left_identity.cmp(right_identity)),
321            _ => Ordering::Equal,
322        })
323}
324
325/// Failure to resolve or convert a source location retained by a report.
326#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
327pub enum DiagnosticReportError {
328    /// The diagnostic names a source outside the report's retained source set.
329    #[error("source {0} is not retained by this diagnostic report")]
330    SourceNotRetained(SourceId),
331    /// The retained source rejected the diagnostic's range.
332    #[error(transparent)]
333    Range(#[from] SourceRangeError),
334    /// The retained source rejected a requested position conversion.
335    #[error(transparent)]
336    Position(#[from] PositionError),
337}
338
339/// Controls diagnostic collection, presentation, and failure policy.
340///
341/// This does NOT control resolver behavior.
342/// Resolver fallback behavior is controlled by [`ResolverStrictness`](crate::types::ResolverStrictness).
343#[derive(Debug, Clone)]
344pub struct DiagnosticConfig {
345    /// Which severity levels are reported. See [`ReportingLevel`].
346    pub reporting: ReportingLevel,
347    /// Diagnostics at this severity or above cause loading to fail.
348    pub fail_at: Severity,
349    /// Per-code severity overrides (e.g. promote a warning to error).
350    ///
351    /// Overrides change the severity stored on emitted diagnostics and used by
352    /// failure checks. Demoting a diagnostic does not by itself suppress it;
353    /// use [`ignore`](Self::ignore) for suppression.
354    pub overrides: HashMap<DiagCode, Severity>,
355    /// Glob patterns for [`DiagCode`] strings to suppress (supports `*` and `?`).
356    pub ignore: Vec<String>,
357}
358
359impl Default for DiagnosticConfig {
360    fn default() -> Self {
361        DiagnosticConfig {
362            reporting: ReportingLevel::Default,
363            fail_at: Severity::Severe,
364            overrides: HashMap::new(),
365            ignore: Vec::new(),
366        }
367    }
368}
369
370impl DiagnosticConfig {
371    /// Returns a preset configuration for the given [`ReportingLevel`].
372    pub fn for_reporting(level: ReportingLevel) -> Self {
373        match level {
374            ReportingLevel::Verbose => Self::verbose(),
375            ReportingLevel::Default => Self::default(),
376            ReportingLevel::Quiet => Self::quiet(),
377            ReportingLevel::Silent => Self::silent(),
378        }
379    }
380
381    /// Verbose preset: report all diagnostics including style and info.
382    pub fn verbose() -> Self {
383        DiagnosticConfig {
384            reporting: ReportingLevel::Verbose,
385            fail_at: Severity::Severe,
386            overrides: HashMap::new(),
387            ignore: Vec::new(),
388        }
389    }
390
391    /// Quiet preset: report errors and above only.
392    pub fn quiet() -> Self {
393        DiagnosticConfig {
394            reporting: ReportingLevel::Quiet,
395            fail_at: Severity::Severe,
396            overrides: HashMap::new(),
397            ignore: Vec::new(),
398        }
399    }
400
401    /// Silent preset: suppress all diagnostics. Only fatal errors cause failure.
402    pub fn silent() -> Self {
403        DiagnosticConfig {
404            reporting: ReportingLevel::Silent,
405            fail_at: Severity::Fatal,
406            overrides: HashMap::new(),
407            ignore: Vec::new(),
408        }
409    }
410
411    /// Returns the configured severity for a diagnostic code.
412    ///
413    /// This is the severity stored on emitted diagnostics and evaluated by
414    /// [`should_fail`](Self::should_fail).
415    pub fn effective_severity(&self, code: DiagCode) -> Severity {
416        self.overrides
417            .get(&code)
418            .copied()
419            .unwrap_or_else(|| code.severity())
420    }
421
422    /// Returns `true` if the diagnostic code matches an [`ignore`](Self::ignore) pattern.
423    pub fn is_ignored(&self, code: DiagCode) -> bool {
424        let code_str = code.as_code();
425        self.ignore
426            .iter()
427            .any(|pattern| match_glob(pattern, code_str))
428    }
429
430    /// Returns `true` if the reporting level collects the given severity.
431    ///
432    /// Fatal diagnostics are always collected, including in silent mode.
433    /// Ignore patterns are a separate policy evaluated by
434    /// [`should_collect`](Self::should_collect).
435    pub fn should_report(&self, severity: Severity) -> bool {
436        severity == Severity::Fatal
437            || self
438                .max_reported_severity()
439                .is_some_and(|max| severity <= max)
440    }
441
442    /// Returns `true` if a diagnostic with the given code should be collected.
443    ///
444    /// Promotions can bring a diagnostic into the configured reporting level,
445    /// while demotions do not discard a diagnostic that its default severity
446    /// would collect. Effective fatal diagnostics are always collected,
447    /// including when ignored or reporting is silent.
448    pub fn should_collect(&self, code: DiagCode) -> bool {
449        let effective_severity = self.effective_severity(code);
450
451        if effective_severity == Severity::Fatal {
452            return true;
453        }
454
455        if self.is_ignored(code) {
456            return false;
457        }
458
459        self.should_report(code.severity()) || self.should_report(effective_severity)
460    }
461
462    /// Returns `true` if the given effective severity meets or exceeds the
463    /// [`fail_at`](Self::fail_at) threshold.
464    pub fn should_fail(&self, severity: Severity) -> bool {
465        severity <= self.fail_at
466    }
467
468    /// Returns the maximum severity number (least severe) that should be
469    /// reported at the current reporting level.
470    ///
471    /// - Verbose: report all diagnostics (sev 0-6)
472    /// - Default: report Minor and above (sev 0-3)
473    /// - Quiet: report Error and above (sev 0-2)
474    /// - Silent: report nothing (except fatal, handled by caller)
475    fn max_reported_severity(&self) -> Option<Severity> {
476        match self.reporting {
477            ReportingLevel::Verbose => Some(Severity::Info),
478            ReportingLevel::Default => Some(Severity::Minor),
479            ReportingLevel::Quiet => Some(Severity::Error),
480            ReportingLevel::Silent => None,
481        }
482    }
483}
484
485/// Glob matching on diagnostic codes. Supports * and ? wildcards.
486/// Diagnostic codes contain no slashes, so * matches any sequence of characters.
487fn match_glob(pattern: &str, s: &str) -> bool {
488    glob_match(pattern.as_bytes(), s.as_bytes())
489}
490
491fn glob_match(pattern: &[u8], s: &[u8]) -> bool {
492    let mut pi = 0;
493    let mut si = 0;
494    let mut star_pi: Option<usize> = None;
495    let mut star_si = 0;
496
497    while si < s.len() {
498        if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == s[si]) {
499            pi += 1;
500            si += 1;
501        } else if pi < pattern.len() && pattern[pi] == b'*' {
502            star_pi = Some(pi);
503            star_si = si;
504            pi += 1;
505        } else if let Some(sp) = star_pi {
506            pi = sp + 1;
507            star_si += 1;
508            si = star_si;
509        } else {
510            return false;
511        }
512    }
513
514    while pi < pattern.len() && pattern[pi] == b'*' {
515        pi += 1;
516    }
517
518    pi == pattern.len()
519}
520
521#[cfg(test)]
522mod tests {
523    use std::sync::Arc;
524
525    use crate::source::SourceOrigin;
526
527    use super::*;
528
529    #[test]
530    fn diagnostic_display() {
531        let d = Diagnostic {
532            severity: Severity::Error,
533            code: DiagCode::ImportNotFound,
534            message: "symbol foo not found".to_string(),
535            module: Some("IF-MIB".to_string()),
536            range: None,
537        };
538        assert_eq!(d.to_string(), "[error] IF-MIB: symbol foo not found");
539    }
540
541    #[test]
542    fn diagnostic_display_no_location() {
543        let d = Diagnostic {
544            severity: Severity::Warning,
545            code: DiagCode::ImportUnused,
546            message: "unused import".to_string(),
547            module: None,
548            range: None,
549        };
550        assert_eq!(d.to_string(), "[warning] unused import");
551    }
552
553    #[test]
554    fn report_retains_and_renders_checked_full_range() {
555        let mut sources = SourceSet::new();
556        let source_id = sources
557            .insert(
558                SourceOrigin::memory("diagnostic-report"),
559                "diagnostic-report",
560                Arc::from(&b"first\nsecond"[..]),
561            )
562            .unwrap();
563        let range = sources.get(source_id).unwrap().range(8..10).unwrap();
564        let diagnostic = Diagnostic {
565            severity: Severity::Error,
566            code: DiagCode::ParseError,
567            message: "precise range".to_string(),
568            module: Some("TEST-MIB".to_string()),
569            range: Some(range),
570        };
571        let sources = Arc::new(sources);
572        let report = DiagnosticReport::new(vec![diagnostic], Arc::clone(&sources));
573        drop(sources);
574
575        let entry = report.get(0).unwrap();
576        assert_eq!(entry.slice().unwrap(), Some(&b"co"[..]));
577        assert_eq!(
578            entry.byte_positions().unwrap(),
579            Some((BytePosition::new(1, 2), BytePosition::new(1, 4)))
580        );
581        assert_eq!(
582            entry.render().unwrap(),
583            "[error] diagnostic-report:2:3-2:5 TEST-MIB: precise range"
584        );
585    }
586
587    #[test]
588    fn report_rejects_a_range_whose_source_is_not_retained() {
589        let mut retained = SourceSet::new();
590        retained
591            .insert(
592                SourceOrigin::memory("retained"),
593                "retained",
594                Arc::from(&b"retained"[..]),
595            )
596            .unwrap();
597        let mut foreign = SourceSet::new();
598        foreign
599            .insert(
600                SourceOrigin::memory("foreign-first"),
601                "foreign-first",
602                Arc::from(&b"first"[..]),
603            )
604            .unwrap();
605        let foreign_id = foreign
606            .insert(
607                SourceOrigin::memory("foreign-second"),
608                "foreign-second",
609                Arc::from(&b"second"[..]),
610            )
611            .unwrap();
612        let range = foreign.get(foreign_id).unwrap().range(0..1).unwrap();
613        let diagnostic = Diagnostic {
614            severity: Severity::Error,
615            code: DiagCode::ParseError,
616            message: "foreign".to_string(),
617            module: None,
618            range: Some(range),
619        };
620        let report = DiagnosticReport::new(vec![diagnostic], Arc::new(retained));
621
622        let entry = report.get(0).unwrap();
623        assert!(matches!(
624            entry.range(),
625            Err(DiagnosticReportError::SourceNotRetained(id)) if id == foreign_id
626        ));
627        assert!(matches!(
628            entry.render(),
629            Err(DiagnosticReportError::SourceNotRetained(id)) if id == foreign_id
630        ));
631    }
632
633    #[test]
634    fn report_entries_cannot_cross_resolve_aliased_source_ids() {
635        fn report(identity: &str, bytes: &'static [u8]) -> DiagnosticReport {
636            let mut sources = SourceSet::new();
637            let source_id = sources
638                .insert(SourceOrigin::memory(identity), identity, Arc::from(bytes))
639                .unwrap();
640            let range = sources
641                .get(source_id)
642                .unwrap()
643                .range(0..bytes.len())
644                .unwrap();
645            DiagnosticReport::new(
646                vec![Diagnostic {
647                    severity: Severity::Error,
648                    code: DiagCode::ParseError,
649                    message: identity.to_string(),
650                    module: None,
651                    range: Some(range),
652                }],
653                Arc::new(sources),
654            )
655        }
656
657        let first = report("first", b"alpha");
658        let second = report("second", b"bravo!");
659        let first_entry = first.get(0).unwrap();
660        let second_entry = second.get(0).unwrap();
661
662        assert_eq!(
663            first_entry.diagnostic().range.unwrap().source(),
664            second_entry.diagnostic().range.unwrap().source()
665        );
666        assert_eq!(first_entry.slice().unwrap(), Some(&b"alpha"[..]));
667        assert_eq!(second_entry.slice().unwrap(), Some(&b"bravo!"[..]));
668        assert_eq!(first_entry.range().unwrap().unwrap().0.label(), "first");
669        assert_eq!(second_entry.range().unwrap().unwrap().0.label(), "second");
670    }
671
672    #[test]
673    fn canonical_order_uses_stable_source_identity_not_source_id_allocation() {
674        fn report(order: [&str; 2]) -> DiagnosticReport {
675            let mut sources = SourceSet::new();
676            for identity in order {
677                sources
678                    .insert(
679                        SourceOrigin::memory(identity),
680                        identity,
681                        Arc::from(&b"x"[..]),
682                    )
683                    .unwrap();
684            }
685            let diagnostics = sources
686                .iter()
687                .map(|source| Diagnostic {
688                    severity: Severity::Error,
689                    code: DiagCode::ParseError,
690                    message: source.label().to_string(),
691                    module: Some("TEST-MIB".to_string()),
692                    range: Some(source.range(0..1).unwrap()),
693                })
694                .collect();
695            DiagnosticReport::new(diagnostics, Arc::new(sources))
696        }
697
698        let forward = report(["a-source", "b-source"]);
699        let reverse = report(["b-source", "a-source"]);
700        let labels = |report: &DiagnosticReport| {
701            report
702                .iter()
703                .map(|entry| entry.range().unwrap().unwrap().0.label().to_string())
704                .collect::<Vec<_>>()
705        };
706        assert_eq!(labels(&forward), vec!["a-source", "b-source"]);
707        assert_eq!(labels(&reverse), labels(&forward));
708    }
709
710    #[test]
711    fn canonical_order_is_deterministic_for_source_less_diagnostics() {
712        let diagnostic = |message: &str| Diagnostic {
713            severity: Severity::Error,
714            code: DiagCode::ParseError,
715            message: message.to_string(),
716            module: None,
717            range: None,
718        };
719        let report = DiagnosticReport::new(
720            vec![diagnostic("second"), diagnostic("first")],
721            Arc::new(SourceSet::new()),
722        );
723        assert_eq!(
724            report
725                .diagnostics()
726                .iter()
727                .map(|diagnostic| diagnostic.message.as_str())
728                .collect::<Vec<_>>(),
729            vec!["first", "second"]
730        );
731    }
732
733    #[test]
734    fn glob_matching() {
735        assert!(match_glob("identifier-*", "identifier-underscore"));
736        assert!(match_glob("identifier-*", "identifier-length-32"));
737        assert!(!match_glob("identifier-*", "import-not-found"));
738        assert!(match_glob("*", "anything"));
739        assert!(match_glob("exact-match", "exact-match"));
740        assert!(!match_glob("exact-match", "exact-mismatch"));
741    }
742
743    #[test]
744    fn should_report_respects_level() {
745        let config = DiagnosticConfig::default();
746        // Default reports Minor and above (sev 0-3)
747        assert!(config.should_report(Severity::Error));
748        assert!(config.should_report(Severity::Minor));
749        assert!(!config.should_report(Severity::Style));
750    }
751
752    #[test]
753    fn should_report_silent() {
754        let config = DiagnosticConfig::silent();
755        // Silent reports nothing except fatal
756        assert!(config.should_report(Severity::Fatal));
757        assert!(!config.should_report(Severity::Error));
758        assert!(!config.should_report(Severity::Style));
759    }
760
761    #[test]
762    fn should_report_verbose() {
763        let config = DiagnosticConfig::verbose();
764        // Verbose reports everything
765        assert!(config.should_report(Severity::Error));
766        assert!(config.should_report(Severity::Style));
767    }
768
769    #[test]
770    fn effective_severity_applies_override() {
771        let mut config = DiagnosticConfig::default();
772        config
773            .overrides
774            .insert(DiagCode::MacroNotImported, Severity::Severe);
775
776        assert_eq!(
777            config.effective_severity(DiagCode::MacroNotImported),
778            Severity::Severe
779        );
780        assert_eq!(
781            config.effective_severity(DiagCode::ParseError),
782            Severity::Error
783        );
784    }
785
786    #[test]
787    fn promotion_affects_collection() {
788        let mut config = DiagnosticConfig::default();
789        config
790            .overrides
791            .insert(DiagCode::IdentifierUnderscore, Severity::Minor);
792
793        assert!(config.should_collect(DiagCode::IdentifierUnderscore));
794    }
795
796    #[test]
797    fn demotion_does_not_discard_collected_diagnostic() {
798        let mut config = DiagnosticConfig::quiet();
799        config
800            .overrides
801            .insert(DiagCode::ParseError, Severity::Info);
802
803        assert!(config.should_collect(DiagCode::ParseError));
804    }
805
806    #[test]
807    fn ignore_suppresses_nonfatal_diagnostic() {
808        let mut config = DiagnosticConfig::verbose();
809        config.ignore.push("parse-*".to_string());
810
811        assert!(config.is_ignored(DiagCode::ParseError));
812        assert!(!config.should_collect(DiagCode::ParseError));
813    }
814
815    #[test]
816    fn effective_fatal_is_always_collected() {
817        let mut config = DiagnosticConfig::silent();
818        config
819            .overrides
820            .insert(DiagCode::IdentifierUnderscore, Severity::Fatal);
821        config.ignore.push("identifier-*".to_string());
822
823        assert!(config.is_ignored(DiagCode::IdentifierUnderscore));
824        assert!(config.should_collect(DiagCode::IdentifierUnderscore));
825    }
826
827    #[test]
828    fn should_fail_threshold() {
829        let config = DiagnosticConfig::default();
830        assert!(config.should_fail(Severity::Fatal));
831        assert!(config.should_fail(Severity::Severe));
832        assert!(!config.should_fail(Severity::Error));
833    }
834
835    #[test]
836    fn for_reporting_presets() {
837        let verbose = DiagnosticConfig::for_reporting(ReportingLevel::Verbose);
838        assert!(matches!(verbose.reporting, ReportingLevel::Verbose));
839
840        let silent = DiagnosticConfig::for_reporting(ReportingLevel::Silent);
841        assert!(matches!(silent.reporting, ReportingLevel::Silent));
842        assert!(matches!(silent.fail_at, Severity::Fatal));
843    }
844}