use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use crate::compile::plan;
use crate::error::{Error, Result};
use crate::{CompileConfig, LinkMode, UnsupportedPolicy, ZoneSelection, DEFAULT_TRANSITION_LIMIT};
#[derive(Debug, Parser)]
#[command(
name = "zic-rs",
about = "A memory-safe Rust timezone compiler for IANA tzdata (declared subset).",
version
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Subcommand)]
pub enum Command {
Compile(CompileArgs),
Compare(CompareArgs),
Explain(ExplainArgs),
SupportedSyntax,
SupportReport(SupportReportArgs),
StructuralReport(StructuralReportArgs),
SemanticReport(SemanticReportArgs),
TzifValidate(TzifValidateArgs),
AuxTableValidate(AuxTableValidateArgs),
VendorOracleSample,
VendorOracleAdmit(VendorOracleAdmitArgs),
ReleaseDiff(ReleaseDiffArgs),
Doctor(DoctorArgs),
SizeReport(SizeReportArgs),
}
#[derive(Debug, Args)]
pub struct CompileArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "out")]
pub out: Option<PathBuf>,
#[arg(long = "zone")]
pub zone: Option<String>,
#[arg(long = "zones")]
pub zones: Option<PathBuf>,
#[arg(long = "all-supported", default_value_t = false)]
pub all_supported: bool,
#[arg(long = "link-mode", default_value = "copy")]
pub link_mode: LinkModeArg,
#[arg(long = "force", default_value_t = false)]
pub force: bool,
#[arg(long = "unsupported", default_value = "error")]
pub unsupported: UnsupportedArg,
#[arg(long = "transition-limit", default_value_t = DEFAULT_TRANSITION_LIMIT)]
pub transition_limit: usize,
#[arg(long = "emit-style", value_enum, default_value_t = EmitStyleArg::Default)]
pub emit_style: EmitStyleArg,
#[arg(long = "bloat", short = 'b', value_enum)]
pub bloat: Option<BloatArg>,
#[arg(long = "redundant-until", short = 'R')]
pub redundant_until: Option<String>,
#[arg(long = "range", short = 'r')]
pub range: Option<String>,
#[arg(long = "leapseconds", short = 'L')]
pub leapseconds: Option<PathBuf>,
#[arg(long = "no-create-dirs", short = 'D', default_value_t = false)]
pub no_create_dirs: bool,
#[arg(long = "localtime", short = 'l')]
pub localtime: Option<String>,
#[arg(long = "localtime-name", short = 't')]
pub localtime_name: Option<String>,
#[arg(long = "mode", short = 'm')]
pub mode: Option<String>,
#[arg(long = "alias-map")]
pub alias_map: Option<PathBuf>,
#[arg(long = "manifest")]
pub manifest: Option<PathBuf>,
#[arg(long = "tzdb-version")]
pub tzdb_version: Option<String>,
#[arg(long = "backward", value_parser = ["included", "excluded"])]
pub backward: Option<String>,
#[arg(long = "backward-source")]
pub backward_source: Option<PathBuf>,
#[arg(long = "backzone", value_parser = ["included", "excluded"])]
pub backzone: Option<String>,
#[arg(long = "packratlist", value_parser = ["full", "subset", "none"])]
pub packratlist: Option<String>,
#[arg(long = "packratlist-source")]
pub packratlist_source: Option<PathBuf>,
#[arg(long = "dataform", value_parser = ["main", "vanguard", "rearguard"])]
pub dataform: Option<String>,
#[arg(long = "verbose", short = 'v', default_value_t = false)]
pub verbose: bool,
}
#[derive(Debug, Args)]
pub struct CompareArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "zone")]
pub zone: String,
#[arg(long = "reference-zic", default_value = "zic")]
pub reference_zic: String,
#[arg(long = "mode", default_value = "zdump")]
pub mode: CompareModeArg,
#[arg(long = "horizon", default_value = "1900,2100")]
pub horizon: String,
#[arg(long = "zdump", default_value = "zdump")]
pub zdump: String,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum CompareModeArg {
Zdump,
Structural,
}
#[derive(Debug, Args)]
pub struct ExplainArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "zone")]
pub zone: String,
}
#[derive(Debug, Args)]
pub struct SupportReportArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
pub format: ReportFormatArg,
#[arg(long = "explain-buckets", default_value_t = false)]
pub explain_buckets: bool,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ReportFormatArg {
Text,
Json,
}
#[derive(Debug, Args)]
pub struct StructuralReportArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "reference-zic", default_value = "zic")]
pub reference_zic: String,
#[arg(long = "zone")]
pub zone: Option<String>,
#[arg(long = "emit-style", value_enum, default_value_t = EmitStyleArg::Default)]
pub emit_style: EmitStyleArg,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
pub format: ReportFormatArg,
}
#[derive(Debug, clap::Args)]
pub struct SemanticReportArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "reference-zic", default_value = "zic")]
pub reference_zic: String,
#[arg(long = "zdump", default_value = "zdump")]
pub zdump: String,
#[arg(long = "zone")]
pub zone: Vec<String>,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
pub format: ReportFormatArg,
}
#[derive(Debug, clap::Args)]
pub struct TzifValidateArgs {
#[arg(long = "input", required = true, num_args = 1..)]
pub input: Vec<PathBuf>,
#[arg(long = "reference-zic", default_value = "zic")]
pub reference_zic: String,
#[arg(long = "zone")]
pub zone: Vec<String>,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
pub format: ReportFormatArg,
}
#[derive(Debug, clap::Args)]
pub struct AuxTableValidateArgs {
#[arg(long = "zone-tab")]
pub zone_tab: Option<PathBuf>,
#[arg(long = "zone1970-tab")]
pub zone1970_tab: Option<PathBuf>,
#[arg(long = "zonenow-tab")]
pub zonenow_tab: Option<PathBuf>,
#[arg(long = "iso3166-tab")]
pub iso3166_tab: Option<PathBuf>,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
pub format: ReportFormatArg,
}
#[derive(Debug, clap::Args)]
pub struct VendorOracleAdmitArgs {
#[arg(long = "receipt", required = true)]
pub receipt: PathBuf,
}
#[derive(Debug, clap::Args)]
pub struct ReleaseDiffArgs {
#[arg(long = "old", required = true)]
pub old: Vec<PathBuf>,
#[arg(long = "new", required = true)]
pub new: Vec<PathBuf>,
#[arg(long = "zone")]
pub zone: Option<String>,
#[arg(long = "horizon", default_value = "1900,2040")]
pub horizon: String,
#[arg(long = "split", default_value_t = 2025)]
pub split: i32,
#[arg(long = "reference-zdump")]
pub reference_zdump: Option<String>,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
pub format: ReportFormatArg,
}
#[derive(Debug, clap::Args)]
pub struct DoctorArgs {
#[arg(long = "reference-zic", default_value = "zic")]
pub reference_zic: String,
#[arg(long = "reference-zdump", default_value = "zdump")]
pub reference_zdump: String,
#[arg(long = "tzdata")]
pub tzdata: Option<PathBuf>,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
pub format: ReportFormatArg,
}
#[derive(Debug, Args)]
pub struct SizeReportArgs {
#[arg(long = "out")]
pub out: PathBuf,
#[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
pub format: ReportFormatArg,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum EmitStyleArg {
Default,
ZicSlim,
ZicFat,
}
impl From<EmitStyleArg> for crate::EmitStyle {
fn from(a: EmitStyleArg) -> Self {
match a {
EmitStyleArg::Default => crate::EmitStyle::Default,
EmitStyleArg::ZicSlim => crate::EmitStyle::ZicSlim,
EmitStyleArg::ZicFat => crate::EmitStyle::ZicFat,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum BloatArg {
Slim,
Fat,
}
impl From<BloatArg> for EmitStyleArg {
fn from(b: BloatArg) -> Self {
match b {
BloatArg::Slim => EmitStyleArg::ZicSlim,
BloatArg::Fat => EmitStyleArg::ZicFat,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum LinkModeArg {
Copy,
Symlink,
}
impl From<LinkModeArg> for LinkMode {
fn from(a: LinkModeArg) -> Self {
match a {
LinkModeArg::Copy => LinkMode::Copy,
LinkModeArg::Symlink => LinkMode::Symlink,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum UnsupportedArg {
Error,
Skip,
}
impl From<UnsupportedArg> for UnsupportedPolicy {
fn from(a: UnsupportedArg) -> Self {
match a {
UnsupportedArg::Error => UnsupportedPolicy::Error,
UnsupportedArg::Skip => UnsupportedPolicy::WarnAndSkipZone,
}
}
}
pub fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Compile(args) => run_compile(args),
Command::Compare(args) => run_compare(args),
Command::Explain(args) => run_explain(args),
Command::SupportedSyntax => {
print!("{}", supported_syntax_text());
Ok(())
}
Command::SupportReport(args) => run_support_report(args),
Command::StructuralReport(args) => run_structural_report(args),
Command::SemanticReport(args) => run_semantic_report(args),
Command::TzifValidate(args) => run_tzif_validate(args),
Command::AuxTableValidate(args) => run_aux_table_validate(args),
Command::VendorOracleSample => {
print!(
"{}",
crate::vendor_oracle::VendorOracleReceipt::minimal_sample().to_json()
);
Ok(())
}
Command::VendorOracleAdmit(args) => run_vendor_oracle_admit(args),
Command::ReleaseDiff(args) => run_release_diff(args),
Command::Doctor(args) => run_doctor(args),
Command::SizeReport(args) => run_size_report(args),
}
}
fn run_support_report(args: SupportReportArgs) -> Result<()> {
let tzdb_version = std::fs::read(&args.input[0])
.ok()
.and_then(|b| crate::report::sniff_tzdb_version(&b));
let db = crate::load_database(&args.input)?;
let report = crate::report::build_support_report(&db, tzdb_version);
match args.format {
ReportFormatArg::Text if args.explain_buckets => print!("{}", report.to_text_explained()),
ReportFormatArg::Text => print!("{}", report.to_text()),
ReportFormatArg::Json => print!("{}", report.to_json()),
}
Ok(())
}
fn run_structural_report(args: StructuralReportArgs) -> Result<()> {
if !crate::compare::reference_zic::is_available(&args.reference_zic) {
return Err(Error::config(format!(
"reference zic `{}` not found; structural-report compares against it",
args.reference_zic
)));
}
let tzdb_version = std::fs::read(&args.input[0])
.ok()
.and_then(|b| crate::report::sniff_tzdb_version(&b));
let db = crate::load_database(&args.input)?;
let files = crate::collect_source_files(&args.input)?;
let work = tempfile::Builder::new()
.prefix("zic-rs-structural-")
.tempdir()
.map_err(|e| Error::io(std::env::temp_dir(), e))?;
let report = crate::structural::build_structural_report(
&db,
&files,
&args.reference_zic,
work.path(),
args.zone.as_deref(),
tzdb_version,
args.emit_style.into(),
)?;
match args.format {
ReportFormatArg::Text => print!("{}", report.to_text()),
ReportFormatArg::Json => print!("{}", report.to_json()),
}
Ok(())
}
fn run_semantic_report(args: SemanticReportArgs) -> Result<()> {
let db = crate::load_database(&args.input)?;
let files = crate::collect_source_files(&args.input)?;
let zones: Vec<String> = if !args.zone.is_empty() {
args.zone.clone()
} else {
const CURATED: &[&str] = &[
"Etc/UTC",
"America/New_York",
"Europe/London",
"Australia/Sydney",
"Asia/Kolkata",
];
let present: Vec<String> = CURATED
.iter()
.filter(|z| db.zone(z).is_some())
.map(|z| z.to_string())
.collect();
if present.is_empty() {
db.zones.iter().take(3).map(|z| z.name.clone()).collect()
} else {
present
}
};
let work = tempfile::Builder::new()
.prefix("zic-rs-semantic-")
.tempdir()
.map_err(|e| Error::io(std::env::temp_dir(), e))?;
let report = crate::semantic_witness::build_semantic_witness_report(
&db,
&zones,
&args.reference_zic,
&args.zdump,
&files,
work.path(),
)?;
match args.format {
ReportFormatArg::Json => print!("{}", report.to_json()),
ReportFormatArg::Text => {
println!(
"semantic witnesses (oracle_mode: {})",
report.oracle_mode.mode_str()
);
for w in &report.witnesses {
println!(" {} @ {}: {}", w.zone, w.timestamp, w.verdict.as_str());
}
}
}
Ok(())
}
fn run_tzif_validate(args: TzifValidateArgs) -> Result<()> {
let db = crate::load_database(&args.input)?;
let files = crate::collect_source_files(&args.input)?;
let zones: Vec<String> = if !args.zone.is_empty() {
args.zone.clone()
} else {
const CURATED: &[&str] = &["Etc/UTC", "America/New_York", "Europe/London", "Asia/Gaza"];
let present: Vec<String> = CURATED
.iter()
.filter(|z| db.zone(z).is_some())
.map(|z| z.to_string())
.collect();
if present.is_empty() {
db.zones.iter().take(3).map(|z| z.name.clone()).collect()
} else {
present
}
};
let work = tempfile::Builder::new()
.prefix("zic-rs-tzif-validate-")
.tempdir()
.map_err(|e| Error::io(std::env::temp_dir(), e))?;
let report = crate::tzif::rfc9636::build_validation_report(
&db,
&zones,
&args.reference_zic,
&files,
work.path(),
)?;
match args.format {
ReportFormatArg::Json => print!("{}", report.to_json()),
ReportFormatArg::Text => {
println!(
"TZif structural validation (reference validated: {})",
report.reference_validated
);
for row in &report.rows {
println!(
" [{}] {} : {}{}",
row.producer,
row.zone,
row.validation.structural.as_str(),
if row.validation.violations.is_empty() {
String::new()
} else {
format!(" — {}", row.validation.violations.join("; "))
}
);
}
}
}
Ok(())
}
fn run_aux_table_validate(args: AuxTableValidateArgs) -> Result<()> {
use crate::aux_tables::{
iso3166_codes, validate_zone_table, AuxTableValidationReport, InstallEcologyStatus,
ZoneTableKind,
};
let read =
|p: &PathBuf| -> Result<Vec<u8>> { std::fs::read(p).map_err(|e| Error::io(p.clone(), e)) };
let iso_bytes = args.iso3166_tab.as_ref().map(read).transpose()?;
let iso_codes = iso_bytes.as_ref().map(|b| iso3166_codes(b));
let mut tables = Vec::new();
if let Some(p) = &args.zone_tab {
tables.push(validate_zone_table(
ZoneTableKind::ZoneTab,
&read(p)?,
iso_codes.as_ref(),
));
}
if let Some(p) = &args.zone1970_tab {
tables.push(validate_zone_table(
ZoneTableKind::Zone1970Tab,
&read(p)?,
iso_codes.as_ref(),
));
}
if let Some(p) = &args.zonenow_tab {
tables.push(validate_zone_table(
ZoneTableKind::ZonenowTab,
&read(p)?,
None,
));
}
if let Some(b) = &iso_bytes {
tables.push(validate_zone_table(ZoneTableKind::Iso3166Tab, b, None));
}
let report = AuxTableValidationReport {
tables,
install_ecology: InstallEcologyStatus::current(),
};
match args.format {
ReportFormatArg::Json => print!("{}", report.to_json()),
ReportFormatArg::Text => {
println!("auxiliary-table validation (structural admissibility only)");
for t in &report.tables {
println!(
" {} [{}] : {} ({} rows, {} findings)",
t.kind.as_str(),
t.kind.coverage(),
t.verdict.as_str(),
t.rows_checked,
t.findings.len()
);
}
}
}
Ok(())
}
fn run_vendor_oracle_admit(args: VendorOracleAdmitArgs) -> Result<()> {
let bytes = std::fs::read(&args.receipt).map_err(|e| Error::io(args.receipt.clone(), e))?;
let text = String::from_utf8(bytes)
.map_err(|_| Error::config("receipt is not valid UTF-8 (parse error)"))?;
match crate::vendor_oracle::VendorOracleReceipt::from_json(&text) {
Err(e) => Err(Error::config(format!("receipt parse error: {e}"))),
Ok(receipt) => {
let verdict = receipt.admit();
println!(
"vendor-oracle receipt: platform={} fixture_set={} → admission={}",
receipt.platform,
receipt.fixture_set,
verdict.as_str()
);
if verdict.is_admitted() {
Ok(())
} else {
Err(Error::config(format!(
"receipt not admitted: {}",
verdict.as_str()
)))
}
}
}
}
fn run_release_diff(args: ReleaseDiffArgs) -> Result<()> {
use crate::release_diff::{build_release_diff, ReleaseDiffOptions};
let (lo, hi) = args
.horizon
.split_once(',')
.and_then(|(a, b)| Some((a.trim().parse::<i32>().ok()?, b.trim().parse::<i32>().ok()?)))
.ok_or_else(|| {
Error::config(format!(
"invalid --horizon {:?} (expected LO,HI)",
args.horizon
))
})?;
if hi < lo {
return Err(Error::config("--horizon HI must be >= LO"));
}
let old_db = crate::load_database(&args.old)?;
let new_db = crate::load_database(&args.new)?;
let opts = ReleaseDiffOptions {
horizon: (lo, hi),
split: args.split,
zone_filter: args.zone,
zdump_program: args.reference_zdump,
};
let report = build_release_diff(&old_db, &new_db, &opts)?;
match args.format {
ReportFormatArg::Json => print!("{}", report.to_json()),
ReportFormatArg::Text => {
println!(
"release-diff (horizon {}..{}, split {}, oracle={})",
report.horizon.0,
report.horizon.1,
report.split,
report.oracle_mode.mode_str()
);
for (kind, n) in report.kind_counts() {
if n > 0 {
println!(" {n:5} {kind}");
}
}
if !report.errors.is_empty() {
println!(
" {} identifier(s) not comparable (outside zic-rs subset):",
report.errors.len()
);
for e in &report.errors {
println!(" {} — {}", e.name, e.reason);
}
}
}
}
Ok(())
}
fn run_doctor(args: DoctorArgs) -> Result<()> {
use crate::doctor::{run_doctor as probe, DoctorOptions};
let report = probe(&DoctorOptions {
reference_zic: args.reference_zic,
reference_zdump: args.reference_zdump,
tzdata: args.tzdata,
})?;
match args.format {
ReportFormatArg::Json => print!("{}", report.to_json()),
ReportFormatArg::Text => print!("{}", report.to_text()),
}
Ok(())
}
fn run_size_report(args: SizeReportArgs) -> Result<()> {
use crate::size_report::{run_size_report as measure, SizeReportOptions};
let report = measure(&SizeReportOptions { out: args.out })?;
match args.format {
ReportFormatArg::Json => print!("{}", report.to_json()),
ReportFormatArg::Text => print!("{}", report.to_text()),
}
Ok(())
}
fn load_leap_table(path: Option<&std::path::Path>) -> Result<Option<crate::model::LeapTable>> {
let Some(p) = path else { return Ok(None) };
let bytes = std::fs::read(p).map_err(|e| Error::io(p, e))?;
Ok(Some(crate::source::parse_leap_source(&bytes, p)?))
}
fn parse_octal_mode(raw: Option<&str>) -> Result<Option<u32>> {
let Some(s) = raw else { return Ok(None) };
let digits = s.strip_prefix("0o").unwrap_or(s);
let bits = u32::from_str_radix(digits, 8)
.map_err(|_| Error::config(format!("--mode {s:?} is not a valid octal mode (e.g. 644)")))?;
if bits > 0o7777 {
return Err(Error::config(format!(
"--mode {s:?} is out of range (max 7777)"
)));
}
Ok(Some(bits))
}
fn reconcile_emit_style(style: EmitStyleArg, bloat: Option<BloatArg>) -> Result<crate::EmitStyle> {
match bloat {
None => Ok(style.into()),
Some(b) => {
let from_bloat: EmitStyleArg = b.into();
if style != EmitStyleArg::Default && style != from_bloat {
let bn = match b {
BloatArg::Slim => "slim",
BloatArg::Fat => "fat",
};
let sn = match style {
EmitStyleArg::ZicSlim => "zic-slim",
EmitStyleArg::ZicFat => "zic-fat",
EmitStyleArg::Default => "default",
};
return Err(Error::config(format!(
"-b {bn} conflicts with --emit-style {sn} (incompatible emission options)"
)));
}
Ok(from_bloat.into())
}
}
}
fn parse_redundant_until(raw: Option<&str>) -> Result<Option<i64>> {
let Some(s) = raw else { return Ok(None) };
let body = s.strip_prefix('@').ok_or_else(|| {
Error::config(format!(
"-R expects an @-prefixed Unix-seconds instant (e.g. @4102444800), got {s:?}"
))
})?;
let secs = body
.parse::<i64>()
.map_err(|_| Error::config(format!("-R {s:?} is not a valid @<seconds> instant")))?;
Ok(Some(secs))
}
fn parse_range(raw: Option<&str>) -> Result<Option<crate::RangeSpec>> {
let Some(s) = raw else { return Ok(None) };
let parse_at = |part: &str, which: &str| -> Result<i64> {
part.strip_prefix('@')
.and_then(|b| b.parse::<i64>().ok())
.ok_or_else(|| {
Error::config(format!(
"-r {which} bound {part:?} must be an @-prefixed Unix-seconds instant (e.g. @0)"
))
})
};
let (lo, hi) = match s.split_once('/') {
Some((lo_str, hi_str)) => {
let lo = if lo_str.is_empty() {
None
} else {
Some(parse_at(lo_str, "lo")?)
};
(lo, Some(parse_at(hi_str, "hi")?))
}
None => (Some(parse_at(s, "lo")?), None),
};
if lo.is_none() && hi.is_none() {
return Err(Error::config(
"-r requires @lo and/or /@hi (e.g. @0/@4102444800)",
));
}
if let (Some(l), Some(h)) = (lo, hi) {
if h < l {
return Err(Error::config(format!("-r hi (@{h}) is before lo (@{l})")));
}
}
Ok(Some(crate::RangeSpec { lo, hi }))
}
fn run_compile(args: CompileArgs) -> Result<()> {
let zones = resolve_selection(&args)?;
let output_dir = args
.out
.ok_or_else(|| Error::config("--out is required; there is no default output directory"))?;
let db = crate::load_database(&args.input)?;
let config = CompileConfig {
input_paths: args.input.clone(),
output_dir,
zones,
link_mode: args.link_mode.into(),
overwrite: args.force,
unsupported_policy: args.unsupported.into(),
transition_limit: args.transition_limit,
emit_style: reconcile_emit_style(args.emit_style, args.bloat)?,
no_create_dirs: args.no_create_dirs,
localtime: args.localtime.clone(),
localtime_name: args.localtime_name.clone(),
file_mode: parse_octal_mode(args.mode.as_deref())?,
redundant_until: parse_redundant_until(args.redundant_until.as_deref())?,
range: parse_range(args.range.as_deref())?,
leaps: load_leap_table(args.leapseconds.as_deref())?,
};
let report = plan::run(&db, &config)?;
for z in &report.zones_compiled {
println!(
"compiled {} -> {} (TZif v{}, {} transitions)",
z.name,
z.output_path.display(),
z.tzif_version as char, z.transition_count
);
}
for l in &report.links_written {
println!("linked {} -> {} ({:?})", l.link_name, l.target, l.mode);
}
for d in &report.diagnostics {
if args.verbose || d.verbosity == crate::diagnostics::DiagnosticVerbosity::AlwaysOn {
eprintln!("{d}");
}
}
if let Some(path) = &args.alias_map {
let map = crate::manifest::build(&report, &config.output_dir)?;
map.write_to(path)?;
println!("alias-map -> {}", path.display());
}
if let Some(path) = &args.manifest {
let requested = plan::select_zones(&db, &config.zones);
let source_files = crate::collect_source_files(&config.input_paths)?;
let variants = crate::manifest::SourceVariantArgs {
backward_claim: args.backward.as_deref().map(|v| v == "included"),
backward_source: args.backward_source.clone(),
backzone_claim: args.backzone.as_deref().map(|v| v == "included"),
packratlist_claim: args.packratlist.clone(),
packratlist_source: args.packratlist_source.clone(),
dataform_claim: args.dataform.clone(),
};
let manifest = crate::manifest::build_compile_manifest(
&requested,
&source_files,
&report,
&config,
&db,
args.tzdb_version.as_deref(),
args.leapseconds.as_deref(),
&variants,
)?;
manifest.write_to(path)?;
println!("manifest -> {}", path.display());
}
Ok(())
}
fn resolve_selection(args: &CompileArgs) -> Result<ZoneSelection> {
match (&args.zone, &args.zones, args.all_supported) {
(Some(z), None, false) => Ok(ZoneSelection::One(z.clone())),
(None, Some(path), false) => {
let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
let names: Vec<String> = text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.to_string())
.collect();
Ok(ZoneSelection::Many(names))
}
(None, None, true) => Ok(ZoneSelection::AllSupported),
_ => Err(Error::config(
"specify exactly one of --zone, --zones, or --all-supported",
)),
}
}
fn run_compare(args: CompareArgs) -> Result<()> {
let db = crate::load_database(&args.input)?;
let files = crate::collect_source_files(&args.input)?;
let mode = match args.mode {
CompareModeArg::Structural => crate::compare::CompareMode::Structural,
CompareModeArg::Zdump => {
let (lo, hi) = parse_horizon(&args.horizon)?;
crate::compare::CompareMode::Zdump {
program: args.zdump.clone(),
lo,
hi,
}
}
};
let work = tempfile::Builder::new()
.prefix("zic-rs-compare-")
.tempdir()
.map_err(|e| Error::io(std::env::temp_dir(), e))?;
let cmp = crate::compare::compare_zone(
&db,
&files,
&args.zone,
&args.reference_zic,
work.path(),
&mode,
)?;
println!("{}", cmp.summary());
if cmp.is_match() {
Ok(())
} else {
Err(Error::message(format!(
"{}: output disagrees with reference zic",
args.zone
)))
}
}
fn parse_horizon(s: &str) -> Result<(i32, i32)> {
let (lo, hi) = s
.split_once(',')
.ok_or_else(|| Error::config(format!("--horizon must be `LO,HI`, got {s:?}")))?;
let lo: i32 = lo
.trim()
.parse()
.map_err(|_| Error::config(format!("invalid horizon start {lo:?}")))?;
let hi: i32 = hi
.trim()
.parse()
.map_err(|_| Error::config(format!("invalid horizon end {hi:?}")))?;
if lo > hi {
return Err(Error::config(format!(
"--horizon start {lo} exceeds end {hi}"
)));
}
Ok((lo, hi))
}
fn run_explain(args: ExplainArgs) -> Result<()> {
let db = crate::load_database(&args.input)?;
match plan::explain(&db, &args.zone) {
Ok(s) => {
println!("{s}");
Ok(())
}
Err(d) => {
println!("{d}");
Err(Error::message(format!("{} is not supported", args.zone)))
}
}
}
pub fn supported_syntax_text() -> String {
"\
zic-rs supported syntax (current declared subset)
Records (keywords accept zic-style unambiguous prefixes, incl. zishrink R/Z/L):
Zone NAME STDOFF RULES FORMAT [UNTIL...] (single or multi-era via UNTIL continuations)
RULES = '-' -> fixed-offset era (any constant standard offset)
RULES = <clock> -> inline-save era: fixed type at STDOFF+SAVE, is_dst set, literal/%z FORMAT
RULES = <name> -> rule set: finite (FROM..TO years) or recurring (TO = maximum)
Rule NAME FROM TO - IN ON AT SAVE LETTER
Link TARGET LINK-NAME (copy or symlink; chains resolved)
The installed single-file tzdata.zi (R/Z/L record keys) is read directly.
Offsets / times:
-, integer hours, h:mm, h:mm:ss, signed; fractional seconds rounded to nearest.
AT suffixes: w (wall, default), s (standard), u/g/z (universal).
SAVE suffixes: s (standard), d (daylight); sign honoured.
ON day forms:
numeric day, lastSun..lastSat, Sun>=N, Sun<=N (with month spill).
FORMAT:
literal, %s (LETTER substitution), STD/DST slash, %z (numeric offset).
Footer (POSIX TZ):
fixed offset for finite rule tails; recurring std/dst rule (e.g. EST5EDT,M3.2.0,M11.1.0)
for TO = maximum rule sets with POSIX-expressible (nth/last weekday) day forms.
Multi-era zones:
Cross-era state is carried correctly (UNTIL in the ending era's context with the
prevailing save; footer from the final era). A final era whose finite rules all end
before the era starts is classified by its EFFECTIVE in-era activations (recurring-only),
which admits real zones such as Europe/London (first pinned IANA slice).
Output:
Valid TZif version 2/3 (content-driven: v1 stub block + v2/v3 block + POSIX TZ footer;
v3 only when a recurring rule's day form requires it).
FROM = minimum is accepted as an obsolete spelling, coerced to 1900 (as reference zic).
NOT yet supported (rejected with an explicit diagnostic, never approximated):
inline save with a %s or STD/DST slash FORMAT (a negative inline save IS supported, law 7),
24:00/negative compiled times, recurring rules whose ON day is a fixed numeric day-of-month
(the Sun<=N/Sat<=N weekday forms ARE supported, law 10), and leap seconds (-L).
Deferred operational modes: ownership (-u, privileged/Unix-only) and the legacy posixrules
link (-p). File mode (-m, octal, Unix-only) IS supported. See docs/unsupported-syntax.md and
docs/roadmap.md.
"
.to_string()
}
#[cfg(test)]
mod tests {
use super::{
parse_octal_mode, parse_redundant_until, reconcile_emit_style, BloatArg, EmitStyleArg,
};
use crate::EmitStyle;
#[test]
fn range_parses_all_three_forms() {
use super::parse_range;
use crate::RangeSpec;
assert_eq!(parse_range(None).unwrap(), None);
assert_eq!(
parse_range(Some("@0")).unwrap(),
Some(RangeSpec {
lo: Some(0),
hi: None
})
);
assert_eq!(
parse_range(Some("@0/@4102444800")).unwrap(),
Some(RangeSpec {
lo: Some(0),
hi: Some(4102444800)
})
);
assert_eq!(
parse_range(Some("/@100")).unwrap(),
Some(RangeSpec {
lo: None,
hi: Some(100)
})
);
}
#[test]
fn range_rejects_malformed() {
use super::parse_range;
assert!(parse_range(Some("")).is_err()); assert!(parse_range(Some("0/@1")).is_err()); assert!(parse_range(Some("@0/1")).is_err()); assert!(parse_range(Some("@abc")).is_err()); assert!(parse_range(Some("@0/")).is_err()); assert!(parse_range(Some("@10/@5")).is_err()); assert!(parse_range(Some("@1/@2/@3")).is_err()); }
#[test]
fn redundant_until_requires_at_prefix() {
assert_eq!(parse_redundant_until(None).unwrap(), None);
assert_eq!(
parse_redundant_until(Some("@946684800")).unwrap(),
Some(946684800)
);
assert_eq!(parse_redundant_until(Some("@-1")).unwrap(), Some(-1));
assert!(parse_redundant_until(Some("946684800")).is_err());
assert!(parse_redundant_until(Some("@abc")).is_err());
assert!(parse_redundant_until(Some("@")).is_err());
}
#[test]
fn bloat_alias_maps_onto_emit_style() {
assert_eq!(
reconcile_emit_style(EmitStyleArg::Default, Some(BloatArg::Slim)).unwrap(),
EmitStyle::ZicSlim
);
assert_eq!(
reconcile_emit_style(EmitStyleArg::Default, Some(BloatArg::Fat)).unwrap(),
EmitStyle::ZicFat
);
assert_eq!(
reconcile_emit_style(EmitStyleArg::ZicSlim, None).unwrap(),
EmitStyle::ZicSlim
);
assert_eq!(
reconcile_emit_style(EmitStyleArg::ZicSlim, Some(BloatArg::Slim)).unwrap(),
EmitStyle::ZicSlim
);
}
#[test]
fn bloat_conflicting_with_emit_style_is_error() {
assert!(reconcile_emit_style(EmitStyleArg::ZicSlim, Some(BloatArg::Fat)).is_err());
assert!(reconcile_emit_style(EmitStyleArg::ZicFat, Some(BloatArg::Slim)).is_err());
}
#[test]
fn parses_octal_with_and_without_leading_zero() {
assert_eq!(parse_octal_mode(Some("644")).unwrap(), Some(0o644));
assert_eq!(parse_octal_mode(Some("0644")).unwrap(), Some(0o644));
assert_eq!(parse_octal_mode(Some("0o600")).unwrap(), Some(0o600));
assert_eq!(parse_octal_mode(Some("755")).unwrap(), Some(0o755));
assert_eq!(parse_octal_mode(None).unwrap(), None);
}
#[test]
fn rejects_non_octal_and_out_of_range() {
assert!(parse_octal_mode(Some("999")).is_err());
assert!(parse_octal_mode(Some("64a")).is_err());
assert!(parse_octal_mode(Some("10000")).is_err());
}
}