use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::error::{Error, Result};
use crate::manifest::OracleMode;
use crate::model::Database;
use crate::structural::{classify, differing_dims, ParityClass, Shape};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReleaseChangeKind {
Unchanged,
Added,
Removed,
LinkChanged,
LeapOnly,
MetadataOnly,
BehaviorPast,
BehaviorFuture,
BehaviorPastAndFuture,
BehaviourUnassessed,
}
impl ReleaseChangeKind {
pub fn as_str(self) -> &'static str {
match self {
ReleaseChangeKind::Unchanged => "unchanged",
ReleaseChangeKind::Added => "added",
ReleaseChangeKind::Removed => "removed",
ReleaseChangeKind::LinkChanged => "link_changed",
ReleaseChangeKind::LeapOnly => "leap_only",
ReleaseChangeKind::MetadataOnly => "metadata_only",
ReleaseChangeKind::BehaviorPast => "behavior_past",
ReleaseChangeKind::BehaviorFuture => "behavior_future",
ReleaseChangeKind::BehaviorPastAndFuture => "behavior_past_and_future",
ReleaseChangeKind::BehaviourUnassessed => "behaviour_unassessed",
}
}
pub const ALL: [ReleaseChangeKind; 10] = [
ReleaseChangeKind::Unchanged,
ReleaseChangeKind::Added,
ReleaseChangeKind::Removed,
ReleaseChangeKind::LinkChanged,
ReleaseChangeKind::LeapOnly,
ReleaseChangeKind::MetadataOnly,
ReleaseChangeKind::BehaviorPast,
ReleaseChangeKind::BehaviorFuture,
ReleaseChangeKind::BehaviorPastAndFuture,
ReleaseChangeKind::BehaviourUnassessed,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BehaviourDelta {
pub past_diffs: usize,
pub future_diffs: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OracleFailureScope {
GlobalToolUnavailable,
RowOrIdentifierFailure,
}
impl OracleFailureScope {
pub fn as_str(self) -> &'static str {
match self {
OracleFailureScope::GlobalToolUnavailable => "global_tool_unavailable",
OracleFailureScope::RowOrIdentifierFailure => "row_or_identifier_failure",
}
}
}
#[derive(Debug, Clone)]
pub struct DiffRow {
pub name: String,
pub change_kind: ReleaseChangeKind,
pub parity_class: Option<ParityClass>,
pub diffs: Vec<&'static str>,
pub behaviour: Option<BehaviourDelta>,
pub link_change: Option<(Option<String>, Option<String>)>,
pub behaviour_error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DiffError {
pub name: String,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct ReleaseDiffOptions {
pub horizon: (i32, i32),
pub split: i32,
pub zone_filter: Option<String>,
pub zdump_program: Option<String>,
}
#[derive(Debug)]
pub struct ReleaseDiffReport {
pub oracle_mode: OracleMode,
pub horizon: (i32, i32),
pub split: i32,
pub rows: Vec<DiffRow>,
pub errors: Vec<DiffError>,
}
impl ReleaseDiffReport {
pub fn kind_counts(&self) -> BTreeMap<&'static str, usize> {
let mut m: BTreeMap<&'static str, usize> = BTreeMap::new();
for k in ReleaseChangeKind::ALL {
m.insert(k.as_str(), 0);
}
for r in &self.rows {
*m.entry(r.change_kind.as_str()).or_default() += 1;
}
m
}
}
fn link_map(db: &Database) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
for l in &db.links {
m.insert(l.link_name.clone(), l.target.clone());
}
m
}
fn dump(
program: &str,
root: &std::path::Path,
name: &str,
bytes: &[u8],
lo: i32,
hi: i32,
) -> Result<Vec<String>> {
let path = crate::fs::output_tree::write_zone_file(root, name, bytes, true, false)?;
crate::compare::zdump::run(program, &path, lo, hi)
}
fn behaviour_delta(
program: &str,
name: &str,
old_bytes: &[u8],
new_bytes: &[u8],
opts: &ReleaseDiffOptions,
work: &std::path::Path,
) -> Result<BehaviourDelta> {
let (lo, hi) = opts.horizon;
let split = opts.split;
let old_root = work.join("old");
let new_root = work.join("new");
let past = if split > lo {
crate::compare::zdump::diff(
&dump(program, &old_root, name, old_bytes, lo, split - 1)?,
&dump(program, &new_root, name, new_bytes, lo, split - 1)?,
)
} else {
Vec::new()
};
let future = if split <= hi {
crate::compare::zdump::diff(
&dump(program, &old_root, name, old_bytes, split, hi)?,
&dump(program, &new_root, name, new_bytes, split, hi)?,
)
} else {
Vec::new()
};
Ok(BehaviourDelta {
past_diffs: past.len(),
future_diffs: future.len(),
})
}
pub fn build_release_diff(
old_db: &Database,
new_db: &Database,
opts: &ReleaseDiffOptions,
) -> Result<ReleaseDiffReport> {
let old_zones: BTreeSet<&str> = old_db.zones.iter().map(|z| z.name.as_str()).collect();
let new_zones: BTreeSet<&str> = new_db.zones.iter().map(|z| z.name.as_str()).collect();
let old_links = link_map(old_db);
let new_links = link_map(new_db);
let mut names: BTreeSet<String> = BTreeSet::new();
for z in &old_db.zones {
names.insert(z.name.clone());
}
for z in &new_db.zones {
names.insert(z.name.clone());
}
for k in old_links.keys().chain(new_links.keys()) {
names.insert(k.clone());
}
if let Some(only) = &opts.zone_filter {
names.retain(|n| n == only);
}
let work = tempfile::Builder::new()
.prefix("zic-rs-release-diff-")
.tempdir()
.map_err(|e| Error::io(PathBuf::from("<tempdir>"), e))?;
let mut rows = Vec::new();
let mut errors = Vec::new();
let oracle_unavailable: Option<String> = match &opts.zdump_program {
None => Some("behaviour axis not requested (pass --reference-zdump to assess)".into()),
Some(prog) => {
if crate::doctor::resolve(prog).is_some() {
None
} else {
Some(format!(
"{}: zdump program {prog:?} could not be resolved on PATH or as an explicit path",
OracleFailureScope::GlobalToolUnavailable.as_str()
))
}
}
};
for name in &names {
let in_old = old_zones.contains(name.as_str()) || old_links.contains_key(name);
let in_new = new_zones.contains(name.as_str()) || new_links.contains_key(name);
let old_link = old_links.get(name);
let new_link = new_links.get(name);
let old_zone = old_zones.contains(name.as_str());
let new_zone = new_zones.contains(name.as_str());
if !in_old && in_new {
rows.push(simple_row(name, ReleaseChangeKind::Added));
continue;
}
if in_old && !in_new {
rows.push(simple_row(name, ReleaseChangeKind::Removed));
continue;
}
if old_link.is_some() || new_link.is_some() {
let unchanged_link = old_link.is_some()
&& new_link.is_some()
&& old_link == new_link
&& !old_zone
&& !new_zone;
let kind = if unchanged_link {
ReleaseChangeKind::Unchanged
} else {
ReleaseChangeKind::LinkChanged
};
let mut row = simple_row(name, kind);
row.link_change = Some((old_link.cloned(), new_link.cloned()));
rows.push(row);
continue;
}
debug_assert!(old_zone && new_zone);
let old_bytes = match crate::compile_zone_to_bytes(old_db, name) {
Ok(b) => b,
Err(e) => {
errors.push(DiffError {
name: name.clone(),
reason: format!("OLD: {e}"),
});
continue;
}
};
let new_bytes = match crate::compile_zone_to_bytes(new_db, name) {
Ok(b) => b,
Err(e) => {
errors.push(DiffError {
name: name.clone(),
reason: format!("NEW: {e}"),
});
continue;
}
};
if old_bytes == new_bytes {
rows.push(simple_row(name, ReleaseChangeKind::Unchanged));
continue;
}
let (op, np) = match (
crate::tzif::validate::parse(&old_bytes),
crate::tzif::validate::parse(&new_bytes),
) {
(Ok(o), Ok(n)) => (o, n),
_ => {
errors.push(DiffError {
name: name.clone(),
reason: "could not decode compiled TZif on one side".into(),
});
continue;
}
};
let dims = differing_dims(&Shape::of(&op), &Shape::of(&np));
let parity = classify(false, &dims);
if dims.as_slice() == ["leapcnt"] {
let mut row = simple_row(name, ReleaseChangeKind::LeapOnly);
row.parity_class = Some(parity);
row.diffs = dims;
rows.push(row);
continue;
}
let mut behaviour = None;
let mut behaviour_error = None;
let kind = if let (Some(program), None) = (&opts.zdump_program, &oracle_unavailable) {
match behaviour_delta(program, name, &old_bytes, &new_bytes, opts, work.path()) {
Ok(d) => {
behaviour = Some(d);
match (d.past_diffs > 0, d.future_diffs > 0) {
(true, true) => ReleaseChangeKind::BehaviorPastAndFuture,
(true, false) => ReleaseChangeKind::BehaviorPast,
(false, true) => ReleaseChangeKind::BehaviorFuture,
(false, false) => ReleaseChangeKind::MetadataOnly,
}
}
Err(e) => {
behaviour_error = Some(format!(
"{}: {e}",
OracleFailureScope::RowOrIdentifierFailure.as_str()
));
ReleaseChangeKind::BehaviourUnassessed
}
}
} else {
ReleaseChangeKind::BehaviourUnassessed
};
let mut row = simple_row(name, kind);
row.parity_class = Some(parity);
row.diffs = dims;
row.behaviour = behaviour;
row.behaviour_error = behaviour_error;
rows.push(row);
}
let oracle_mode = match (&opts.zdump_program, oracle_unavailable) {
(Some(_), None) => OracleMode::ReferenceZdump,
(_, Some(reason)) => OracleMode::Unavailable(reason),
(None, None) => OracleMode::Unavailable("behaviour axis not requested".into()),
};
Ok(ReleaseDiffReport {
oracle_mode,
horizon: opts.horizon,
split: opts.split,
rows,
errors,
})
}
fn simple_row(name: &str, kind: ReleaseChangeKind) -> DiffRow {
DiffRow {
name: name.to_string(),
change_kind: kind,
parity_class: None,
diffs: Vec::new(),
behaviour: None,
link_change: None,
behaviour_error: None,
}
}
pub const SCHEMA: &str = "zic-rs-release-diff-v1";
impl ReleaseDiffReport {
pub fn to_json(&self) -> String {
use crate::json::escape;
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",
self.oracle_mode.to_json_field()
));
s.push_str(&format!(
" \"horizon\": {{ \"lo\": {}, \"hi\": {} }},\n",
self.horizon.0, self.horizon.1
));
s.push_str(&format!(" \"split\": {},\n", self.split));
s.push_str(
" \"non_claim\": \"a release-diff is scoped to the declared horizon + split, not all-time; \
the behaviour axis requires a zdump oracle (absence ⇒ behaviour_unassessed, never 'no change'); \
it does not state WHY a zone changed (that is IANA NEWS); identifiers outside zic-rs's compile \
subset are reported as errors, never guessed\",\n",
);
let counts = self.kind_counts();
s.push_str(" \"summary\": {");
let mut first = true;
for (k, v) in &counts {
s.push_str(if first { "\n" } else { ",\n" });
first = false;
s.push_str(&format!(" {}: {}", escape(k), v));
}
s.push_str("\n },\n");
s.push_str(" \"identifiers\": [");
for (i, r) in self.rows.iter().enumerate() {
s.push_str(if i == 0 { "\n" } else { ",\n" });
s.push_str(&row_json(r));
}
s.push_str(if self.rows.is_empty() {
"],\n"
} else {
"\n ],\n"
});
s.push_str(" \"errors\": [");
for (i, e) in self.errors.iter().enumerate() {
s.push_str(if i == 0 { "\n" } else { ",\n" });
s.push_str(&format!(
" {{ \"name\": {}, \"reason\": {} }}",
escape(&e.name),
escape(&e.reason)
));
}
s.push_str(if self.errors.is_empty() {
"]\n"
} else {
"\n ]\n"
});
s.push_str("}\n");
s
}
}
fn row_json(r: &DiffRow) -> String {
use crate::json::escape;
let mut s = String::new();
s.push_str(&format!(
" {{ \"name\": {}, \"change_kind\": {}",
escape(&r.name),
escape(r.change_kind.as_str())
));
if let Some(p) = r.parity_class {
let dims: Vec<String> = r.diffs.iter().map(|d| escape(d)).collect();
s.push_str(&format!(
", \"structural\": {{ \"parity_class\": {}, \"differing\": [{}] }}",
escape(p.label()),
dims.join(", ")
));
}
if let Some(b) = r.behaviour {
s.push_str(&format!(
", \"behaviour\": {{ \"past_diffs\": {}, \"future_diffs\": {} }}",
b.past_diffs, b.future_diffs
));
}
if let Some((old_t, new_t)) = &r.link_change {
let f = |o: &Option<String>| {
o.as_ref()
.map(|t| escape(t))
.unwrap_or_else(|| "null".into())
};
s.push_str(&format!(
", \"link\": {{ \"old_target\": {}, \"new_target\": {} }}",
f(old_t),
f(new_t)
));
}
if let Some(reason) = &r.behaviour_error {
s.push_str(&format!(", \"behaviour_error\": {}", escape(reason)));
}
s.push_str(" }");
s
}