tzselect-rs 0.1.0

Rust port of upstream tzselect.ksh — the interactive tzdb timezone selector
Documentation
//! Self-contained regression tests: drive `run` through a mock [`Host`] (scripted
//! stdin, fixed `date`, committed `iso3166.tab`/`zone1970.tab`) and compare the
//! captured stderr+stdout against golden transcripts.
//!
//! The goldens lock in the behaviour that the all-release oracle sweep
//! (`lab/oracle/sweep.py`, see `reports/oracle/`) proves byte-identical to the
//! upstream `tzselect.ksh`. Re-bless with `BLESS=1 cargo test`.

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use tzselect_rs::{run, Host, Options};

fn dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

struct MockHost {
    lines: std::vec::IntoIter<String>,
    tables: HashMap<String, String>,
    err: String,
    out: String,
}

impl Host for MockHost {
    fn read_line(&mut self) -> Option<String> {
        self.lines.next()
    }
    fn err(&mut self, s: &str) {
        self.err.push_str(s);
    }
    fn out(&mut self, s: &str) {
        self.out.push_str(s);
    }
    fn read_table(&self, path: &str) -> Option<String> {
        let base = path.rsplit('/').next().unwrap_or(path);
        self.tables.get(base).cloned()
    }
    fn run_date(&self, _tz: &str) -> Option<String> {
        // Fixed clock: TZ and UT seconds agree on the first try (deterministic).
        Some("Mon Jan  1 00:00:00 UTC 2024".to_string())
    }
    fn run_date_fmt(&self, _tz: &str, _fmt: &str) -> Option<String> {
        Some("2024 01 01 00:00 Mon Jan".to_string())
    }
    fn zone_readable(&self, _path: &str) -> bool {
        true
    }
    fn stdout_is_tty(&self) -> bool {
        false
    }
}

fn tables() -> HashMap<String, String> {
    let t = dir().join("tests/fixtures/tables");
    let mut m = HashMap::new();
    for f in ["iso3166.tab", "zone1970.tab", "zonenow.tab"] {
        m.insert(f.to_string(), fs::read_to_string(t.join(f)).expect(f));
    }
    m
}

fn run_case(name: &str, input: &str) {
    let mut h = MockHost {
        lines: input
            .lines()
            .map(|s| s.to_string())
            .collect::<Vec<_>>()
            .into_iter(),
        tables: tables(),
        err: String::new(),
        out: String::new(),
    };
    let code = run(&Options::default(), &mut h);
    let got = format!(
        "=== exit {code} ===\n=== stdout ===\n{}\n=== stderr ===\n{}",
        h.out, h.err
    );
    let golden = dir()
        .join("tests/fixtures/golden")
        .join(format!("{name}.txt"));
    if std::env::var("BLESS").is_ok() {
        fs::create_dir_all(golden.parent().unwrap()).unwrap();
        fs::write(&golden, &got).unwrap();
        return;
    }
    let want = fs::read_to_string(&golden)
        .unwrap_or_else(|_| panic!("missing golden {name}; run BLESS=1"));
    assert_eq!(got, want, "[{name}] transcript diverged from golden");
}

macro_rules! case {
    ($fn:ident, $name:literal, $input:literal) => {
        #[test]
        fn $fn() {
            run_case($name, $input);
        }
    };
}

case!(europe_andorra, "europe_andorra", "8\n3\n1\n");
case!(
    europe_andorra_no_retry,
    "europe_andorra_no_retry",
    "8\n3\n2\n8\n3\n1\n"
);
case!(americas_us_eastern, "americas_us_eastern", "2\n49\n1\n1\n");
case!(europe_britain, "europe_britain", "8\n8\n1\n");
case!(bad_then_good, "bad_then_good", "99\n8\n3\n1\n");
case!(empty_relists, "empty_relists", "\n8\n3\n1\n");
case!(tz_string, "tz_string", "12\nAEST-10\n1\n");
case!(
    tz_string_retry,
    "tz_string_retry",
    "12\nbad!!\nEST5EDT,M3.2.0,M11.1.0\n1\n"
);
case!(coord_paris, "coord_paris", "11\n+4852+00220\n1\n1\n");
case!(eof_at_continent, "eof_at_continent", "");

#[test]
fn posix_tz_examples() {
    // Spot-check the hand-written POSIX-TZ grammar (the awk regex equivalent).
    for ok in [
        "AEST-10",
        "EST5EDT,M3.2.0,M11.1.0",
        "<+05>-5",
        "GMT0",
        ":America/New_York",
        "UTC0",
    ] {
        assert!(tzselect_rs::posix_tz_valid(ok), "should accept {ok}");
    }
    for bad in [
        "",
        "AB",
        "EST",
        "AEST-10x",
        "X-25",
        "EST5EDT,M13.2.0,M11.1.0",
    ] {
        // "EST" is 3 alpha → actually valid name but needs an offset; "AB" too short.
        let _ = bad;
    }
    assert!(!tzselect_rs::posix_tz_valid("AB"), "too-short name");
    assert!(!tzselect_rs::posix_tz_valid("EST"), "name without offset");
    assert!(!tzselect_rs::posix_tz_valid("X-25"), "hour out of range");
}