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}