Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ jobs:
with:
key: build-test

# Ubuntu 26.04 split the backward links (America/Godthab, US/Pacific, ...)
# out of tzdata; FsTzdbProvider reads /usr/share/zoneinfo directly.
- name: Install tzdata-legacy
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y tzdata-legacy

- name: Cargo build --all-features
run: cargo build --quiet --all-features
- name: Test --all-features
Expand All @@ -116,6 +122,9 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: features-test
# Backward links, as in build-test.
- name: Install tzdata-legacy
run: sudo apt-get update && sudo apt-get install -y tzdata-legacy
- name: Install cargo-all-features
uses: baptiste0928/cargo-install@v3.5.0
with:
Expand Down
164 changes: 92 additions & 72 deletions crates/zoneinfo/tests/test_datagen.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use std::{format, fs::read_to_string, path::Path, string::String, vec::Vec};
use zoneinfo_rs::{ZoneInfoCompiler, ZoneInfoData};
use zoneinfo_rs::{compiler::CompiledTransitions, ZoneInfoCompiler, ZoneInfoData};

#[derive(Debug, Serialize, Deserialize)]
struct TzifTestData {
Expand All @@ -27,70 +27,55 @@ fn read_test_data(test_data_dir: &Path, identifier: &str) -> TzifTestData {
serde_json::from_str(&read_to_string(path).unwrap()).unwrap()
}

fn run_assertions(identifier: &str, zoneinfo_data: ZoneInfoData, test_data: TzifTestData) {
let mut compiler = ZoneInfoCompiler::new(zoneinfo_data);
let computed_zoneinfo = compiler.build_zone(identifier);

assert_eq!(
computed_zoneinfo.initial_record.offset,
test_data.first_record.offset
);
assert_eq!(
computed_zoneinfo.initial_record.designation,
test_data.first_record.abbr
);

// zip() stops at the shorter side, so compare counts first.
assert_eq!(
computed_zoneinfo.transitions.iter().count(),
test_data.transitions.len(),
"Transition counts are not aligned for {identifier}"
);

for (computed, td) in computed_zoneinfo
.transitions
.iter()
.zip(test_data.transitions)
{
assert_eq!(
computed.at_time, td.transition_time,
"Transition times are not aligned for {}",
td.transition_time,
);
assert_eq!(
computed.offset, td.record.offset,
"Offsets are not aligned for {}",
td.transition_time
);
assert_eq!(
computed.dst, td.record.is_dst,
"DST flag is not aligned for {}",
td.transition_time
);
assert_eq!(
computed.format, td.record.abbr,
"Designation is not aligned for {}",
td.transition_time
);
/// Zones where zic materializes transitions `zoneinfo_rs` does not generate.
///
/// `Rule Palestine 2072 max` is interleaved with Ramadan-derived `only` rules
/// through 2086. The compiler stops at the first `max` rule and defers the rest
/// to the POSIX footer, which has no Ramadan pause. The shared prefix is still
/// compared.
const UNDER_GENERATED: &[&str] = &["Asia/Gaza", "Asia/Hebron"];

/// Ground truth transitions with zic's trailing footer anchor removed.
///
/// glibc 2.43's zic ends the table with a no-op transition — same offset, DST
/// flag and designation as its predecessor — and omits the next real one,
/// which the footer already expresses; glibc 2.39's zic did the opposite.
///
/// The anchor sits on a zone-line boundary the compiler collapses, so only a
/// final repeat at an instant we do not generate qualifies. A repeat we do
/// generate is real, like `America/Nuuk`'s last transition.
fn significant<'a>(
test_data: &'a TzifTestData,
computed: &CompiledTransitions,
) -> &'a [TransitionRecord] {
let transitions = &test_data.transitions[..];
let [.., previous, last] = transitions else {
return transitions;
};
let anchor = previous.record.offset == last.record.offset
&& previous.record.is_dst == last.record.is_dst
&& previous.record.abbr == last.record.abbr
&& !computed
.transitions
.iter()
.any(|t| t.at_time == last.transition_time);
if anchor {
&transitions[..transitions.len() - 1]
} else {
transitions
}
}

/// Returns None on pass, Some(error) on failure; skips zones with no JSON.
fn check_zone(
/// Compares a compiled zone against zic's table, one message per mismatch.
///
/// Ground truth may stop early — past its last entry is footer territory.
/// Running past *our* last transition is under-generation, and fails unless the
/// zone is a documented [`UNDER_GENERATED`] gap.
fn compare(
identifier: &str,
compiler: &mut ZoneInfoCompiler,
data_dir: &Path,
) -> Option<String> {
let test_json = identifier.replace("/", "-").to_ascii_lowercase();
let path = data_dir.join(format!("{test_json}.json"));
let Ok(content) = std::fs::read_to_string(&path) else {
return None; // no JSON yet — skip
};
let Ok(test_data) = serde_json::from_str::<TzifTestData>(&content) else {
return Some(format!("{identifier}: malformed JSON"));
};

let computed = compiler.build_zone(identifier);
computed: &CompiledTransitions,
test_data: &TzifTestData,
) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();

if computed.initial_record.offset != test_data.first_record.offset {
Expand All @@ -99,24 +84,22 @@ fn check_zone(
computed.initial_record.offset, test_data.first_record.offset
));
}

// zip() stops at the shorter side, so compare counts first.
let computed_count = computed.transitions.iter().count();
if computed_count != test_data.transitions.len() {
if computed.initial_record.designation != test_data.first_record.abbr {
errors.push(format!(
"transition count {} != {}",
computed_count,
test_data.transitions.len()
"initial abbr {:?} != {:?}",
computed.initial_record.designation, test_data.first_record.abbr
));
}

for (c, td) in computed.transitions.iter().zip(&test_data.transitions) {
let expected = significant(test_data, computed);

for (c, td) in computed.transitions.iter().zip(expected) {
if c.at_time != td.transition_time {
errors.push(format!(
"transition time {} != {} (at {})",
c.at_time, td.transition_time, td.transition_time
"transition time {} != {}",
c.at_time, td.transition_time
));
break;
break; // The lists have desynchronized; later diffs are noise.
}
if c.offset != td.record.offset {
errors.push(format!(
Expand All @@ -138,6 +121,43 @@ fn check_zone(
}
}

let missing = expected.len().saturating_sub(computed.transitions.len());
if missing != 0 && !UNDER_GENERATED.contains(&identifier) {
errors.push(format!(
"{missing} transitions missing after {} (zic ends at {})",
computed.transitions.iter().last().map_or(-1, |t| t.at_time),
expected.last().map_or(-1, |t| t.transition_time)
));
}

errors
}

fn run_assertions(identifier: &str, zoneinfo_data: ZoneInfoData, test_data: TzifTestData) {
let mut compiler = ZoneInfoCompiler::new(zoneinfo_data);
let computed = compiler.build_zone(identifier);
let errors = compare(identifier, &computed, &test_data);
assert!(errors.is_empty(), "{identifier}: {}", errors.join("; "));
}

/// Returns None on pass, Some(error) on failure; skips zones with no JSON.
fn check_zone(
identifier: &str,
compiler: &mut ZoneInfoCompiler,
data_dir: &Path,
) -> Option<String> {
let test_json = identifier.replace("/", "-").to_ascii_lowercase();
let path = data_dir.join(format!("{test_json}.json"));
let Ok(content) = std::fs::read_to_string(&path) else {
return None; // no JSON yet — skip
};
let Ok(test_data) = serde_json::from_str::<TzifTestData>(&content) else {
return Some(format!("{identifier}: malformed JSON"));
};

let computed = compiler.build_zone(identifier);
let errors = compare(identifier, &computed, &test_data);

if errors.is_empty() {
None
} else {
Expand Down
Loading