Skip to main content

tzcompile/compare/
zdump.rs

1//! The **`zdump` behaviour oracle**: compare two compiled zone files by what they actually
2//! *do*, as reported by the reference `zdump` tool, over a declared year horizon.
3//!
4//! Why this rather than decoding the TZif structurally: reference `zic` is free to choose
5//! different explicit-transition horizons, type orderings, and how much it leans on the
6//! POSIX footer for the tail. Those are valid representational choices that a structural
7//! diff would flag as "mismatches". The contract users actually care about is local-time
8//! *behaviour*: for every instant in the declared window, do both files agree on the UTC
9//! instant, local time, UT offset, DST flag, and abbreviation? `zdump -v -c LO,HI`
10//! enumerates exactly that (it evaluates transitions **and** the POSIX footer), so diffing
11//! its normalised output is the faithful semantic comparison.
12//!
13//! ## Path handling (a real foot-gun)
14//!
15//! `zdump` treats a bare name as a timezone-database lookup (`TZDIR`), not a file. To dump
16//! an arbitrary compiled file you must pass an **absolute path**. We always do.
17
18use std::path::Path;
19use std::process::Command;
20
21use crate::error::{Error, Result};
22
23use super::semantic::Difference;
24
25/// Whether the `zdump` oracle program is runnable (mirrors `reference_zic::is_available`). Used by
26/// the T15.3 semantic-witness builder to render `OracleMode::Unavailable` (with a reason) rather than
27/// silently weakening when `zdump` is absent.
28pub fn is_available(program: &str) -> bool {
29    std::process::Command::new(program)
30        .arg("--version")
31        .output()
32        .map(|o| o.status.success())
33        .unwrap_or(false)
34}
35
36/// Run `zdump -v -c LO,HI <abs path>` and return its output as **normalised** lines.
37///
38/// Normalisation strips the leading path token from each line (every line begins with the
39/// file path), so two dumps of the same behaviour at different paths compare equal.
40pub fn run(program: &str, path: &Path, lo: i32, hi: i32) -> Result<Vec<String>> {
41    // `path` must be absolute (see module docs). The caller builds it under a tempdir, which
42    // is absolute; assert defensively so a future caller can't silently regress to a lookup.
43    debug_assert!(path.is_absolute(), "zdump needs an absolute path");
44    let path_str = path.to_str().ok_or_else(|| {
45        Error::message("compiled zone path is not valid UTF-8 (cannot pass to zdump)")
46    })?;
47
48    let output = Command::new(program)
49        .arg("-v")
50        .arg("-c")
51        .arg(format!("{lo},{hi}"))
52        .arg(path_str)
53        .output()
54        .map_err(|e| Error::message(format!("failed to run {program:?}: {e}")))?;
55    if !output.status.success() {
56        return Err(Error::message(format!(
57            "{program} failed for {path_str}: {}",
58            String::from_utf8_lossy(&output.stderr).trim()
59        )));
60    }
61
62    let stdout = String::from_utf8_lossy(&output.stdout);
63    let normalised = stdout
64        .lines()
65        .map(|line| normalise(line, path_str))
66        .collect();
67    Ok(normalised)
68}
69
70/// Strip the leading path token from one `zdump -v` line so behaviour can be compared
71/// independent of where the file lives.
72fn normalise(line: &str, path: &str) -> String {
73    line.strip_prefix(path)
74        .unwrap_or(line)
75        .trim_start()
76        .to_string()
77}
78
79/// Diff two normalised `zdump` line lists into human-readable [`Difference`]s.
80///
81/// Compared line-by-line in order; `zdump -v` output is deterministic for a given file and
82/// horizon, so a positional diff is exact. Any divergence — count or content — is reported.
83pub fn diff(ours: &[String], theirs: &[String]) -> Vec<Difference> {
84    let mut diffs = Vec::new();
85    if ours.len() != theirs.len() {
86        diffs.push(Difference {
87            what: "zdump line count".into(),
88            ours: ours.len().to_string(),
89            theirs: theirs.len().to_string(),
90        });
91    }
92    for (i, (a, b)) in ours.iter().zip(theirs).enumerate() {
93        if a != b {
94            diffs.push(Difference {
95                what: format!("zdump line {i}"),
96                ours: a.clone(),
97                theirs: b.clone(),
98            });
99        }
100    }
101    diffs
102}