use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::compare::reference_zic;
use crate::error::{Error, Result};
use crate::json::escape;
use crate::model::Database;
use crate::tzif::{self, ParsedTzif};
const SCHEMA: &str = "zic-rs-structural-report-v3";
const TEXT_EXAMPLES: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shape {
pub version: u8,
pub timecnt: u32,
pub typecnt: u32,
pub charcnt: u32,
pub isutcnt: u32,
pub isstdcnt: u32,
pub leapcnt: u32,
pub footer: String,
}
impl Shape {
pub(crate) fn of(p: &ParsedTzif) -> Self {
Shape {
version: p.version,
timecnt: p.counts.timecnt,
typecnt: p.counts.typecnt,
charcnt: p.counts.charcnt,
isutcnt: p.counts.isutcnt,
isstdcnt: p.counts.isstdcnt,
leapcnt: p.counts.leapcnt,
footer: p.footer.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ParityClass {
ByteIdentical,
StructurallyEquivalent,
SlimFatTimecnt,
TypeCount,
AbbrevTable,
Version,
Footer,
TtisStdUt,
Leap,
Mixed,
}
impl ParityClass {
pub fn label(self) -> &'static str {
match self {
ParityClass::ByteIdentical => "byte-identical",
ParityClass::StructurallyEquivalent => "structurally-equivalent",
ParityClass::SlimFatTimecnt => "slim/fat-timecnt",
ParityClass::TypeCount => "type-count",
ParityClass::AbbrevTable => "abbreviation-table",
ParityClass::Version => "version-byte",
ParityClass::Footer => "footer",
ParityClass::TtisStdUt => "ttisstd/ttisut",
ParityClass::Leap => "leap-count",
ParityClass::Mixed => "mixed/unexpected",
}
}
}
pub(crate) fn differing_dims(a: &Shape, b: &Shape) -> Vec<&'static str> {
let mut d = Vec::new();
if a.version != b.version {
d.push("version");
}
if a.timecnt != b.timecnt {
d.push("timecnt");
}
if a.typecnt != b.typecnt {
d.push("typecnt");
}
if a.charcnt != b.charcnt {
d.push("charcnt");
}
if a.isutcnt != b.isutcnt {
d.push("isutcnt");
}
if a.isstdcnt != b.isstdcnt {
d.push("isstdcnt");
}
if a.leapcnt != b.leapcnt {
d.push("leapcnt");
}
if a.footer != b.footer {
d.push("footer");
}
d
}
pub(crate) fn classify(byte_identical: bool, dims: &[&str]) -> ParityClass {
if byte_identical {
return ParityClass::ByteIdentical;
}
match dims {
[] => ParityClass::StructurallyEquivalent,
["timecnt"] => ParityClass::SlimFatTimecnt,
["typecnt"] => ParityClass::TypeCount,
["charcnt"] => ParityClass::AbbrevTable,
["version"] => ParityClass::Version,
["footer"] => ParityClass::Footer,
["isutcnt"] | ["isstdcnt"] | ["isutcnt", "isstdcnt"] => ParityClass::TtisStdUt,
["leapcnt"] => ParityClass::Leap,
_ => ParityClass::Mixed,
}
}
#[derive(Debug, Clone)]
pub struct ZoneShape {
pub name: String,
pub class: ParityClass,
pub diffs: Vec<&'static str>,
pub ours: Shape,
pub theirs: Shape,
}
#[derive(Debug, Clone)]
pub struct ZoneError {
pub name: String,
pub reason: String,
}
#[derive(Debug)]
pub struct StructuralReport {
pub tzdb_version: Option<String>,
pub reference_zic: String,
pub zones: Vec<ZoneShape>,
pub errors: Vec<ZoneError>,
pub timecnt_delta_total: i64,
}
impl StructuralReport {
pub fn class_counts(&self) -> BTreeMap<ParityClass, usize> {
let mut m: BTreeMap<ParityClass, usize> = BTreeMap::new();
for z in &self.zones {
*m.entry(z.class).or_default() += 1;
}
m
}
pub fn zones_compared(&self) -> usize {
self.zones.len()
}
pub fn version_footer_match(&self) -> usize {
self.zones
.iter()
.filter(|z| z.ours.version == z.theirs.version && z.ours.footer == z.theirs.footer)
.count()
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("zic-rs structural-parity inventory (campaign T8)\n");
s.push_str(
" axis: TZif *structure* vs reference `zic` — SEPARATE from behaviour parity.\n",
);
s.push_str(
" behaviour parity (CORE.1: 341/341 zdump-match over 1900..2040) is the contract;\n",
);
s.push_str(" byte parity is claimed only where a reference blob is pinned.\n\n");
if let Some(v) = &self.tzdb_version {
s.push_str(&format!(" tzdb release : {v}\n"));
}
s.push_str(&format!(" reference zic : {}\n", self.reference_zic));
s.push_str(&format!(
" zones compared : {}\n",
self.zones_compared()
));
s.push_str(&format!(
" version+footer ok : {} (structure drop-in modulo slim/fat + packing)\n",
self.version_footer_match()
));
s.push_str(&format!(
" net extra transitions (ours − ref, slim/fat): {}\n\n",
self.timecnt_delta_total
));
s.push_str(" parity classes:\n");
let counts = self.class_counts();
for (class, n) in &counts {
s.push_str(&format!(" {:<24} {}\n", class.label(), n));
if matches!(
class,
ParityClass::Version
| ParityClass::Footer
| ParityClass::AbbrevTable
| ParityClass::TypeCount
| ParityClass::TtisStdUt
| ParityClass::Leap
| ParityClass::Mixed
) {
for (shown, z) in self.zones.iter().filter(|z| z.class == *class).enumerate() {
if shown == TEXT_EXAMPLES {
s.push_str(&format!(
" (+{} more)\n",
counts[class] - TEXT_EXAMPLES
));
break;
}
s.push_str(&format!(
" {}: [{}] ours v{} tc={} cc={} ref v{} tc={} cc={}\n",
z.name,
z.diffs.join(","),
z.ours.version as char,
z.ours.timecnt,
z.ours.charcnt,
z.theirs.version as char,
z.theirs.timecnt,
z.theirs.charcnt,
));
}
}
}
if !self.errors.is_empty() {
s.push_str(&format!("\n not compared ({}):\n", self.errors.len()));
for e in self.errors.iter().take(TEXT_EXAMPLES) {
s.push_str(&format!(" {}: {}\n", e.name, e.reason));
}
if self.errors.len() > TEXT_EXAMPLES {
s.push_str(&format!(
" (+{} more)\n",
self.errors.len() - TEXT_EXAMPLES
));
}
}
s.push_str(&crate::manifest::provenance_block_text());
s
}
pub fn to_json(&self) -> String {
let mut s = String::new();
s.push_str("{\n");
s.push_str(&format!(" \"schema\": {},\n", escape(SCHEMA)));
s.push_str(&crate::manifest::provenance_block_json());
s.push_str(&format!(
" \"oracle_mode\": {},\n",
crate::manifest::OracleMode::ReferenceZic.to_json_field()
));
match &self.tzdb_version {
Some(v) => s.push_str(&format!(" \"tzdb_version\": {},\n", escape(v))),
None => s.push_str(" \"tzdb_version\": null,\n"),
}
s.push_str(&format!(
" \"reference_zic\": {},\n",
escape(&self.reference_zic)
));
s.push_str(&format!(
" \"zones_compared\": {},\n",
self.zones_compared()
));
s.push_str(&format!(
" \"version_footer_match\": {},\n",
self.version_footer_match()
));
s.push_str(&format!(
" \"timecnt_delta_total\": {},\n",
self.timecnt_delta_total
));
s.push_str(" \"class_counts\": {");
let mut first = true;
for (class, n) in &self.class_counts() {
s.push_str(if first { "\n" } else { ",\n" });
first = false;
s.push_str(&format!(" {}: {}", escape(class.label()), n));
}
s.push_str(if first { "}," } else { "\n },\n" });
s.push('\n');
s.push_str(" \"differences\": [");
let mut first = true;
for z in self.zones.iter().filter(|z| {
!matches!(
z.class,
ParityClass::ByteIdentical
| ParityClass::StructurallyEquivalent
| ParityClass::SlimFatTimecnt
)
}) {
s.push_str(if first { "\n" } else { ",\n" });
first = false;
let dims: Vec<String> = z.diffs.iter().map(|d| escape(d)).collect();
s.push_str(&format!(
" {{ \"zone\": {}, \"class\": {}, \"dims\": [{}], \"ours_version\": {}, \"ref_version\": {}, \"ours_timecnt\": {}, \"ref_timecnt\": {}, \"ours_charcnt\": {}, \"ref_charcnt\": {} }}",
escape(&z.name),
escape(z.class.label()),
dims.join(", "),
escape(&(z.ours.version as char).to_string()),
escape(&(z.theirs.version as char).to_string()),
z.ours.timecnt,
z.theirs.timecnt,
z.ours.charcnt,
z.theirs.charcnt,
));
}
s.push_str(if first { "],\n" } else { "\n ],\n" });
s.push_str(&format!(" \"errors\": {}\n", self.errors.len()));
s.push_str("}\n");
s
}
}
pub fn build_structural_report(
db: &Database,
inputs: &[PathBuf],
reference_zic: &str,
work_dir: &Path,
only: Option<&str>,
tzdb_version: Option<String>,
emit_style: crate::EmitStyle,
) -> Result<StructuralReport> {
let ref_root = work_dir.join("ref");
std::fs::create_dir_all(&ref_root).map_err(|e| Error::io(&ref_root, e))?;
reference_zic::compile_with_reference(reference_zic, inputs, &ref_root)?;
let names: Vec<String> = match only {
Some(z) => vec![z.to_string()],
None => {
let mut v: Vec<String> = db.zones.iter().map(|z| z.name.clone()).collect();
v.sort();
v
}
};
let mut zones = Vec::new();
let mut errors = Vec::new();
let mut timecnt_delta_total: i64 = 0;
for name in names {
let ours_bytes = match crate::compile_zone_to_bytes_styled(db, &name, emit_style) {
Ok(b) => b,
Err(e) => {
errors.push(ZoneError {
name,
reason: format!("ours: {e}"),
});
continue;
}
};
let ref_path = reference_zic::compiled_path(&ref_root, &name);
let theirs_bytes = match std::fs::read(&ref_path) {
Ok(b) => b,
Err(e) => {
errors.push(ZoneError {
name,
reason: format!("reference: {e}"),
});
continue;
}
};
let byte_identical = ours_bytes == theirs_bytes;
let ours = match tzif::parse(&ours_bytes) {
Ok(p) => Shape::of(&p),
Err(e) => {
errors.push(ZoneError {
name,
reason: format!("decode ours: {e}"),
});
continue;
}
};
let theirs = match tzif::parse(&theirs_bytes) {
Ok(p) => Shape::of(&p),
Err(e) => {
errors.push(ZoneError {
name,
reason: format!("decode reference: {e}"),
});
continue;
}
};
let diffs = differing_dims(&ours, &theirs);
let class = classify(byte_identical, &diffs);
timecnt_delta_total += ours.timecnt as i64 - theirs.timecnt as i64;
zones.push(ZoneShape {
name,
class,
diffs,
ours,
theirs,
});
}
Ok(StructuralReport {
tzdb_version,
reference_zic: reference_zic.to_string(),
zones,
errors,
timecnt_delta_total,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn shape(version: u8, timecnt: u32, charcnt: u32, footer: &str) -> Shape {
Shape {
version,
timecnt,
typecnt: 3,
charcnt,
isutcnt: 0,
isstdcnt: 0,
leapcnt: 0,
footer: footer.to_string(),
}
}
#[test]
fn byte_identical_dominates() {
let a = shape(b'2', 100, 20, "EST5");
let b = shape(b'2', 100, 20, "EST5");
assert_eq!(
classify(true, &differing_dims(&a, &b)),
ParityClass::ByteIdentical
);
}
#[test]
fn equal_decode_but_byte_diff_is_structurally_equivalent() {
let a = shape(b'2', 100, 20, "EST5");
let b = shape(b'2', 100, 20, "EST5");
assert_eq!(
classify(false, &differing_dims(&a, &b)),
ParityClass::StructurallyEquivalent
);
}
#[test]
fn lone_timecnt_diff_is_slim_fat() {
let a = shape(b'2', 236, 20, "EST5EDT,M3.2.0,M11.1.0");
let b = shape(b'2', 175, 20, "EST5EDT,M3.2.0,M11.1.0");
let d = differing_dims(&a, &b);
assert_eq!(d, ["timecnt"]);
assert_eq!(classify(false, &d), ParityClass::SlimFatTimecnt);
}
#[test]
fn lone_charcnt_diff_is_abbrev_table() {
let a = shape(b'2', 145, 37, "HST10");
let b = shape(b'2', 145, 33, "HST10");
assert_eq!(
classify(false, &differing_dims(&a, &b)),
ParityClass::AbbrevTable
);
}
#[test]
fn lone_version_diff_is_version() {
let a = shape(b'2', 100, 12, "<-04>4<-03>,M9.1.6/24,M4.1.6/24");
let b = shape(b'3', 100, 12, "<-04>4<-03>,M9.1.6/24,M4.1.6/24");
assert_eq!(
classify(false, &differing_dims(&a, &b)),
ParityClass::Version
);
}
#[test]
fn multiple_dims_is_mixed() {
let a = shape(b'2', 100, 20, "EST5");
let b = shape(b'3', 90, 18, "EST5EDT,M3.2.0,M11.1.0");
assert_eq!(classify(false, &differing_dims(&a, &b)), ParityClass::Mixed);
}
}