Skip to main content

tzcompile/source/
parser.rs

1//! The parser: lexed [`Line`]s → a typed [`Database`] of `Rule`/`Zone`/`Link` records.
2//!
3//! The parser is deliberately *complete* with respect to the source grammar — it parses
4//! rules, multi-era zones with `UNTIL` continuations, and links — independently of what the
5//! compiler can currently turn into TZif. Keeping the parser general means:
6//!
7//! * we validate the whole file the way `zic` does (so we don't silently ignore typos in
8//!   rules a selected zone happens not to use); and
9//! * the compiler inherits a stable, complete front-end as its supported subset grows.
10//!
11//! The *unsupported-construct* decision lives in `compile`, not here: the parser's job is to
12//! represent the source faithfully; the compiler decides what it can turn into TZif correctly
13//! (and fails closed otherwise). Record keywords are matched as `zic`-style unambiguous
14//! prefixes (see the `record_keyword` helper), so the parser reads both the canonical
15//! `Rule`/`Zone`/`Link` spelling **and** the zishrink-abbreviated `R`/`Z`/`L` form used by the
16//! installed single-file `tzdata.zi` — i.e. zic-rs can parse the real installed source directly.
17//!
18//! ## Zone continuation handling (the one piece of cross-line state)
19//!
20//! A `Zone` line whose era ends with an `UNTIL` is continued by the following line(s),
21//! which omit the `Zone` keyword and the name. A line is a continuation iff the previous
22//! era carried an `UNTIL`. We track exactly that with `expect_continuation`.
23
24use std::path::Path;
25
26use crate::diagnostics::{Diagnostic, DiagnosticCode};
27use crate::error::{Error, Result};
28use crate::model::calendar::OnDay;
29use crate::model::time::{parse_offset, parse_save, parse_time_of_day};
30use crate::model::{
31    Database, LinkRecord, Origin, RuleRecord, Until, YearBound, ZoneEra, ZoneRecord, ZoneRules,
32};
33
34use super::lexer::tokenize;
35use super::names;
36use super::records::Line;
37
38/// Parse a whole source file's bytes into `db`, appending its records.
39pub fn parse_into(bytes: &[u8], file: &Path, db: &mut Database) -> Result<()> {
40    let lines = tokenize(bytes, file)?;
41
42    // Duplicate-zone detection (T13.2): a name → first-definition-line map. Seeded from any zones
43    // already in `db` (prior files) with line 0 ("earlier, line unknown") so cross-file duplicates are
44    // caught the same as within-file ones — matching reference `zic`'s single-namespace "duplicate zone
45    // name N" error. Built once (O(n)); checked/updated per `Zone`.
46    let mut seen_zones: std::collections::HashMap<String, usize> =
47        db.zones.iter().map(|z| (z.name.clone(), 0usize)).collect();
48
49    let mut expect_continuation = false;
50    for line in &lines {
51        if expect_continuation {
52            // The previous era ended with UNTIL; this line continues that zone.
53            let era = parse_era(&line.fields, 0, file, line)?;
54            expect_continuation = era.until.is_some();
55            let zone = db
56                .zones
57                .last_mut()
58                .expect("continuation without an open zone is impossible by construction");
59            zone.eras.push(era);
60            continue;
61        }
62
63        let keyword = line.keyword().unwrap_or("");
64        match record_keyword(keyword) {
65            Some(RecordKind::Rule) => {
66                let r = parse_rule(line, file)?;
67                db.rules.entry(r.name.clone()).or_default().push(r);
68            }
69            Some(RecordKind::Zone) => {
70                let (zone, more) = parse_zone(line, file)?;
71                // Reject a second definition of the same zone name (T13.2 — structural). zic-rs
72                // records the original line when it was in this file (`> 0`); a `0` means an earlier
73                // file, where the precise line is not tracked.
74                if let Some(&orig) = seen_zones.get(&zone.name) {
75                    let where_orig = if orig > 0 {
76                        format!(" (originally defined at line {orig})")
77                    } else {
78                        " (originally defined in an earlier input)".to_string()
79                    };
80                    return Err(diag(
81                        DiagnosticCode::DuplicateZone,
82                        format!("duplicate zone name {:?}{where_orig}", zone.name),
83                        file,
84                        line,
85                    ));
86                }
87                seen_zones.insert(zone.name.clone(), line.number);
88                expect_continuation = more;
89                db.zones.push(zone);
90            }
91            Some(RecordKind::Link) => {
92                db.links.push(parse_link(line, file)?);
93            }
94            None => {
95                // `record_keyword == None` — the first token is not `Rule`/`Zone`/`Link`. Split by
96                // column (T13.2): an **indented** line (col > 0) is continuation-shaped but no zone is
97                // open to continue it (`ContinuationWithoutZone`); a line in **command position**
98                // (col 0) is an unrecognised keyword (`UnknownLineType`). Reference `zic` folds both
99                // into "input line of unknown type"; the continuation refinement is an intentional,
100                // documented divergence (see `docs/zic-warning-parity.md`).
101                let indented = line.fields.first().is_some_and(|f| f.col > 0);
102                let (code, msg) = if indented {
103                    (
104                        DiagnosticCode::ContinuationWithoutZone,
105                        format!("continuation line {keyword:?} has no open zone to continue"),
106                    )
107                } else {
108                    (
109                        DiagnosticCode::UnknownLineType,
110                        format!("input line of unknown type beginning with {keyword:?}"),
111                    )
112                };
113                return Err(diag(code, msg, file, line));
114            }
115        }
116    }
117
118    if expect_continuation {
119        // A zone whose final era had UNTIL but no continuation is malformed.
120        let last = db.zones.last();
121        let name = last.map(|z| z.name.as_str()).unwrap_or("<zone>");
122        return Err(Error::message(format!(
123            "zone {name:?} ends with an UNTIL but has no continuation line"
124        )));
125    }
126    Ok(())
127}
128
129/// The three record kinds zic's main-file keyword table recognises.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum RecordKind {
132    Rule,
133    Zone,
134    Link,
135}
136
137/// Classify a line's leading keyword, accepting any **unambiguous case-insensitive prefix** of
138/// `Rule` / `Zone` / `Link` — exactly as reference `zic` does.
139///
140/// `zic`'s `byword` matches a keyword against its table by exact match or unambiguous prefix.
141/// The main zone-file table is `{Rule, Zone, Link}` only: `Leap`/`Expires` are recognised
142/// solely in a *leapseconds* file, so in a zone file `Leap` is "input line of unknown type"
143/// (verified against reference `zic` 2026b) and a bare `L` is therefore the **unambiguous**
144/// abbreviation of `Link`. Because the three words share no common prefix, every non-empty
145/// prefix maps to at most one — so this never returns an ambiguous match for them.
146///
147/// This is what lets zic-rs read the **zishrink-abbreviated** single-file `tzdata.zi` directly
148/// (it uses `R`/`Z`/`L` for the ~2700 records). The month/weekday/year tokens are already
149/// accepted as `zic`-style prefixes by [`crate::source::names`], so no other de-abbreviation is
150/// needed. (`Leap`/`Expires` remain unrecognised here, matching `zic`; leap seconds are out of
151/// scope — see `docs/unsupported-syntax.md`.)
152fn record_keyword(word: &str) -> Option<RecordKind> {
153    if word.is_empty() {
154        return None;
155    }
156    let needle = word.to_ascii_lowercase();
157    const TABLE: [(&str, RecordKind); 3] = [
158        ("rule", RecordKind::Rule),
159        ("zone", RecordKind::Zone),
160        ("link", RecordKind::Link),
161    ];
162    let mut found = None;
163    for (full, kind) in TABLE {
164        if full.starts_with(&needle) {
165            if found.is_some() {
166                return None; // ambiguous prefix (unreachable for Rule/Zone/Link)
167            }
168            found = Some(kind);
169        }
170    }
171    found
172}
173
174/// Build a located error diagnostic for `line`.
175fn diag(code: DiagnosticCode, msg: impl Into<String>, file: &Path, line: &Line) -> Error {
176    Error::from(Diagnostic::error(code, msg, file, line.number))
177}
178
179/// Turn a `(code, message)` field-level failure into a located error.
180fn field_err(e: (DiagnosticCode, String), file: &Path, line: &Line, col: usize) -> Error {
181    Error::from(Diagnostic::error(e.0, e.1, file, line.number).with_span(col, col))
182}
183
184// ---- Rule -----------------------------------------------------------------------------
185
186/// Parse a `Rule NAME FROM TO TYPE IN ON AT SAVE LETTER` line (10 fields).
187fn parse_rule(line: &Line, file: &Path) -> Result<RuleRecord> {
188    let f = &line.fields;
189    if f.len() != 10 {
190        return Err(diag(
191            DiagnosticCode::InvalidFieldCount,
192            format!("Rule line needs 10 fields, found {}", f.len()),
193            file,
194            line,
195        ));
196    }
197    let name = f[1].text.clone();
198    let from = parse_year(&f[2].text).map_err(|e| field_err(e, file, line, f[2].col))?;
199    let to = parse_to_year(&f[3].text, from).map_err(|e| field_err(e, file, line, f[3].col))?;
200    // f[4] is the reserved TYPE field; `zic` says it "should always contain '-'". We accept
201    // '-' and warn-tolerate anything else by ignoring it (kept for forward-compat).
202    let in_month = names::month(&f[5].text).map_err(|e| field_err(e, file, line, f[5].col))?;
203    let on = parse_on_day(&f[6].text).map_err(|e| field_err(e, file, line, f[6].col))?;
204    let at = parse_time_of_day(&f[7].text).map_err(|e| field_err(e, file, line, f[7].col))?;
205    let save = parse_save(&f[8].text).map_err(|e| field_err(e, file, line, f[8].col))?;
206    // LETTER: '-' denotes the empty variable part.
207    let letter = if f[9].text == "-" {
208        String::new()
209    } else {
210        f[9].text.clone()
211    };
212
213    Ok(RuleRecord {
214        name,
215        from,
216        to,
217        in_month,
218        on,
219        at,
220        save,
221        letter,
222        origin: Origin::new(file, line.number),
223    })
224}
225
226/// Parse a `FROM` year: an integer, or the keywords `minimum`/`maximum`.
227///
228/// `minimum`/`maximum` are matched as `zic`-style **unambiguous case-insensitive prefixes**, so
229/// a bare `m` is ambiguous (errors), `mi` is `minimum`, `ma` is `maximum`.
230///
231/// **`FROM = minimum` is obsolete.** Reference `zic` (2026b) emits *"FROM year 'minimum' is
232/// obsolete; treated as 1900"* and coerces it to the year **1900** — it does **not** expand into
233/// an unbounded past. zic-rs follows the same finite-1900 semantics. (tzdata 2026b uses it zero
234/// times; it exists only for legacy-source compatibility. Surfacing the obsolescence as a
235/// compile-time *warning* is a tracked follow-up — the compile path has no warning sink yet; see
236/// `docs/reference-zic-semantics.md`.)
237fn parse_year(s: &str) -> std::result::Result<i32, (DiagnosticCode, String)> {
238    if s.is_empty() {
239        return Err((
240            DiagnosticCode::UnsupportedYearType,
241            "empty year".to_string(),
242        ));
243    }
244    let low = s.to_ascii_lowercase();
245    let is_min = "minimum".starts_with(&low);
246    let is_max = "maximum".starts_with(&low);
247    match (is_min, is_max) {
248        (true, true) => Err((
249            DiagnosticCode::UnsupportedYearType,
250            format!("ambiguous year {s:?} — write `min`/`minimum` or `max`/`maximum`"),
251        )),
252        (true, false) => Ok(1900), // obsolete; coerced to 1900, exactly as reference `zic`
253        (false, true) => Ok(i32::MAX),
254        (false, false) => s.parse::<i32>().map_err(|_| {
255            (
256                DiagnosticCode::UnsupportedYearType,
257                format!("invalid year {s:?}"),
258            )
259        }),
260    }
261}
262
263/// Parse a `TO` year: an integer, `only` (== `from`), or the keywords `minimum`/`maximum`,
264/// each accepted as a `zic`-style unambiguous case-insensitive prefix (`o`/`on`→`only`,
265/// `mi`→`minimum`, `ma`→`maximum`; a bare `m` is ambiguous and rejected). `minimum` is the
266/// obsolete spelling coerced to 1900 (see [`parse_year`]); `maximum` is the open-ended tail.
267fn parse_to_year(s: &str, from: i32) -> std::result::Result<YearBound, (DiagnosticCode, String)> {
268    if s.is_empty() {
269        return Err((
270            DiagnosticCode::UnsupportedYearType,
271            "empty year".to_string(),
272        ));
273    }
274    let low = s.to_ascii_lowercase();
275    if "only".starts_with(&low) {
276        // `only`/`minimum`/`maximum` share no first letter, so `only`'s prefixes are exclusive.
277        return Ok(YearBound::Year(from));
278    }
279    let is_min = "minimum".starts_with(&low);
280    let is_max = "maximum".starts_with(&low);
281    match (is_min, is_max) {
282        (true, true) => Err((
283            DiagnosticCode::UnsupportedYearType,
284            format!("ambiguous year {s:?} — write `min`/`minimum` or `max`/`maximum`"),
285        )),
286        (true, false) => Ok(YearBound::Year(1900)), // obsolete; coerced to 1900, as reference `zic`
287        (false, true) => Ok(YearBound::Max),
288        (false, false) => s.parse::<i32>().map(YearBound::Year).map_err(|_| {
289            (
290                DiagnosticCode::UnsupportedYearType,
291                format!("invalid year {s:?}"),
292            )
293        }),
294    }
295}
296
297/// Parse an `ON` day spec: `5`, `lastSun`, `Sun>=8`, `Sun<=25`.
298fn parse_on_day(s: &str) -> std::result::Result<OnDay, (DiagnosticCode, String)> {
299    // Numeric day-of-month.
300    if let Ok(d) = s.parse::<u8>() {
301        if (1..=31).contains(&d) {
302            return Ok(OnDay::Day(d));
303        }
304        return Err((
305            DiagnosticCode::InvalidDayRule,
306            format!("day {d} out of range"),
307        ));
308    }
309    // `lastWeekday`.
310    if let Some(rest) = strip_prefix_ci(s, "last") {
311        let wd = names::weekday(rest)?;
312        return Ok(OnDay::Last(wd));
313    }
314    // `Weekday>=N` / `Weekday<=N`.
315    if let Some((wd_str, n)) = s.split_once(">=") {
316        let wd = names::weekday(wd_str)?;
317        let n = parse_dom(n)?;
318        return Ok(OnDay::OnAfter(wd, n));
319    }
320    if let Some((wd_str, n)) = s.split_once("<=") {
321        let wd = names::weekday(wd_str)?;
322        let n = parse_dom(n)?;
323        return Ok(OnDay::OnBefore(wd, n));
324    }
325    Err((
326        DiagnosticCode::InvalidDayRule,
327        format!("unrecognised ON day spec {s:?}"),
328    ))
329}
330
331fn parse_dom(s: &str) -> std::result::Result<u8, (DiagnosticCode, String)> {
332    s.parse::<u8>()
333        .ok()
334        .filter(|d| (1..=31).contains(d))
335        .ok_or_else(|| {
336            (
337                DiagnosticCode::InvalidDayRule,
338                format!("invalid day-of-month {s:?}"),
339            )
340        })
341}
342
343/// Case-insensitive prefix strip (returns the suffix if `s` begins with `prefix`).
344fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
345    if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
346        Some(&s[prefix.len()..])
347    } else {
348        None
349    }
350}
351
352// ---- Zone -----------------------------------------------------------------------------
353
354/// Parse a `Zone NAME ...era...` line. Returns the zone (with its first era) and whether a
355/// continuation line is expected (i.e. the first era ended with `UNTIL`).
356fn parse_zone(line: &Line, file: &Path) -> Result<(ZoneRecord, bool)> {
357    let f = &line.fields;
358    // `Zone` + NAME + at least STDOFF RULES FORMAT.
359    if f.len() < 5 {
360        return Err(diag(
361            DiagnosticCode::InvalidFieldCount,
362            format!("Zone line needs at least 5 fields, found {}", f.len()),
363            file,
364            line,
365        ));
366    }
367    let name = f[1].text.clone();
368    let era = parse_era(f, 2, file, line)?;
369    let more = era.until.is_some();
370    let zone = ZoneRecord {
371        name,
372        eras: vec![era],
373        origin: Origin::new(file, line.number),
374    };
375    Ok((zone, more))
376}
377
378/// Parse one era from `fields[start..]`: `STDOFF RULES FORMAT [UNTIL...]`.
379fn parse_era(
380    fields: &[super::records::Field],
381    start: usize,
382    file: &Path,
383    line: &Line,
384) -> Result<ZoneEra> {
385    let era = &fields[start..];
386    if era.len() < 3 {
387        return Err(diag(
388            DiagnosticCode::InvalidFieldCount,
389            "zone era needs STDOFF, RULES and FORMAT",
390            file,
391            line,
392        ));
393    }
394    let stdoff = parse_offset(&era[0].text).map_err(|e| field_err(e, file, line, era[0].col))?;
395    let rules =
396        parse_rules_field(&era[1].text).map_err(|e| field_err(e, file, line, era[1].col))?;
397    let format = era[2].text.clone();
398    let until = if era.len() > 3 {
399        Some(parse_until(&era[3..], file, line)?)
400    } else {
401        None
402    };
403    Ok(ZoneEra {
404        stdoff,
405        rules,
406        format,
407        until,
408        origin: Origin::new(file, line.number),
409    })
410}
411
412/// Parse the `RULES` column: `-`, an inline saving like `1:00`, or a named ruleset.
413fn parse_rules_field(s: &str) -> std::result::Result<ZoneRules, (DiagnosticCode, String)> {
414    if s == "-" {
415        return Ok(ZoneRules::None);
416    }
417    // An inline amount looks like a time: starts with a digit or sign and has a digit.
418    let looks_like_time = s
419        .chars()
420        .next()
421        .map(|c| c.is_ascii_digit() || c == '-' || c == '+')
422        .unwrap_or(false)
423        && s.chars().any(|c| c.is_ascii_digit());
424    if looks_like_time {
425        return parse_save(s).map(ZoneRules::Save);
426    }
427    Ok(ZoneRules::Named(s.to_string()))
428}
429
430/// Parse an `UNTIL` field: `YEAR [MONTH [DAY [TIME]]]`. Omitted parts default to the
431/// earliest possible value (`zic`: month → January, day → 1, time → 00:00 wall).
432fn parse_until(fields: &[super::records::Field], file: &Path, line: &Line) -> Result<Until> {
433    let year = fields[0].text.parse::<i32>().map_err(|_| {
434        diag(
435            DiagnosticCode::InvalidValue,
436            "invalid UNTIL year",
437            file,
438            line,
439        )
440    })?;
441    let month = match fields.get(1) {
442        Some(m) => names::month(&m.text).map_err(|e| field_err(e, file, line, m.col))?,
443        None => 1,
444    };
445    let day = match fields.get(2) {
446        Some(d) => parse_on_day(&d.text).map_err(|e| field_err(e, file, line, d.col))?,
447        None => OnDay::Day(1),
448    };
449    let time = match fields.get(3) {
450        Some(t) => parse_time_of_day(&t.text).map_err(|e| field_err(e, file, line, t.col))?,
451        None => crate::model::TimeOfDay::zero(),
452    };
453    Ok(Until {
454        year,
455        month,
456        day,
457        time,
458    })
459}
460
461// ---- Link -----------------------------------------------------------------------------
462
463/// Parse a `Link TARGET LINK-NAME` line.
464fn parse_link(line: &Line, file: &Path) -> Result<LinkRecord> {
465    let f = &line.fields;
466    if f.len() != 3 {
467        return Err(diag(
468            DiagnosticCode::InvalidFieldCount,
469            format!("Link line needs 3 fields, found {}", f.len()),
470            file,
471            line,
472        ));
473    }
474    Ok(LinkRecord {
475        target: f[1].text.clone(),
476        link_name: f[2].text.clone(),
477        origin: Origin::new(file, line.number),
478    })
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::model::calendar::Weekday;
485    use std::path::PathBuf;
486
487    fn parse(s: &str) -> Result<Database> {
488        let mut db = Database::default();
489        parse_into(s.as_bytes(), &PathBuf::from("t.zi"), &mut db)?;
490        Ok(db)
491    }
492
493    #[test]
494    fn fixed_zone_and_link() {
495        let db = parse("Zone Etc/UTC 0 - UTC\nLink Etc/UTC UTC\n").unwrap();
496        assert_eq!(db.zones.len(), 1);
497        assert_eq!(db.zones[0].name, "Etc/UTC");
498        assert_eq!(db.zones[0].eras.len(), 1);
499        assert_eq!(db.zones[0].eras[0].rules, ZoneRules::None);
500        assert_eq!(db.links.len(), 1);
501        assert_eq!(db.links[0].target, "Etc/UTC");
502        assert_eq!(db.links[0].link_name, "UTC");
503    }
504
505    #[test]
506    fn rule_round_trip() {
507        let db = parse("Rule X 2020 only - Mar Sun>=8 2:00 1:00 D\n").unwrap();
508        let r = &db.rules["X"][0];
509        assert_eq!(r.from, 2020);
510        assert_eq!(r.to, YearBound::Year(2020));
511        assert_eq!(r.in_month, 3);
512        assert_eq!(r.on, OnDay::OnAfter(Weekday::Sun, 8));
513        assert_eq!(r.save.seconds, 3600);
514        assert!(r.save.is_dst);
515        assert_eq!(r.letter, "D");
516    }
517
518    #[test]
519    fn multi_era_zone_with_until() {
520        // Two eras: the first ends at an UNTIL, the second is open.
521        let src = "Zone T/Z -5:00 - EST 1970\n\t\t-6:00 - CST\n";
522        let db = parse(src).unwrap();
523        assert_eq!(db.zones.len(), 1);
524        assert_eq!(db.zones[0].eras.len(), 2);
525        assert!(db.zones[0].eras[0].until.is_some());
526        assert!(db.zones[0].eras[1].until.is_none());
527    }
528
529    #[test]
530    fn wrong_field_count_is_diagnostic() {
531        let e = parse("Link only-one-field\n").unwrap_err();
532        assert_eq!(
533            e.diagnostic().map(|d| d.code),
534            Some(DiagnosticCode::InvalidFieldCount)
535        );
536    }
537
538    #[test]
539    fn dangling_until_errors() {
540        assert!(parse("Zone T/Z -5:00 - EST 1970\n").is_err());
541    }
542
543    #[test]
544    fn record_keyword_matches_zic_style_unambiguous_prefixes() {
545        // Full words and any unambiguous prefix, case-insensitively.
546        for w in ["Rule", "rule", "RULE", "Rul", "Ru", "R", "r"] {
547            assert_eq!(record_keyword(w), Some(RecordKind::Rule), "{w:?}");
548        }
549        for w in ["Zone", "zone", "Zon", "Zo", "Z", "z"] {
550            assert_eq!(record_keyword(w), Some(RecordKind::Zone), "{w:?}");
551        }
552        for w in ["Link", "link", "Lin", "Li", "L", "l"] {
553            assert_eq!(record_keyword(w), Some(RecordKind::Link), "{w:?}");
554        }
555        // `Leap`/`Expires` are NOT in the zone-file table (verified against reference `zic`):
556        // a zone file's `Leap` is "input line of unknown type". So they map to None here.
557        for w in [
558            "", "Leap", "Le", "Expires", "Ex", "X", "Rules", "Zonely", "q",
559        ] {
560            assert_eq!(record_keyword(w), None, "{w:?}");
561        }
562    }
563
564    #[test]
565    fn parses_zishrink_abbreviated_record_keys() {
566        // The form used by the installed single-file `tzdata.zi`: `R`/`Z`/`L`.
567        let db = parse("R X 2020 o - Mar Sun>=8 2:00 1:00 D\nZ Etc/UTC 0 - UTC\nL Etc/UTC UTC\n")
568            .unwrap();
569        assert_eq!(db.rules["X"][0].from, 2020);
570        assert_eq!(db.zones.len(), 1);
571        assert_eq!(db.zones[0].name, "Etc/UTC");
572        assert_eq!(db.links[0].link_name, "UTC");
573    }
574
575    #[test]
576    fn year_keyword_prefixes_match_zic_style() {
577        // FROM keywords: any unambiguous prefix of minimum/maximum.
578        for w in ["minimum", "minimu", "min", "mi"] {
579            assert_eq!(parse_year(w), Ok(1900), "{w:?}"); // obsolete -> coerced to 1900
580        }
581        for w in ["maximum", "max", "ma"] {
582            assert_eq!(parse_year(w), Ok(i32::MAX), "{w:?}");
583        }
584        assert_eq!(parse_year("1916"), Ok(1916));
585        // TO keywords: only/minimum/maximum prefixes.
586        for w in ["only", "onl", "on", "o"] {
587            assert_eq!(parse_to_year(w, 1916), Ok(YearBound::Year(1916)), "{w:?}");
588        }
589        assert_eq!(parse_to_year("ma", 1916), Ok(YearBound::Max));
590        assert_eq!(parse_to_year("mi", 1916), Ok(YearBound::Year(1900)));
591    }
592
593    #[test]
594    fn bare_m_year_keyword_is_ambiguous() {
595        // `m` could be minimum or maximum — reject, don't silently pick one.
596        assert!(parse_year("m").is_err());
597        assert!(parse_to_year("m", 2000).is_err());
598    }
599
600    #[test]
601    fn from_minimum_is_lowered_to_1900() {
602        // Obsolete `minimum` is coerced to a finite 1900, never an infinite-past sentinel.
603        let db = parse("Rule X minimum max - Apr lastSun 2:00 1:00 D\n").unwrap();
604        assert_eq!(db.rules["X"][0].from, 1900);
605        assert_eq!(db.rules["X"][0].to, YearBound::Max);
606        assert_ne!(db.rules["X"][0].from, i32::MIN);
607    }
608
609    #[test]
610    fn leap_line_rejected_in_main_source_context() {
611        // `Leap`/`Expires` are NOT part of the zone-file keyword table (reference `zic` reports
612        // "input line of unknown type"). zic-rs matches that class precisely (T13.2): a `Leap` line in
613        // command position is `UnknownLineType`, not the (recognised-but-unsupported) `UnsupportedDirective`.
614        let e = parse("Leap 2016 Dec 31 23:59:60 + S\n").unwrap_err();
615        assert_eq!(
616            e.diagnostic().map(|d| d.code),
617            Some(DiagnosticCode::UnknownLineType)
618        );
619    }
620}