use std::collections::BTreeMap;
use crate::diagnostics::DiagnosticCode;
use crate::json::escape;
use crate::model::Database;
use crate::{compile_zone, resolve_link_target, Error};
const SCHEMA: &str = "zic-rs-support-report-v4";
const TEXT_EXAMPLES: usize = 6;
#[derive(Debug, Default)]
pub struct Bucket {
pub zones: Vec<String>,
pub example_message: String,
}
#[derive(Debug, Default)]
pub struct LinkAccounting {
pub to_supported: Vec<String>,
pub to_unsupported: Vec<String>,
pub cycles: Vec<String>,
pub missing: Vec<String>,
}
#[derive(Debug)]
pub struct SupportReport {
pub tzdb_version: Option<String>,
pub zones_parsed: usize,
pub links_parsed: usize,
pub supported_zones: Vec<String>,
pub unsupported: BTreeMap<String, Bucket>,
pub links: LinkAccounting,
}
pub fn build_support_report(db: &Database, tzdb_version: Option<String>) -> SupportReport {
let mut supported_zones: Vec<String> = Vec::new();
let mut unsupported: BTreeMap<String, Bucket> = BTreeMap::new();
for zone in &db.zones {
match compile_zone(db, &zone.name) {
Ok(_) => supported_zones.push(zone.name.clone()),
Err(e) => {
let (label, message) = classify(&e);
let bucket = unsupported.entry(label).or_default();
bucket.zones.push(zone.name.clone());
if bucket.example_message.is_empty() {
bucket.example_message = message;
}
}
}
}
supported_zones.sort();
for b in unsupported.values_mut() {
b.zones.sort();
}
let supported_set: std::collections::BTreeSet<&str> =
supported_zones.iter().map(String::as_str).collect();
let mut links = LinkAccounting::default();
for link in &db.links {
match resolve_link_target(db, &link.link_name) {
Ok(canonical) => {
if supported_set.contains(canonical) {
links.to_supported.push(link.link_name.clone());
} else {
links.to_unsupported.push(link.link_name.clone());
}
}
Err(e) => {
if e.to_string().contains("cycle") {
links.cycles.push(link.link_name.clone());
} else {
links.missing.push(link.link_name.clone());
}
}
}
}
links.to_supported.sort();
links.to_unsupported.sort();
links.cycles.sort();
links.missing.sort();
SupportReport {
tzdb_version,
zones_parsed: db.zones.len(),
links_parsed: db.links.len(),
supported_zones,
unsupported,
links,
}
}
fn classify(e: &Error) -> (String, String) {
let Some(diag) = e.diagnostic() else {
let m = e.to_string();
return (format!("error: {m}"), m);
};
let code = diag.code.as_str();
let msg = diag.message.clone();
if diag.code == DiagnosticCode::UnsupportedDirective {
let m = &diag.message;
let reason = if m.contains("negative inline SAVE") {
"inline-save: negative SAVE"
} else if m.contains("inline-save FORMAT") {
"inline-save: %s or STD/DST slash FORMAT"
} else if m.contains("rule context") {
"no-rules era: %s or STD/DST slash FORMAT"
} else if m.contains("not POSIX-expressible") {
"recurring footer: non-POSIX day form"
} else if m.contains("unknown rule set") {
"unknown rule set"
} else {
"other (see message)"
};
(format!("{code}: {reason}"), msg)
} else {
(code.to_string(), msg)
}
}
pub fn deep_semantic(label: &str) -> Option<&'static str> {
if label.contains("negative SAVE") {
Some(
"law 7 — SAVE is signed state (negative SAVE is valid; Ireland). Implement as \
first-class signed SAVE, not a per-zone exception.",
)
} else if label.contains("non-POSIX day form") {
Some(
"law 10 — ON day forms can leave the nominal month (e.g. `Sun>=31`); a recurring such \
form is not POSIX-footer-expressible, so an exact footer cannot be synthesised.",
)
} else if label.contains("STD/DST slash") || label.contains("%s or STD/DST") {
Some("law 9 — `%s`, `%z`, and `STD/DST` slash are three distinct FORMAT paths; the slash/`%s` \
forms on this era are not yet pinned against reference `zic`.")
} else {
None
}
}
impl SupportReport {
pub fn identifiers(&self) -> usize {
self.zones_parsed + self.links_parsed
}
pub fn supported_identifiers(&self) -> usize {
self.supported_zones.len() + self.links.to_supported.len()
}
pub fn unsupported_zone_count(&self) -> usize {
self.unsupported.values().map(|b| b.zones.len()).sum()
}
pub fn is_fully_accounted(&self) -> bool {
self.supported_zones.len() + self.unsupported_zone_count() == self.zones_parsed
}
pub fn largest_bucket(&self) -> Option<(&str, usize)> {
self.unsupported
.iter()
.map(|(k, b)| (k.as_str(), b.zones.len()))
.max_by_key(|(_, n)| *n)
}
pub fn to_text(&self) -> String {
self.render_text(false)
}
pub fn to_text_explained(&self) -> String {
self.render_text(true)
}
fn render_text(&self, explain: bool) -> String {
let mut s = String::new();
let version = self.tzdb_version.as_deref().unwrap_or("unknown");
s.push_str(&format!(
"zic-rs support report — tzdb release: {version}\n"
));
s.push_str(
"(reports COMPILE support — a valid TZif is produced; behavioural correctness is a\n\
separate question answered by the reference `zic`/`zdump` oracle, not this report.)\n\n",
);
s.push_str(&format!("identifiers: {}\n", self.identifiers()));
s.push_str(&format!(
" canonical zones: {} parsed, {} compile-supported\n",
self.zones_parsed,
self.supported_zones.len()
));
s.push_str(&format!(
" links: {} parsed ({} → supported, {} → unsupported, {} cycle, {} missing)\n",
self.links_parsed,
self.links.to_supported.len(),
self.links.to_unsupported.len(),
self.links.cycles.len(),
self.links.missing.len(),
));
s.push_str(&format!(
" total supported: {} / {} identifiers\n\n",
self.supported_identifiers(),
self.identifiers()
));
if self.unsupported.is_empty() {
s.push_str("unsupported zones: none\n");
} else {
s.push_str(&format!(
"unsupported zones ({} across {} buckets):\n",
self.unsupported_zone_count(),
self.unsupported.len()
));
for (label, bucket) in &self.unsupported {
s.push_str(&format!(" [{}] {}\n", bucket.zones.len(), label));
if explain {
match deep_semantic(label) {
Some(law) => s.push_str(&format!(" ↳ deep law: {law}\n")),
None => s.push_str(" ↳ deep law: (not yet mapped)\n"),
}
}
let shown = bucket.zones.len().min(TEXT_EXAMPLES);
for z in &bucket.zones[..shown] {
s.push_str(&format!(" {z}\n"));
}
if bucket.zones.len() > shown {
s.push_str(&format!(" (+{} more)\n", bucket.zones.len() - shown));
}
}
if let Some((label, n)) = self.largest_bucket() {
s.push_str(&format!(
"\nbiggest unlock: the `{label}` bucket ({n} zones) — addressing it admits the most zones.\n"
));
}
}
s.push_str(&format!(
"\naccounting: {} supported + {} unsupported == {} zones parsed [{}]\n",
self.supported_zones.len(),
self.unsupported_zone_count(),
self.zones_parsed,
if self.is_fully_accounted() {
"OK"
} else {
"MISMATCH"
},
));
s.push_str(&crate::manifest::provenance_block_text());
s
}
pub fn to_json(&self) -> String {
let arr = |names: &[String]| -> String {
let items: Vec<String> = names.iter().map(|n| escape(n)).collect();
format!("[{}]", items.join(", "))
};
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::NotRun.to_json_field()
));
s.push_str(&crate::manifest::ConformanceStatus::support().to_json_block());
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!(" \"zones_parsed\": {},\n", self.zones_parsed));
s.push_str(&format!(" \"links_parsed\": {},\n", self.links_parsed));
s.push_str(&format!(
" \"supported_identifiers\": {},\n",
self.supported_identifiers()
));
s.push_str(&format!(
" \"fully_accounted\": {},\n",
self.is_fully_accounted()
));
s.push_str(&format!(
" \"supported_zones\": {},\n",
arr(&self.supported_zones)
));
s.push_str(" \"unsupported\": {");
let mut first = true;
for (label, bucket) in &self.unsupported {
s.push_str(if first { "\n" } else { ",\n" });
first = false;
let law = match deep_semantic(label) {
Some(l) => escape(l),
None => "null".to_string(),
};
s.push_str(&format!(
" {}: {{ \"count\": {}, \"deep_semantic\": {}, \"example_message\": {}, \"zones\": {} }}",
escape(label),
bucket.zones.len(),
law,
escape(&bucket.example_message),
arr(&bucket.zones),
));
}
s.push_str(if self.unsupported.is_empty() {
"},\n"
} else {
"\n },\n"
});
s.push_str(" \"links\": {\n");
s.push_str(&format!(
" \"to_supported\": {},\n",
arr(&self.links.to_supported)
));
s.push_str(&format!(
" \"to_unsupported\": {},\n",
arr(&self.links.to_unsupported)
));
s.push_str(&format!(" \"cycles\": {},\n", arr(&self.links.cycles)));
s.push_str(&format!(" \"missing\": {}\n", arr(&self.links.missing)));
s.push_str(" }\n");
s.push_str("}\n");
s
}
}
pub fn sniff_tzdb_version(bytes: &[u8]) -> Option<String> {
let text = std::str::from_utf8(bytes).ok()?;
for line in text.lines().take(40) {
let l = line.trim_start();
if let Some(rest) = l.strip_prefix('#') {
let rest = rest.trim_start();
if let Some(v) = rest.strip_prefix("version ") {
let token = v.split_whitespace().next()?;
return Some(token.to_string());
}
}
}
None
}