use crate::compile::{compile_zone, compile_zone_styled};
use crate::diagnostics::Diagnostic;
use crate::error::{Error, Result};
use crate::fs::output_tree;
use crate::model::{Database, ZoneEra, ZoneRules};
use crate::tzif::write_bytes;
use crate::{
CompileConfig, CompileReport, LinkReport, UnsupportedPolicy, ZoneReport, ZoneSelection,
};
struct Staged {
name: String,
bytes: Vec<u8>,
version: u8,
transition_count: usize,
}
const MIN_PORTABLE_ABBR_LEN: usize = 3;
const MAX_PORTABLE_ABBR_LEN: usize = 6;
fn is_posix_abbr_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '+' || c == '-'
}
fn collect_abbreviation_warnings(
db: &Database,
name: &str,
data: &crate::tzif::TzifData,
out: &mut Vec<Diagnostic>,
) {
use crate::diagnostics::{Diagnostic, DiagnosticCode};
let origin = match db.zones.iter().find(|z| z.name == name) {
Some(z) => &z.origin,
None => return, };
let mut seen: Vec<&str> = Vec::new();
for ty in &data.types {
let abbr = ty.abbr.as_str();
if abbr.is_empty() || seen.contains(&abbr) {
continue; }
seen.push(abbr);
let conforming_prefix = abbr.chars().take_while(|&c| is_posix_abbr_char(c)).count();
let total = abbr.chars().count();
let (code, msg, verbose) = if conforming_prefix < total {
(
DiagnosticCode::AbbreviationNotPosix,
format!("time zone abbreviation {abbr:?} differs from the POSIX standard (non-alphanumeric, non-+/- character)"),
false,
)
} else if conforming_prefix > MAX_PORTABLE_ABBR_LEN {
(
DiagnosticCode::AbbreviationPolicyViolation,
format!("time zone abbreviation {abbr:?} has too many characters ({total} > {MAX_PORTABLE_ABBR_LEN})"),
false,
)
} else if conforming_prefix < MIN_PORTABLE_ABBR_LEN {
(
DiagnosticCode::AbbreviationPolicyViolation,
format!("time zone abbreviation {abbr:?} has fewer than {MIN_PORTABLE_ABBR_LEN} characters ({total})"),
true, )
} else {
continue; };
let mut d = Diagnostic::warning(code, msg, &origin.file, origin.line);
if verbose {
d = d.verbose_only();
}
out.push(d);
}
}
const ZONE_NAME_COMPONENT_LEN_MAX: usize = 14;
fn is_benign_name_byte(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'-' || b == b'_'
}
fn collect_zone_name_warnings(
name: &str,
file: &std::path::Path,
line: usize,
out: &mut Vec<Diagnostic>,
) {
use crate::diagnostics::{Diagnostic, DiagnosticCode};
if let Some(&b) = name
.as_bytes()
.iter()
.find(|&&b| b != b'/' && !is_benign_name_byte(b))
{
let shown = if b.is_ascii_graphic() {
format!("'{}'", b as char)
} else {
format!("byte \\{b:03o}")
};
out.push(
Diagnostic::warning(
DiagnosticCode::ZoneNameNonPortableByte,
format!(
"zone/link name {name:?} contains non-portable {shown} (portable set: ASCII letters, '-', '_')"
),
file,
line,
)
.verbose_only(),
);
}
if let Some(comp) = name
.split('/')
.find(|c| c.len() > ZONE_NAME_COMPONENT_LEN_MAX)
{
out.push(
Diagnostic::warning(
DiagnosticCode::ZoneNameOverlengthComponent,
format!(
"zone/link name {name:?} has an overlength component {comp:?} (> {ZONE_NAME_COMPONENT_LEN_MAX} bytes)"
),
file,
line,
)
.verbose_only(),
);
}
}
const TRANSITION_WARN_THRESHOLD: usize = 1200;
fn collect_transition_count_warning(
db: &Database,
name: &str,
data: &crate::tzif::TzifData,
out: &mut Vec<Diagnostic>,
) {
use crate::diagnostics::{Diagnostic, DiagnosticCode};
let timecnt = data.transitions.len();
if timecnt <= TRANSITION_WARN_THRESHOLD {
return;
}
let origin = match db.zones.iter().find(|z| z.name == name) {
Some(z) => &z.origin,
None => return,
};
out.push(
Diagnostic::warning(
DiagnosticCode::TooManyTransitionsForLegacyClient,
format!(
"{timecnt} transition times — more than {TRANSITION_WARN_THRESHOLD}; pre-2014 clients may mishandle this zone"
),
&origin.file,
origin.line,
)
.verbose_only(),
);
}
const TWENTY_FOUR_HOURS_SECS: i64 = 24 * 60 * 60;
fn value_over_24h(seconds: i32) -> bool {
i64::from(seconds).unsigned_abs() > TWENTY_FOUR_HOURS_SECS as u64
}
fn collect_over_24h_warnings(db: &Database, out: &mut Vec<Diagnostic>) {
use crate::diagnostics::{Diagnostic, DiagnosticCode};
let mut warn = |seconds: i32, kind: &str, file: &std::path::Path, line: usize| {
if value_over_24h(seconds) {
out.push(
Diagnostic::warning(
DiagnosticCode::ValueOverTwentyFourHours,
format!(
"{kind} value {seconds}s exceeds 24:00:00 — not handled by pre-2007 versions of zic"
),
file,
line,
)
.verbose_only(),
);
}
};
for recs in db.rules.values() {
for r in recs {
warn(r.at.seconds, "rule AT", &r.origin.file, r.origin.line);
warn(r.save.seconds, "rule SAVE", &r.origin.file, r.origin.line);
}
}
for z in &db.zones {
for era in &z.eras {
warn(era.stdoff.0, "STDOFF", &era.origin.file, era.origin.line);
if let crate::model::ZoneRules::Save(s) = &era.rules {
warn(s.seconds, "inline SAVE", &era.origin.file, era.origin.line);
}
if let Some(until) = &era.until {
warn(
until.time.seconds,
"UNTIL",
&era.origin.file,
era.origin.line,
);
}
}
}
}
pub fn run(db: &Database, config: &CompileConfig) -> Result<CompileReport> {
let mut report = CompileReport::default();
collect_over_24h_warnings(db, &mut report.diagnostics);
let requested = select_zones(db, &config.zones);
let mut canonical_zones: Vec<String> = Vec::new();
for name in &requested {
let canonical = canonical_zone_name(db, name);
if !canonical_zones.contains(&canonical) {
canonical_zones.push(canonical);
}
}
let localtime_link: Option<(String, String)> = match &config.localtime {
None => None,
Some(zone) => {
let canonical = canonical_zone_name(db, zone);
if !canonical_zones.contains(&canonical) {
return Err(Error::config(format!(
"--localtime {zone:?} resolves to {canonical:?}, which is not among the \
selected zones; also select it (e.g. --zone {canonical}, or --all-supported)"
)));
}
let name = config.localtime_name.as_deref().unwrap_or("localtime");
output_tree::safe_relative_path(name)?; Some((name.to_string(), canonical))
}
};
if config.file_mode.is_some() && !cfg!(unix) {
return Err(Error::config(
"--mode (file permission bits) is only supported on Unix platforms",
));
}
let mut staged: Vec<Staged> = Vec::new();
for name in &canonical_zones {
let emit = crate::EmitOptions {
style: config.emit_style,
redundant_until: config.redundant_until,
range: config.range,
};
match compile_zone_styled(db, name, emit) {
Ok(mut data) => {
if let Some(table) = &config.leaps {
crate::compile::apply_leaps(&mut data, table, config.range)?;
}
collect_abbreviation_warnings(db, name, &data, &mut report.diagnostics);
collect_transition_count_warning(db, name, &data, &mut report.diagnostics);
if let Some(z) = db.zones.iter().find(|z| z.name == *name) {
collect_zone_name_warnings(
name,
&z.origin.file,
z.origin.line,
&mut report.diagnostics,
);
}
let bytes = write_bytes(&data)?;
output_tree::safe_relative_path(name)?;
staged.push(Staged {
name: name.clone(),
bytes,
version: data.version,
transition_count: data.transitions.len(),
});
}
Err(e) => match (config.unsupported_policy, e.diagnostic()) {
(UnsupportedPolicy::WarnAndSkipZone, Some(d)) => {
let mut warn = d.clone();
warn.severity = crate::Severity::Warning;
report.diagnostics.push(warn);
}
_ => return Err(e),
},
}
}
if config.no_create_dirs {
if !config.output_dir.is_dir() {
return Err(Error::config(format!(
"output directory {} does not exist (and --no-create-dirs was given)",
config.output_dir.display()
)));
}
} else {
std::fs::create_dir_all(&config.output_dir)
.map_err(|e| Error::io(&config.output_dir, e))?;
}
let mut compiled: Vec<String> = Vec::new();
for z in &staged {
let path = output_tree::write_zone_file(
&config.output_dir,
&z.name,
&z.bytes,
config.overwrite,
true,
)?;
if let Some(mode) = config.file_mode {
output_tree::set_file_mode(&path, mode)?; }
report.zones_compiled.push(ZoneReport {
name: z.name.clone(),
output_path: path,
tzif_version: z.version,
transition_count: z.transition_count,
});
compiled.push(z.name.clone());
}
for link in &db.links {
let canonical = match crate::resolve_link_target(db, &link.link_name) {
Ok(t) => t.to_string(),
Err(_) => continue, };
if !compiled.contains(&canonical) {
continue;
}
let link_path = output_tree::write_link(
&config.output_dir,
&link.link_name,
&canonical,
config.link_mode,
config.overwrite,
true, )?;
apply_link_mode(&link_path, config)?;
collect_zone_name_warnings(
&link.link_name,
&link.origin.file,
link.origin.line,
&mut report.diagnostics,
);
report.links_written.push(LinkReport {
link_name: link.link_name.clone(),
target: canonical,
mode: config.link_mode,
});
}
if let Some((name, canonical)) = localtime_link {
if compiled.contains(&canonical) {
let link_path = output_tree::write_link(
&config.output_dir,
&name,
&canonical,
config.link_mode,
config.overwrite,
true, )?;
apply_link_mode(&link_path, config)?;
report.links_written.push(LinkReport {
link_name: name,
target: canonical,
mode: config.link_mode,
});
} else {
report.diagnostics.push(Diagnostic::warning(
crate::DiagnosticCode::UnsupportedDirective,
format!(
"localtime target {canonical:?} was skipped (unsupported); no {name:?} link written"
),
std::path::Path::new("<output>"),
0,
));
}
}
Ok(report)
}
fn apply_link_mode(link_path: &std::path::Path, config: &CompileConfig) -> Result<()> {
match (config.file_mode, config.link_mode) {
(Some(mode), crate::LinkMode::Copy) => output_tree::set_file_mode(link_path, mode),
_ => Ok(()),
}
}
fn canonical_zone_name(db: &Database, name: &str) -> String {
if db.zone(name).is_some() {
return name.to_string();
}
crate::resolve_link_target(db, name)
.map(|t| t.to_string())
.unwrap_or_else(|_| name.to_string())
}
pub fn select_zones(db: &Database, sel: &ZoneSelection) -> Vec<String> {
match sel {
ZoneSelection::One(z) => vec![z.clone()],
ZoneSelection::Many(zs) => zs.clone(),
ZoneSelection::AllSupported => {
db.zones.iter().map(|z| z.name.clone()).collect()
}
}
}
pub fn explain(db: &Database, zone: &str) -> std::result::Result<String, Diagnostic> {
let mut out = String::new();
let canonical = if db.zone(zone).is_some() {
zone.to_string()
} else {
match crate::resolve_link_target(db, zone) {
Ok(target) => {
out.push_str(&format!("{zone}: link alias -> canonical zone {target}\n"));
target.to_string()
}
Err(e) => {
return Err(Diagnostic::error(
crate::DiagnosticCode::InvalidValue,
e.to_string(),
std::path::Path::new("<input>"),
0,
))
}
}
};
if let Some(zrec) = db.zone(&canonical) {
out.push_str(&format!(
"{canonical}: {} era(s) [{}:{}]\n",
zrec.eras.len(),
zrec.origin.file.display(),
zrec.origin.line
));
for (i, era) in zrec.eras.iter().enumerate() {
out.push_str(&format!(" era {}: {}\n", i + 1, era_summary(era)));
}
let aliases: Vec<&str> = db
.links
.iter()
.filter(|l| {
crate::resolve_link_target(db, &l.link_name).ok() == Some(canonical.as_str())
})
.map(|l| l.link_name.as_str())
.collect();
if !aliases.is_empty() {
out.push_str(&format!(" aliases (links): {}\n", aliases.join(", ")));
}
}
let data = match compile_zone(db, &canonical) {
Ok(d) => d,
Err(Error::Diagnostic(d)) => return Err(*d),
Err(e) => {
return Err(Diagnostic::error(
crate::DiagnosticCode::InvalidValue,
e.to_string(),
std::path::Path::new("<input>"),
0,
))
}
};
out.push_str(&format!(
" compiled: TZif v{} — {} local-time-type(s), {} transition(s); footer {:?}\n",
data.version as char,
data.types.len(),
data.transitions.len(),
data.footer
));
out.push_str(" types:\n");
for (i, t) in data.types.iter().enumerate() {
out.push_str(&format!(
" [{i}] utoff={} is_dst={} abbr={:?}\n",
fmt_offset(t.utoff),
t.is_dst,
t.abbr
));
}
if data.transitions.is_empty() {
out.push_str(" transitions: none (fixed offset / footer-only)\n");
} else {
out.push_str(" transitions (UT instant -> type):\n");
let n = data.transitions.len();
let show: Vec<usize> = if n <= 8 {
(0..n).collect()
} else {
(0..4).chain(n - 2..n).collect()
};
let mut last_shown: Option<usize> = None;
for idx in show {
if let Some(prev) = last_shown {
if idx != prev + 1 {
out.push_str(&format!(" … ({} more)\n", idx - prev - 1));
}
}
let tr = &data.transitions[idx];
let ty = &data.types[tr.type_index as usize];
out.push_str(&format!(
" {} -> [{}] {} {}\n",
fmt_instant(tr.at),
tr.type_index,
fmt_offset(ty.utoff),
ty.abbr
));
last_shown = Some(idx);
}
}
let mut by_off: std::collections::BTreeMap<i32, Vec<usize>> = std::collections::BTreeMap::new();
for (i, t) in data.types.iter().enumerate() {
by_off.entry(t.utoff).or_default().push(i);
}
for (off, idxs) in by_off.iter().filter(|(_, v)| v.len() > 1) {
let desc: Vec<String> = idxs
.iter()
.map(|&i| format!("[{i}] {} dst={}", data.types[i].abbr, data.types[i].is_dst))
.collect();
out.push_str(&format!(
" note: utoff {} is shared by distinct types ({}) — kept as separate types \
(offset equal, DST/abbreviation differ)\n",
fmt_offset(*off),
desc.join(", ")
));
}
Ok(out)
}
fn era_summary(era: &ZoneEra) -> String {
let rules = match &era.rules {
ZoneRules::None => "-".to_string(),
ZoneRules::Named(n) => format!("rules {n}"),
ZoneRules::Save(s) => format!("inline save {}", fmt_offset(s.seconds)),
};
let until = match &era.until {
Some(u) => {
let suffix = match u.time.reference {
crate::model::TimeRef::Wall => "w",
crate::model::TimeRef::Standard => "s",
crate::model::TimeRef::Universal => "u",
};
format!(
" UNTIL {}-{:02}-{} {}{}",
u.year,
u.month,
fmt_on_day(u.day),
fmt_signed(u.time.seconds),
suffix
)
}
None => " (final era)".to_string(),
};
format!(
"STDOFF {} | RULES {} | FORMAT {:?}{}",
fmt_offset(era.stdoff.0),
rules,
era.format,
until
)
}
fn fmt_on_day(on: crate::model::calendar::OnDay) -> String {
use crate::model::calendar::OnDay;
let wd = |w: crate::model::calendar::Weekday| format!("{w:?}");
match on {
OnDay::Day(d) => d.to_string(),
OnDay::Last(w) => format!("last{}", wd(w)),
OnDay::OnAfter(w, n) => format!("{}>={n}", wd(w)),
OnDay::OnBefore(w, n) => format!("{}<={n}", wd(w)),
}
}
fn fmt_offset(secs: i32) -> String {
fmt_signed(secs)
}
fn fmt_signed(secs: i32) -> String {
let sign = if secs < 0 { "-" } else { "" };
let a = secs.unsigned_abs();
let (h, m, s) = (a / 3600, (a % 3600) / 60, a % 60);
if s != 0 {
format!("{sign}{h}:{m:02}:{s:02}")
} else if m != 0 {
format!("{sign}{h}:{m:02}")
} else {
format!("{sign}{h}")
}
}
fn fmt_instant(secs: i64) -> String {
let (y, mo, d) = crate::model::calendar::civil_from_days(secs.div_euclid(86400));
let tod = secs.rem_euclid(86400);
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}Z")
}