Skip to main content

tzcompile/
diagnostics.rs

1//! Located, coded diagnostics about tzdata source.
2//!
3//! Every diagnostic carries a stable code (`ZIC0xx`), a severity, a human message, the
4//! originating file and line, an optional column span within the line, and an optional
5//! suggestion. Codes are part of the tool's contract: they let scripts and tests match on
6//! a failure class without scraping prose.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11/// Severity of a diagnostic.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Severity {
14    Error,
15    Warning,
16    Info,
17}
18
19impl fmt::Display for Severity {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        let s = match self {
22            Severity::Error => "error",
23            Severity::Warning => "warning",
24            Severity::Info => "info",
25        };
26        f.write_str(s)
27    }
28}
29
30/// Stable diagnostic codes.
31///
32/// Numbering follows the project's documented scheme; gaps are reserved for future use so
33/// existing codes never shift meaning.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DiagnosticCode {
36    /// A directive/keyword we do not implement in this version.
37    UnsupportedDirective,
38    /// A line had the wrong number of fields for its record type.
39    InvalidFieldCount,
40    /// A month name was unrecognised or ambiguously abbreviated.
41    InvalidMonth,
42    /// A name abbreviation matched more than one keyword.
43    AmbiguousNameAbbreviation,
44    /// An `ON` day specification was malformed.
45    InvalidDayRule,
46    /// An `AT`/`SAVE` time had an invalid suffix.
47    InvalidTimeSuffix,
48    /// A `FROM`/`TO` year keyword we do not implement.
49    UnsupportedYearType,
50    /// A zone/link name (or its output path) would escape the output root.
51    OutputPathTraversal,
52    /// A zone produced more transitions than the configured limit.
53    TooManyTransitions,
54    /// Leap-second input was supplied but is not implemented.
55    UnsupportedLeapSeconds,
56    /// Our output disagreed with reference `zic`.
57    ReferenceZicMismatch,
58    /// A field value was syntactically invalid (offset, name, etc.).
59    InvalidValue,
60    /// A line in command position began with a token that is not a recognised record keyword
61    /// (`Rule`/`Zone`/`Link`). Lexical layer; reference `zic` reports "input line of unknown type".
62    /// Distinct from [`UnsupportedDirective`](Self::UnsupportedDirective), which is a *recognised*
63    /// construct zic-rs deliberately does not compile (a safety/scope divergence, T13.1).
64    UnknownLineType,
65    /// An indented (continuation-shaped) line appeared where no zone is open to continue. Structural
66    /// layer. zic-rs classifies this more precisely than reference `zic` (which folds it into "input
67    /// line of unknown type") — a documented, intentional refinement, not a wording mismatch.
68    ContinuationWithoutZone,
69    /// A second `Zone` was defined with a name already in use. Structural layer; carries the conflicting
70    /// name and the original definition's line (mirrors reference `zic`'s "duplicate zone name N").
71    DuplicateZone,
72    /// The input contained a NUL byte (lexical/admissibility). Reference `zic`: "NUL input byte".
73    NulByteInInput,
74    /// A source line exceeded the byte-length cap (lexical/admissibility). The cap (`MAX_LINE_LEN`)
75    /// was reconciled against reference `zic` in **T14.1** and **matches** its `_POSIX2_LINE_MAX = 2048`
76    /// (the older "511" manpage figure is stale — verified against the pinned 2026b source).
77    OverlongInputLine,
78    /// A time-zone abbreviation's **length** is outside the portable range (tzfile(5): **3–6
79    /// characters**) — fewer than 3 or more than 6 — a non-fatal **warning** (the file still compiles).
80    /// Mirrors reference `zic`'s always-on "time zone abbreviation has too many characters (X)" /
81    /// "…has fewer than 3 characters (X)" (thresholds pinned empirically against tzcode 2026b). The
82    /// first non-fatal warning class (T13.3); the `< 3` side was added in T13.4.
83    AbbreviationPolicyViolation,
84    /// A time-zone abbreviation contains a character outside the POSIX-portable set (only ASCII
85    /// alphanumerics and `+`/`-`), a non-fatal **warning**. Mirrors reference `zic`'s "time zone
86    /// abbreviation differs from POSIX standard (X)" (T13.4). A **distinct rule** from the length
87    /// policy ([`AbbreviationPolicyViolation`](Self::AbbreviationPolicyViolation)) — both can fire for
88    /// one abbreviation. (Case is *not* a violation: lowercase letters are alphanumeric and accepted.)
89    AbbreviationNotPosix,
90    /// The emitted transition count exceeds a client-compatibility threshold (an **emitted-stream**
91    /// warning, `-v`/verbose-only in reference `zic`). Pinned from `zic.c`: `1200 < timecnt` →
92    /// "pre-2014 clients may mishandle more than 1200 transition times", `TZ_MAX_TIMES < timecnt` →
93    /// "reference clients mishandle …". **Reserved class (T13.5):** the code + reference rule are
94    /// recorded; emission is wired in T13.6 (gated by the verbosity model), since it depends on the
95    /// finished `timecnt` and must be quiet-by-default to match reference.
96    TooManyTransitionsForLegacyClient,
97    /// The final input line has content but no terminating newline (lexical/admissibility). Mirrors
98    /// reference `zic`'s `inputline`: at EOF with `linelen > 0` it reports "unterminated line" and exits.
99    /// A POSIX text file ends every line with `\n`; zic-rs was previously lenient here (accepted the
100    /// trailing partial line) — T14.2 makes it fail closed, matching reference. The class name is the
101    /// **rule violated**, not the wording ("unterminated line").
102    UnterminatedInputLine,
103    /// A double-quoted field is opened but never closed before end-of-line (lexical/admissibility).
104    /// Mirrors reference `zic`'s `getfields`, which hits the line's terminating `\0` inside a quote and
105    /// reports "Odd number of quotation marks", then exits. zic-rs already failed closed here but under
106    /// the generic [`InvalidValue`](Self::InvalidValue) (`ZIC012`); T14.2 gives it a dedicated class.
107    UnterminatedQuote,
108    /// Two rules expand to a transition at the **same instant** (semantic). Mirrors reference `zic`'s
109    /// "two rules for same instant", which it treats as a fatal error (`errors=true` → exit 1). zic-rs
110    /// fails closed with this code rather than emitting a non-monotonic (invalid) TZif — a same-instant
111    /// rule conflict is not silently resolved. Surfaced by the T14.4 pathology ledger, which found that
112    /// the prior `debug_assert!` would panic in debug / emit an invalid stream in release.
113    SimultaneousTransition,
114    /// A zone/link **name** (which becomes an output path) contains a byte outside the portable benign
115    /// set (`-`, `_`, ASCII letters, and `/` as the separator) — e.g. a digit, `+`, `.`, or a high/control
116    /// byte. A non-fatal **warning** mirroring reference `zic`'s `-v`-gated "file name '%s' contains byte
117    /// '…'" (`namecheck`). The hard *structural* path rules (absolute · `..` · `//` · empty · trailing
118    /// `/`) are fatal [`OutputPathTraversal`](Self::OutputPathTraversal); this is the softer portability
119    /// axis (T14.5). zic-rs has no quiet mode → collects always; surfaced only under `--verbose`.
120    ZoneNameNonPortableByte,
121    /// A single component of a zone/link **name** exceeds the portable length (reference `zic`'s
122    /// `component_len_max = 14`). A non-fatal **warning** mirroring `zic`'s `-v`-gated "file name '%s'
123    /// contains overlength component" (`componentcheck`); the file still compiles (T14.5).
124    ZoneNameOverlengthComponent,
125    /// A parsed time value (a `STDOFF` / `SAVE` / `AT` / `UNTIL` field) has a magnitude **strictly
126    /// greater than 24:00:00**. A non-fatal **warning** mirroring reference `zic`'s `noise`/`-v`-gated
127    /// `gethms` check (`zic.c`: `hh > HOURSPERDAY || (hh == HOURSPERDAY && (mm||ss))` → "values over 24
128    /// hours not handled by pre-2007 versions of zic"). Exactly `24:00:00` does **not** warn; the file
129    /// still compiles. zic-rs has no quiet mode → collects always; surfaced only under `--verbose`
130    /// (T15.5-remainder; closes the T14.4 ledger residual).
131    ValueOverTwentyFourHours,
132}
133
134/// The **layer** a diagnostic belongs to — the first axis of the T13 comparison method
135/// (`docs/zic-warning-parity.md`). Recorded as a machine-checkable accessor so the taxonomy cannot
136/// silently drift from the doc.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum DiagnosticLayer {
139    /// The byte/line stream is not well-formed `zic` text (NUL, overlong, unknown keyword, field count).
140    Lexical,
141    /// Lines are well-formed but don't assemble into a valid record graph (continuation/duplicate/…).
142    Structural,
143    /// A field/value is individually invalid or unrepresentable (month, day-rule, time, value, …).
144    Semantic,
145    /// Accepted, but a portability/obsolescence/client-compat hazard (`-v`-style warnings).
146    Warning,
147    /// Caused by *output materialization*, not the source text (path traversal, no-clobber, mode,
148    /// alias-map validation). A diagnostic, but **not** a source `zic -v` diagnostic (T13.5).
149    OperationalMaterialization,
150    /// Tool-level / oracle meta (e.g. a `compare` mismatch), not a source diagnostic.
151    Meta,
152}
153
154/// How verbose a run must be for a diagnostic to surface, mirroring reference `zic`'s `noise`/`-v`
155/// gating. **Per-diagnostic, not per-code** — `zic.c::checkabbr` gates "fewer than 3" behind `-v`
156/// while "too many"/"differs from POSIX" (same `ZIC018`/`ZIC019` family) are always-on, so the gating
157/// is set when the diagnostic is *emitted*, not by its code. The CLI filters on it (T13.6): quiet
158/// prints `AlwaysOn` only, `--verbose`/`-v` prints both — matching `zic` vs `zic -v`.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum DiagnosticVerbosity {
161    /// Surfaced regardless of verbosity (all errors; always-on warnings).
162    AlwaysOn,
163    /// Surfaced only in verbose mode (reference `zic -v`-gated, e.g. the "fewer than 3" abbreviation).
164    VerboseOnly,
165}
166
167/// The **source-location precision** a diagnostic currently carries (T13.5 policy → T13.6 made
168/// machine-checked). Locations are *not* all equal; this records the level each class actually
169/// populates today (a refinement target, not a claim of perfection — e.g. exact token columns are
170/// future work for several classes).
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum DiagnosticSpanPrecision {
173    /// File only, no meaningful line (a tool/oracle-level meta diagnostic).
174    FileOnly,
175    /// File + 1-based line.
176    Line,
177    /// File + line + a related-entity cross-reference location (e.g. duplicate-zone's original line).
178    RelatedEntityLocation,
179    /// An output/materialization path rather than a source location.
180    OperationalPath,
181}
182
183impl DiagnosticCode {
184    /// The stable string form, e.g. `ZIC001_UNSUPPORTED_DIRECTIVE`.
185    pub fn as_str(self) -> &'static str {
186        use DiagnosticCode::*;
187        match self {
188            UnsupportedDirective => "ZIC001_UNSUPPORTED_DIRECTIVE",
189            InvalidFieldCount => "ZIC002_INVALID_FIELD_COUNT",
190            InvalidMonth => "ZIC003_INVALID_MONTH",
191            AmbiguousNameAbbreviation => "ZIC004_AMBIGUOUS_NAME_ABBREVIATION",
192            InvalidDayRule => "ZIC005_INVALID_DAY_RULE",
193            InvalidTimeSuffix => "ZIC006_INVALID_TIME_SUFFIX",
194            UnsupportedYearType => "ZIC007_UNSUPPORTED_YEAR_TYPE",
195            OutputPathTraversal => "ZIC008_OUTPUT_PATH_TRAVERSAL",
196            TooManyTransitions => "ZIC009_TOO_MANY_TRANSITIONS",
197            UnsupportedLeapSeconds => "ZIC010_UNSUPPORTED_LEAP_SECONDS",
198            ReferenceZicMismatch => "ZIC011_REFERENCE_ZIC_MISMATCH",
199            InvalidValue => "ZIC012_INVALID_VALUE",
200            UnknownLineType => "ZIC013_UNKNOWN_LINE_TYPE",
201            ContinuationWithoutZone => "ZIC014_CONTINUATION_WITHOUT_ZONE",
202            DuplicateZone => "ZIC015_DUPLICATE_ZONE",
203            NulByteInInput => "ZIC016_NUL_INPUT_BYTE",
204            OverlongInputLine => "ZIC017_OVERLONG_INPUT_LINE",
205            AbbreviationPolicyViolation => "ZIC018_ABBREVIATION_POLICY_VIOLATION",
206            AbbreviationNotPosix => "ZIC019_ABBREVIATION_NOT_POSIX",
207            TooManyTransitionsForLegacyClient => "ZIC020_TOO_MANY_TRANSITIONS_FOR_LEGACY_CLIENT",
208            UnterminatedInputLine => "ZIC021_UNTERMINATED_INPUT_LINE",
209            UnterminatedQuote => "ZIC022_UNTERMINATED_QUOTE",
210            SimultaneousTransition => "ZIC023_SIMULTANEOUS_TRANSITION",
211            ZoneNameNonPortableByte => "ZIC024_ZONE_NAME_NONPORTABLE_BYTE",
212            ZoneNameOverlengthComponent => "ZIC025_ZONE_NAME_OVERLENGTH_COMPONENT",
213            ValueOverTwentyFourHours => "ZIC026_VALUE_OVER_24_HOURS",
214        }
215    }
216
217    /// The [`DiagnosticLayer`] this code belongs to (T13.5 — the comparison method's first axis). The
218    /// `match` is exhaustive, so adding a code without classifying it is a compile error — the taxonomy
219    /// stays in lock-step with `docs/zic-warning-parity.md`.
220    pub fn layer(self) -> DiagnosticLayer {
221        use DiagnosticCode::*;
222        use DiagnosticLayer as L;
223        match self {
224            // Lexical / admissibility — the byte/line stream isn't well-formed `zic` text.
225            InvalidFieldCount
226            | AmbiguousNameAbbreviation
227            | UnknownLineType
228            | NulByteInInput
229            | OverlongInputLine
230            | UnterminatedInputLine
231            | UnterminatedQuote => L::Lexical,
232            // Structural — well-formed lines that don't assemble into a valid record graph.
233            ContinuationWithoutZone | DuplicateZone => L::Structural,
234            // Semantic — an individually invalid/unrepresentable field or unsupported construct.
235            UnsupportedDirective
236            | InvalidMonth
237            | InvalidDayRule
238            | InvalidTimeSuffix
239            | UnsupportedYearType
240            | TooManyTransitions
241            | UnsupportedLeapSeconds
242            | InvalidValue
243            | SimultaneousTransition => L::Semantic,
244            // Warning — accepted but a portability/client-compat hazard.
245            AbbreviationPolicyViolation
246            | AbbreviationNotPosix
247            | TooManyTransitionsForLegacyClient
248            | ZoneNameNonPortableByte
249            | ZoneNameOverlengthComponent
250            | ValueOverTwentyFourHours => L::Warning,
251            // Operational — caused by output materialization, not the source text.
252            OutputPathTraversal => L::OperationalMaterialization,
253            // Meta — tool/oracle level, not a source diagnostic.
254            ReferenceZicMismatch => L::Meta,
255        }
256    }
257
258    /// The [`DiagnosticSpanPrecision`] this code currently populates (T13.5 audit, made machine-checked
259    /// in T13.6). Exhaustive — a new code must declare its precision or the build fails. *Records the
260    /// current level, not a perfection claim* (exact token columns are a future refinement for several
261    /// `Line` classes).
262    pub fn span_precision(self) -> DiagnosticSpanPrecision {
263        use DiagnosticCode::*;
264        use DiagnosticSpanPrecision as P;
265        match self {
266            // Carries a related-entity cross-reference (the original definition's line).
267            DuplicateZone => P::RelatedEntityLocation,
268            // An output/materialization path, not a source location.
269            OutputPathTraversal => P::OperationalPath,
270            // Tool/oracle meta — no specific source line.
271            ReferenceZicMismatch => P::FileOnly,
272            // Everything else is currently file + line (column/byte-offset = future refinement).
273            UnsupportedDirective
274            | InvalidFieldCount
275            | InvalidMonth
276            | AmbiguousNameAbbreviation
277            | InvalidDayRule
278            | InvalidTimeSuffix
279            | UnsupportedYearType
280            | TooManyTransitions
281            | UnsupportedLeapSeconds
282            | InvalidValue
283            | UnknownLineType
284            | ContinuationWithoutZone
285            | NulByteInInput
286            | OverlongInputLine
287            | AbbreviationPolicyViolation
288            | AbbreviationNotPosix
289            | TooManyTransitionsForLegacyClient
290            | UnterminatedInputLine
291            | UnterminatedQuote
292            | SimultaneousTransition
293            | ZoneNameNonPortableByte
294            | ZoneNameOverlengthComponent
295            | ValueOverTwentyFourHours => P::Line,
296        }
297    }
298
299    /// The **canonical severity** of this code (T13.6 — completes the per-code contract metadata so
300    /// every code carries `(layer, span_precision, default_severity)`; exhaustive → adding a code
301    /// forces a classification). The three non-fatal abbreviation/transition warnings are `Warning`;
302    /// every other code is a fail-closed `Error`. *Canonical, not absolute:* the `warn-and-skip`
303    /// policy may downgrade an emitted error-diagnostic to `Warning` at the instance level (see
304    /// `plan::run`), and **verbosity** is the one genuinely per-*instance* axis (a `Diagnostic` cannot
305    /// be constructed without a `severity` and a `verbosity`, so those are type-enforced on every
306    /// instance — see [`Diagnostic`]).
307    pub fn default_severity(self) -> Severity {
308        use DiagnosticCode::*;
309        match self {
310            AbbreviationPolicyViolation
311            | AbbreviationNotPosix
312            | TooManyTransitionsForLegacyClient
313            | ZoneNameNonPortableByte
314            | ZoneNameOverlengthComponent
315            | ValueOverTwentyFourHours => Severity::Warning,
316            _ => Severity::Error,
317        }
318    }
319}
320
321impl fmt::Display for DiagnosticCode {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        f.write_str(self.as_str())
324    }
325}
326
327/// A half-open column span `[start, end)` within a source line, 0-based by byte.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct Span {
330    pub start: usize,
331    pub end: usize,
332}
333
334/// A single located diagnostic.
335#[derive(Debug, Clone)]
336pub struct Diagnostic {
337    pub severity: Severity,
338    pub code: DiagnosticCode,
339    pub message: String,
340    pub file: PathBuf,
341    /// 1-based line number; 0 means "no specific line".
342    pub line: usize,
343    pub span: Option<Span>,
344    pub suggestion: Option<String>,
345    /// Verbosity gating (T13.5), set **per diagnostic** at emission — see [`DiagnosticVerbosity`].
346    /// Defaults to [`DiagnosticVerbosity::AlwaysOn`]; the few reference-`zic` `-v`-gated warnings (e.g.
347    /// the "fewer than 3 characters" abbreviation case) set [`DiagnosticVerbosity::VerboseOnly`] via
348    /// [`Diagnostic::verbose_only`]. (No filter is applied yet — zic-rs surfaces everything; the
349    /// default-vs-verbose split is T13.6. This field *records* the gating so it isn't lost.)
350    pub verbosity: DiagnosticVerbosity,
351}
352
353impl Diagnostic {
354    /// Construct an error-severity diagnostic.
355    pub fn error(
356        code: DiagnosticCode,
357        message: impl Into<String>,
358        file: &Path,
359        line: usize,
360    ) -> Self {
361        Diagnostic {
362            severity: Severity::Error,
363            code,
364            message: message.into(),
365            file: file.to_path_buf(),
366            line,
367            span: None,
368            suggestion: None,
369            verbosity: DiagnosticVerbosity::AlwaysOn,
370        }
371    }
372
373    /// Construct a warning-severity diagnostic.
374    pub fn warning(
375        code: DiagnosticCode,
376        message: impl Into<String>,
377        file: &Path,
378        line: usize,
379    ) -> Self {
380        Diagnostic {
381            severity: Severity::Warning,
382            ..Diagnostic::error(code, message, file, line)
383        }
384    }
385
386    /// Attach a column span (builder style).
387    pub fn with_span(mut self, start: usize, end: usize) -> Self {
388        self.span = Some(Span { start, end });
389        self
390    }
391
392    /// Attach a suggestion (builder style).
393    pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
394        self.suggestion = Some(s.into());
395        self
396    }
397
398    /// Mark this diagnostic as **verbose-only** (T13.5) — surfaced only in a future `-v`/verbose mode,
399    /// mirroring reference `zic`'s `noise`-gated warnings (e.g. the "fewer than 3 characters"
400    /// abbreviation case). Builder style; default is [`DiagnosticVerbosity::AlwaysOn`].
401    pub fn verbose_only(mut self) -> Self {
402        self.verbosity = DiagnosticVerbosity::VerboseOnly;
403        self
404    }
405}
406
407impl fmt::Display for Diagnostic {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        // `file:line: severity[CODE]: message` — a familiar, greppable shape.
410        let loc = if self.line > 0 {
411            format!("{}:{}", self.file.display(), self.line)
412        } else {
413            self.file.display().to_string()
414        };
415        write!(
416            f,
417            "{loc}: {}[{}]: {}",
418            self.severity, self.code, self.message
419        )?;
420        if let Some(s) = &self.suggestion {
421            write!(f, " (suggestion: {s})")?;
422        }
423        Ok(())
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::path::Path;
431
432    // T13.5 — the diagnostic-contract metadata. These guard the taxonomy against silent drift from
433    // `docs/zic-warning-parity.md`.
434
435    #[test]
436    fn layer_maps_each_code_to_its_taxonomy_layer() {
437        use DiagnosticCode as C;
438        use DiagnosticLayer as L;
439        assert_eq!(C::NulByteInInput.layer(), L::Lexical);
440        assert_eq!(C::OverlongInputLine.layer(), L::Lexical);
441        assert_eq!(C::UnknownLineType.layer(), L::Lexical);
442        assert_eq!(C::InvalidFieldCount.layer(), L::Lexical);
443        assert_eq!(C::ContinuationWithoutZone.layer(), L::Structural);
444        assert_eq!(C::DuplicateZone.layer(), L::Structural);
445        assert_eq!(C::InvalidMonth.layer(), L::Semantic);
446        assert_eq!(C::UnsupportedDirective.layer(), L::Semantic);
447        assert_eq!(C::AbbreviationPolicyViolation.layer(), L::Warning);
448        assert_eq!(C::AbbreviationNotPosix.layer(), L::Warning);
449        assert_eq!(C::TooManyTransitionsForLegacyClient.layer(), L::Warning);
450        // Operational/materialization diagnostics are NOT source `zic -v` diagnostics (T13.5).
451        assert_eq!(
452            C::OutputPathTraversal.layer(),
453            L::OperationalMaterialization
454        );
455        assert_eq!(C::ReferenceZicMismatch.layer(), L::Meta);
456    }
457
458    #[test]
459    fn diagnostic_verbosity_defaults_always_on_and_builder_sets_verbose_only() {
460        let p = Path::new("t.zi");
461        let d = Diagnostic::warning(DiagnosticCode::AbbreviationPolicyViolation, "m", p, 1);
462        assert_eq!(d.verbosity, DiagnosticVerbosity::AlwaysOn);
463        let v = d.verbose_only();
464        assert_eq!(v.verbosity, DiagnosticVerbosity::VerboseOnly);
465    }
466
467    #[test]
468    fn zic020_transition_count_code_is_stable() {
469        assert_eq!(
470            DiagnosticCode::TooManyTransitionsForLegacyClient.as_str(),
471            "ZIC020_TOO_MANY_TRANSITIONS_FOR_LEGACY_CLIENT"
472        );
473    }
474
475    /// Every code carries the contract metadata `(as_str, layer, span_precision)` and the codes are
476    /// distinct & append-ordered (`ZIC001`…`ZIC020`). This is the machine-enforced contract: the
477    /// `match`es in `layer()`/`span_precision()` are exhaustive, so adding a code without classifying
478    /// it is a compile error; this test additionally pins ordering/uniqueness (T13.6 stability policy).
479    #[test]
480    fn every_code_has_contract_metadata_and_codes_are_append_ordered() {
481        use DiagnosticCode::*;
482        const ALL: &[DiagnosticCode] = &[
483            UnsupportedDirective,
484            InvalidFieldCount,
485            InvalidMonth,
486            AmbiguousNameAbbreviation,
487            InvalidDayRule,
488            InvalidTimeSuffix,
489            UnsupportedYearType,
490            OutputPathTraversal,
491            TooManyTransitions,
492            UnsupportedLeapSeconds,
493            ReferenceZicMismatch,
494            InvalidValue,
495            UnknownLineType,
496            ContinuationWithoutZone,
497            DuplicateZone,
498            NulByteInInput,
499            OverlongInputLine,
500            AbbreviationPolicyViolation,
501            AbbreviationNotPosix,
502            TooManyTransitionsForLegacyClient,
503            UnterminatedInputLine,
504            UnterminatedQuote,
505            SimultaneousTransition,
506            ZoneNameNonPortableByte,
507            ZoneNameOverlengthComponent,
508            ValueOverTwentyFourHours,
509        ];
510        let mut seen = std::collections::BTreeSet::new();
511        for (i, code) in ALL.iter().enumerate() {
512            let s = code.as_str();
513            // Append-ordered numbering: the Nth code is `ZIC{N+1:03}_…`.
514            assert!(
515                s.starts_with(&format!("ZIC{:03}_", i + 1)),
516                "code {i} = {s} is not append-ordered"
517            );
518            assert!(seen.insert(s), "duplicate code string {s}");
519            // Per-code contract metadata is total — these would not compile if a variant were
520            // unclassified (layer/span/severity are exhaustive `match`es). Verbosity is the one
521            // per-instance axis, type-enforced as a required `Diagnostic` field.
522            let _ = code.layer();
523            let _ = code.span_precision();
524            let _ = code.default_severity();
525        }
526        assert_eq!(seen.len(), 26, "expected ZIC001–ZIC026");
527        // The three non-fatal warning classes; everything else is a fail-closed error.
528        assert_eq!(
529            AbbreviationPolicyViolation.default_severity(),
530            Severity::Warning
531        );
532        assert_eq!(AbbreviationNotPosix.default_severity(), Severity::Warning);
533        assert_eq!(
534            TooManyTransitionsForLegacyClient.default_severity(),
535            Severity::Warning
536        );
537        assert_eq!(UnknownLineType.default_severity(), Severity::Error);
538    }
539
540    #[test]
541    fn span_precision_classifies_related_entity_and_operational() {
542        use DiagnosticCode as C;
543        use DiagnosticSpanPrecision as P;
544        assert_eq!(C::DuplicateZone.span_precision(), P::RelatedEntityLocation);
545        assert_eq!(C::OutputPathTraversal.span_precision(), P::OperationalPath);
546        assert_eq!(C::ReferenceZicMismatch.span_precision(), P::FileOnly);
547        assert_eq!(C::UnknownLineType.span_precision(), P::Line);
548    }
549}