Skip to main content

tzcompile/compare/
semantic.rs

1//! Semantic comparison of two TZif files.
2//!
3//! Byte-equality is too strict a contract in general: `zic`'s slim heuristics, type
4//! ordering, and de-duplication can differ from ours while the *meaning* is identical. So
5//! the binding contract is semantic — do the two files describe the same local time for
6//! every instant? We approximate that faithfully by comparing:
7//!
8//! * the **footer** POSIX `TZ` string (governs the open-ended future); and
9//! * the **transition timeline**: the same number of transitions, at the same UT instants,
10//!   each switching to a local-time-type with the same `(utoff, is_dst, abbreviation)`;
11//! * the **initial** local-time-type effective before the first transition.
12//!
13//! To compare initial types consistently we apply the standard reader convention to *both*
14//! files: with no transitions, use type 0; otherwise use the first non-DST type (falling
15//! back to type 0). Applying one rule to both sides makes the comparison fair.
16
17use crate::tzif::{LocalTimeType, ParsedTzif};
18
19/// A single semantic difference, rendered for humans.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Difference {
22    pub what: String,
23    pub ours: String,
24    pub theirs: String,
25}
26
27/// The effective behaviour of a local-time-type, abstracted away from its storage index.
28type Eff = (i32, bool, String);
29
30fn eff(t: &LocalTimeType) -> Eff {
31    (t.utoff, t.is_dst, t.abbr.clone())
32}
33
34/// The local-time-type effective before the first transition, per reader convention.
35fn initial_type(p: &ParsedTzif) -> Eff {
36    if p.transitions.is_empty() {
37        return eff(&p.types[0]);
38    }
39    let idx = p.types.iter().position(|t| !t.is_dst).unwrap_or(0);
40    eff(&p.types[idx])
41}
42
43/// The effective `(utoff, is_dst, abbreviation)` **at UT instant `t`**, by the standard TZif reader
44/// rule: the type of the latest transition with `at <= t`, or the `initial_type` before the first
45/// transition. (The open-ended future beyond the last explicit transition is governed by the POSIX
46/// footer, which this does *not* evaluate — probe instants for semantic witnesses are chosen within the
47/// explicit range, or the caller treats post-footer instants as `NotApplicable`.) Reused by T15.3
48/// semantic witnesses so both zic-rs and the reference are read by one identical rule.
49pub fn effective_at(p: &ParsedTzif, t: i64) -> (i32, bool, String) {
50    match p.transitions.iter().rev().find(|tr| tr.at <= t) {
51        Some(tr) => eff(&p.types[tr.type_index as usize]),
52        None => initial_type(p),
53    }
54}
55
56/// Compare two parsed TZif files; an empty result means "semantically identical".
57pub fn diff(ours: &ParsedTzif, theirs: &ParsedTzif) -> Vec<Difference> {
58    let mut diffs = Vec::new();
59
60    if ours.footer != theirs.footer {
61        diffs.push(Difference {
62            what: "footer (POSIX TZ)".into(),
63            ours: ours.footer.clone(),
64            theirs: theirs.footer.clone(),
65        });
66    }
67
68    let oi = initial_type(ours);
69    let ti = initial_type(theirs);
70    if oi != ti {
71        diffs.push(Difference {
72            what: "initial local-time-type".into(),
73            ours: format!("{oi:?}"),
74            theirs: format!("{ti:?}"),
75        });
76    }
77
78    if ours.transitions.len() != theirs.transitions.len() {
79        diffs.push(Difference {
80            what: "transition count".into(),
81            ours: ours.transitions.len().to_string(),
82            theirs: theirs.transitions.len().to_string(),
83        });
84        // Counts differ: comparing element-wise below would be noise.
85        return diffs;
86    }
87
88    for (i, (a, b)) in ours.transitions.iter().zip(&theirs.transitions).enumerate() {
89        if a.at != b.at {
90            diffs.push(Difference {
91                what: format!("transition[{i}] instant"),
92                ours: a.at.to_string(),
93                theirs: b.at.to_string(),
94            });
95        }
96        let ea = eff(&ours.types[a.type_index as usize]);
97        let eb = eff(&theirs.types[b.type_index as usize]);
98        if ea != eb {
99            diffs.push(Difference {
100                what: format!("transition[{i}] target type"),
101                ours: format!("{ea:?}"),
102                theirs: format!("{eb:?}"),
103            });
104        }
105    }
106
107    diffs
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::tzif::{write_bytes, TzifData};
114
115    fn parsed(d: &TzifData) -> ParsedTzif {
116        crate::tzif::parse(&write_bytes(d).unwrap()).unwrap()
117    }
118
119    #[test]
120    fn identical_fixed_zones_match() {
121        let a = parsed(&TzifData::fixed(-18000, "EST", "EST5"));
122        let b = parsed(&TzifData::fixed(-18000, "EST", "EST5"));
123        assert!(diff(&a, &b).is_empty());
124    }
125
126    #[test]
127    fn offset_mismatch_is_reported() {
128        let a = parsed(&TzifData::fixed(-18000, "EST", "EST5"));
129        let b = parsed(&TzifData::fixed(-14400, "EDT", "EDT4"));
130        assert!(!diff(&a, &b).is_empty());
131    }
132}