1use std::path::PathBuf;
14
15use clap::{Args, Parser, Subcommand};
16
17use crate::compile::plan;
18use crate::error::{Error, Result};
19use crate::{CompileConfig, LinkMode, UnsupportedPolicy, ZoneSelection, DEFAULT_TRANSITION_LIMIT};
20
21#[derive(Debug, Parser)]
23#[command(
24 name = "zic-rs",
25 about = "A memory-safe Rust timezone compiler for IANA tzdata (declared subset).",
26 version
27)]
28pub struct Cli {
29 #[command(subcommand)]
30 pub command: Command,
31}
32
33#[allow(clippy::large_enum_variant)]
39#[derive(Debug, Subcommand)]
40pub enum Command {
41 Compile(CompileArgs),
43 Compare(CompareArgs),
45 Explain(ExplainArgs),
47 SupportedSyntax,
49 SupportReport(SupportReportArgs),
51 StructuralReport(StructuralReportArgs),
53 SemanticReport(SemanticReportArgs),
57 TzifValidate(TzifValidateArgs),
62 AuxTableValidate(AuxTableValidateArgs),
67 VendorOracleSample,
71 VendorOracleAdmit(VendorOracleAdmitArgs),
76 ReleaseDiff(ReleaseDiffArgs),
80 Doctor(DoctorArgs),
83 SizeReport(SizeReportArgs),
87}
88
89#[derive(Debug, Args)]
90pub struct CompileArgs {
91 #[arg(long = "input", required = true, num_args = 1..)]
93 pub input: Vec<PathBuf>,
94 #[arg(long = "out")]
96 pub out: Option<PathBuf>,
97 #[arg(long = "zone")]
99 pub zone: Option<String>,
100 #[arg(long = "zones")]
102 pub zones: Option<PathBuf>,
103 #[arg(long = "all-supported", default_value_t = false)]
105 pub all_supported: bool,
106 #[arg(long = "link-mode", default_value = "copy")]
108 pub link_mode: LinkModeArg,
109 #[arg(long = "force", default_value_t = false)]
111 pub force: bool,
112 #[arg(long = "unsupported", default_value = "error")]
114 pub unsupported: UnsupportedArg,
115 #[arg(long = "transition-limit", default_value_t = DEFAULT_TRANSITION_LIMIT)]
117 pub transition_limit: usize,
118 #[arg(long = "emit-style", value_enum, default_value_t = EmitStyleArg::Default)]
122 pub emit_style: EmitStyleArg,
123 #[arg(long = "bloat", short = 'b', value_enum)]
127 pub bloat: Option<BloatArg>,
128 #[arg(long = "redundant-until", short = 'R')]
133 pub redundant_until: Option<String>,
134 #[arg(long = "range", short = 'r')]
139 pub range: Option<String>,
140 #[arg(long = "leapseconds", short = 'L')]
144 pub leapseconds: Option<PathBuf>,
145 #[arg(long = "no-create-dirs", short = 'D', default_value_t = false)]
147 pub no_create_dirs: bool,
148 #[arg(long = "localtime", short = 'l')]
151 pub localtime: Option<String>,
152 #[arg(long = "localtime-name", short = 't')]
155 pub localtime_name: Option<String>,
156 #[arg(long = "mode", short = 'm')]
160 pub mode: Option<String>,
161 #[arg(long = "alias-map")]
163 pub alias_map: Option<PathBuf>,
164 #[arg(long = "manifest")]
166 pub manifest: Option<PathBuf>,
167 #[arg(long = "tzdb-version")]
171 pub tzdb_version: Option<String>,
172 #[arg(long = "backward", value_parser = ["included", "excluded"])]
176 pub backward: Option<String>,
177 #[arg(long = "backward-source")]
181 pub backward_source: Option<PathBuf>,
182 #[arg(long = "backzone", value_parser = ["included", "excluded"])]
187 pub backzone: Option<String>,
188 #[arg(long = "packratlist", value_parser = ["full", "subset", "none"])]
193 pub packratlist: Option<String>,
194 #[arg(long = "packratlist-source")]
198 pub packratlist_source: Option<PathBuf>,
199 #[arg(long = "dataform", value_parser = ["main", "vanguard", "rearguard"])]
204 pub dataform: Option<String>,
205 #[arg(long = "verbose", short = 'v', default_value_t = false)]
210 pub verbose: bool,
211}
212
213#[derive(Debug, Args)]
214pub struct CompareArgs {
215 #[arg(long = "input", required = true, num_args = 1..)]
216 pub input: Vec<PathBuf>,
217 #[arg(long = "zone")]
218 pub zone: String,
219 #[arg(long = "reference-zic", default_value = "zic")]
221 pub reference_zic: String,
222 #[arg(long = "mode", default_value = "zdump")]
225 pub mode: CompareModeArg,
226 #[arg(long = "horizon", default_value = "1900,2100")]
229 pub horizon: String,
230 #[arg(long = "zdump", default_value = "zdump")]
232 pub zdump: String,
233}
234
235#[derive(Debug, Clone, Copy, clap::ValueEnum)]
237pub enum CompareModeArg {
238 Zdump,
239 Structural,
240}
241
242#[derive(Debug, Args)]
243pub struct ExplainArgs {
244 #[arg(long = "input", required = true, num_args = 1..)]
245 pub input: Vec<PathBuf>,
246 #[arg(long = "zone")]
247 pub zone: String,
248}
249
250#[derive(Debug, Args)]
251pub struct SupportReportArgs {
252 #[arg(long = "input", required = true, num_args = 1..)]
254 pub input: Vec<PathBuf>,
255 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
257 pub format: ReportFormatArg,
258 #[arg(long = "explain-buckets", default_value_t = false)]
262 pub explain_buckets: bool,
263}
264
265#[derive(Debug, Clone, Copy, clap::ValueEnum)]
267pub enum ReportFormatArg {
268 Text,
269 Json,
270}
271
272#[derive(Debug, Args)]
273pub struct StructuralReportArgs {
274 #[arg(long = "input", required = true, num_args = 1..)]
276 pub input: Vec<PathBuf>,
277 #[arg(long = "reference-zic", default_value = "zic")]
280 pub reference_zic: String,
281 #[arg(long = "zone")]
283 pub zone: Option<String>,
284 #[arg(long = "emit-style", value_enum, default_value_t = EmitStyleArg::Default)]
288 pub emit_style: EmitStyleArg,
289 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
291 pub format: ReportFormatArg,
292}
293
294#[derive(Debug, clap::Args)]
296pub struct SemanticReportArgs {
297 #[arg(long = "input", required = true, num_args = 1..)]
299 pub input: Vec<PathBuf>,
300 #[arg(long = "reference-zic", default_value = "zic")]
302 pub reference_zic: String,
303 #[arg(long = "zdump", default_value = "zdump")]
305 pub zdump: String,
306 #[arg(long = "zone")]
308 pub zone: Vec<String>,
309 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
311 pub format: ReportFormatArg,
312}
313
314#[derive(Debug, clap::Args)]
316pub struct TzifValidateArgs {
317 #[arg(long = "input", required = true, num_args = 1..)]
319 pub input: Vec<PathBuf>,
320 #[arg(long = "reference-zic", default_value = "zic")]
322 pub reference_zic: String,
323 #[arg(long = "zone")]
325 pub zone: Vec<String>,
326 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
328 pub format: ReportFormatArg,
329}
330
331#[derive(Debug, clap::Args)]
334pub struct AuxTableValidateArgs {
335 #[arg(long = "zone-tab")]
337 pub zone_tab: Option<PathBuf>,
338 #[arg(long = "zone1970-tab")]
340 pub zone1970_tab: Option<PathBuf>,
341 #[arg(long = "zonenow-tab")]
343 pub zonenow_tab: Option<PathBuf>,
344 #[arg(long = "iso3166-tab")]
346 pub iso3166_tab: Option<PathBuf>,
347 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
349 pub format: ReportFormatArg,
350}
351
352#[derive(Debug, clap::Args)]
354pub struct VendorOracleAdmitArgs {
355 #[arg(long = "receipt", required = true)]
357 pub receipt: PathBuf,
358}
359
360#[derive(Debug, clap::Args)]
362pub struct ReleaseDiffArgs {
363 #[arg(long = "old", required = true)]
365 pub old: Vec<PathBuf>,
366 #[arg(long = "new", required = true)]
368 pub new: Vec<PathBuf>,
369 #[arg(long = "zone")]
371 pub zone: Option<String>,
372 #[arg(long = "horizon", default_value = "1900,2040")]
374 pub horizon: String,
375 #[arg(long = "split", default_value_t = 2025)]
377 pub split: i32,
378 #[arg(long = "reference-zdump")]
380 pub reference_zdump: Option<String>,
381 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
383 pub format: ReportFormatArg,
384}
385
386#[derive(Debug, clap::Args)]
388pub struct DoctorArgs {
389 #[arg(long = "reference-zic", default_value = "zic")]
391 pub reference_zic: String,
392 #[arg(long = "reference-zdump", default_value = "zdump")]
394 pub reference_zdump: String,
395 #[arg(long = "tzdata")]
397 pub tzdata: Option<PathBuf>,
398 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
400 pub format: ReportFormatArg,
401}
402
403#[derive(Debug, Args)]
404pub struct SizeReportArgs {
405 #[arg(long = "out")]
407 pub out: PathBuf,
408 #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
410 pub format: ReportFormatArg,
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
415pub enum EmitStyleArg {
416 Default,
418 ZicSlim,
420 ZicFat,
422}
423
424impl From<EmitStyleArg> for crate::EmitStyle {
425 fn from(a: EmitStyleArg) -> Self {
426 match a {
427 EmitStyleArg::Default => crate::EmitStyle::Default,
428 EmitStyleArg::ZicSlim => crate::EmitStyle::ZicSlim,
429 EmitStyleArg::ZicFat => crate::EmitStyle::ZicFat,
430 }
431 }
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
440pub enum BloatArg {
441 Slim,
442 Fat,
443}
444
445impl From<BloatArg> for EmitStyleArg {
446 fn from(b: BloatArg) -> Self {
447 match b {
448 BloatArg::Slim => EmitStyleArg::ZicSlim,
449 BloatArg::Fat => EmitStyleArg::ZicFat,
450 }
451 }
452}
453
454#[derive(Debug, Clone, Copy, clap::ValueEnum)]
456pub enum LinkModeArg {
457 Copy,
458 Symlink,
459}
460
461impl From<LinkModeArg> for LinkMode {
462 fn from(a: LinkModeArg) -> Self {
463 match a {
464 LinkModeArg::Copy => LinkMode::Copy,
465 LinkModeArg::Symlink => LinkMode::Symlink,
466 }
467 }
468}
469
470#[derive(Debug, Clone, Copy, clap::ValueEnum)]
472pub enum UnsupportedArg {
473 Error,
474 Skip,
475}
476
477impl From<UnsupportedArg> for UnsupportedPolicy {
478 fn from(a: UnsupportedArg) -> Self {
479 match a {
480 UnsupportedArg::Error => UnsupportedPolicy::Error,
481 UnsupportedArg::Skip => UnsupportedPolicy::WarnAndSkipZone,
482 }
483 }
484}
485
486pub fn run(cli: Cli) -> Result<()> {
488 match cli.command {
489 Command::Compile(args) => run_compile(args),
490 Command::Compare(args) => run_compare(args),
491 Command::Explain(args) => run_explain(args),
492 Command::SupportedSyntax => {
493 print!("{}", supported_syntax_text());
494 Ok(())
495 }
496 Command::SupportReport(args) => run_support_report(args),
497 Command::StructuralReport(args) => run_structural_report(args),
498 Command::SemanticReport(args) => run_semantic_report(args),
499 Command::TzifValidate(args) => run_tzif_validate(args),
500 Command::AuxTableValidate(args) => run_aux_table_validate(args),
501 Command::VendorOracleSample => {
502 print!(
503 "{}",
504 crate::vendor_oracle::VendorOracleReceipt::minimal_sample().to_json()
505 );
506 Ok(())
507 }
508 Command::VendorOracleAdmit(args) => run_vendor_oracle_admit(args),
509 Command::ReleaseDiff(args) => run_release_diff(args),
510 Command::Doctor(args) => run_doctor(args),
511 Command::SizeReport(args) => run_size_report(args),
512 }
513}
514
515fn run_support_report(args: SupportReportArgs) -> Result<()> {
517 let tzdb_version = std::fs::read(&args.input[0])
520 .ok()
521 .and_then(|b| crate::report::sniff_tzdb_version(&b));
522 let db = crate::load_database(&args.input)?;
523 let report = crate::report::build_support_report(&db, tzdb_version);
524 match args.format {
525 ReportFormatArg::Text if args.explain_buckets => print!("{}", report.to_text_explained()),
526 ReportFormatArg::Text => print!("{}", report.to_text()),
527 ReportFormatArg::Json => print!("{}", report.to_json()),
528 }
529 Ok(())
530}
531
532fn run_structural_report(args: StructuralReportArgs) -> Result<()> {
536 if !crate::compare::reference_zic::is_available(&args.reference_zic) {
537 return Err(Error::config(format!(
538 "reference zic `{}` not found; structural-report compares against it",
539 args.reference_zic
540 )));
541 }
542 let tzdb_version = std::fs::read(&args.input[0])
543 .ok()
544 .and_then(|b| crate::report::sniff_tzdb_version(&b));
545 let db = crate::load_database(&args.input)?;
546 let files = crate::collect_source_files(&args.input)?;
548 let work = tempfile::Builder::new()
549 .prefix("zic-rs-structural-")
550 .tempdir()
551 .map_err(|e| Error::io(std::env::temp_dir(), e))?;
552 let report = crate::structural::build_structural_report(
553 &db,
554 &files,
555 &args.reference_zic,
556 work.path(),
557 args.zone.as_deref(),
558 tzdb_version,
559 args.emit_style.into(),
560 )?;
561 match args.format {
562 ReportFormatArg::Text => print!("{}", report.to_text()),
563 ReportFormatArg::Json => print!("{}", report.to_json()),
564 }
565 Ok(())
566}
567
568fn run_semantic_report(args: SemanticReportArgs) -> Result<()> {
571 let db = crate::load_database(&args.input)?;
572 let files = crate::collect_source_files(&args.input)?;
573 let zones: Vec<String> = if !args.zone.is_empty() {
576 args.zone.clone()
577 } else {
578 const CURATED: &[&str] = &[
579 "Etc/UTC",
580 "America/New_York",
581 "Europe/London",
582 "Australia/Sydney",
583 "Asia/Kolkata",
584 ];
585 let present: Vec<String> = CURATED
586 .iter()
587 .filter(|z| db.zone(z).is_some())
588 .map(|z| z.to_string())
589 .collect();
590 if present.is_empty() {
591 db.zones.iter().take(3).map(|z| z.name.clone()).collect()
592 } else {
593 present
594 }
595 };
596 let work = tempfile::Builder::new()
597 .prefix("zic-rs-semantic-")
598 .tempdir()
599 .map_err(|e| Error::io(std::env::temp_dir(), e))?;
600 let report = crate::semantic_witness::build_semantic_witness_report(
601 &db,
602 &zones,
603 &args.reference_zic,
604 &args.zdump,
605 &files,
606 work.path(),
607 )?;
608 match args.format {
609 ReportFormatArg::Json => print!("{}", report.to_json()),
610 ReportFormatArg::Text => {
611 println!(
612 "semantic witnesses (oracle_mode: {})",
613 report.oracle_mode.mode_str()
614 );
615 for w in &report.witnesses {
616 println!(" {} @ {}: {}", w.zone, w.timestamp, w.verdict.as_str());
617 }
618 }
619 }
620 Ok(())
621}
622
623fn run_tzif_validate(args: TzifValidateArgs) -> Result<()> {
625 let db = crate::load_database(&args.input)?;
626 let files = crate::collect_source_files(&args.input)?;
627 let zones: Vec<String> = if !args.zone.is_empty() {
628 args.zone.clone()
629 } else {
630 const CURATED: &[&str] = &["Etc/UTC", "America/New_York", "Europe/London", "Asia/Gaza"];
631 let present: Vec<String> = CURATED
632 .iter()
633 .filter(|z| db.zone(z).is_some())
634 .map(|z| z.to_string())
635 .collect();
636 if present.is_empty() {
637 db.zones.iter().take(3).map(|z| z.name.clone()).collect()
638 } else {
639 present
640 }
641 };
642 let work = tempfile::Builder::new()
643 .prefix("zic-rs-tzif-validate-")
644 .tempdir()
645 .map_err(|e| Error::io(std::env::temp_dir(), e))?;
646 let report = crate::tzif::rfc9636::build_validation_report(
647 &db,
648 &zones,
649 &args.reference_zic,
650 &files,
651 work.path(),
652 )?;
653 match args.format {
654 ReportFormatArg::Json => print!("{}", report.to_json()),
655 ReportFormatArg::Text => {
656 println!(
657 "TZif structural validation (reference validated: {})",
658 report.reference_validated
659 );
660 for row in &report.rows {
661 println!(
662 " [{}] {} : {}{}",
663 row.producer,
664 row.zone,
665 row.validation.structural.as_str(),
666 if row.validation.violations.is_empty() {
667 String::new()
668 } else {
669 format!(" — {}", row.validation.violations.join("; "))
670 }
671 );
672 }
673 }
674 }
675 Ok(())
676}
677
678fn run_aux_table_validate(args: AuxTableValidateArgs) -> Result<()> {
682 use crate::aux_tables::{
683 iso3166_codes, validate_zone_table, AuxTableValidationReport, InstallEcologyStatus,
684 ZoneTableKind,
685 };
686 let read =
687 |p: &PathBuf| -> Result<Vec<u8>> { std::fs::read(p).map_err(|e| Error::io(p.clone(), e)) };
688 let iso_bytes = args.iso3166_tab.as_ref().map(read).transpose()?;
690 let iso_codes = iso_bytes.as_ref().map(|b| iso3166_codes(b));
691
692 let mut tables = Vec::new();
693 if let Some(p) = &args.zone_tab {
694 tables.push(validate_zone_table(
696 ZoneTableKind::ZoneTab,
697 &read(p)?,
698 iso_codes.as_ref(),
699 ));
700 }
701 if let Some(p) = &args.zone1970_tab {
702 tables.push(validate_zone_table(
703 ZoneTableKind::Zone1970Tab,
704 &read(p)?,
705 iso_codes.as_ref(),
706 ));
707 }
708 if let Some(p) = &args.zonenow_tab {
709 tables.push(validate_zone_table(
710 ZoneTableKind::ZonenowTab,
711 &read(p)?,
712 None,
713 ));
714 }
715 if let Some(b) = &iso_bytes {
716 tables.push(validate_zone_table(ZoneTableKind::Iso3166Tab, b, None));
717 }
718 let report = AuxTableValidationReport {
719 tables,
720 install_ecology: InstallEcologyStatus::current(),
721 };
722 match args.format {
723 ReportFormatArg::Json => print!("{}", report.to_json()),
724 ReportFormatArg::Text => {
725 println!("auxiliary-table validation (structural admissibility only)");
726 for t in &report.tables {
727 println!(
728 " {} [{}] : {} ({} rows, {} findings)",
729 t.kind.as_str(),
730 t.kind.coverage(),
731 t.verdict.as_str(),
732 t.rows_checked,
733 t.findings.len()
734 );
735 }
736 }
737 }
738 Ok(())
739}
740
741fn run_vendor_oracle_admit(args: VendorOracleAdmitArgs) -> Result<()> {
746 let bytes = std::fs::read(&args.receipt).map_err(|e| Error::io(args.receipt.clone(), e))?;
747 let text = String::from_utf8(bytes)
748 .map_err(|_| Error::config("receipt is not valid UTF-8 (parse error)"))?;
749 match crate::vendor_oracle::VendorOracleReceipt::from_json(&text) {
750 Err(e) => Err(Error::config(format!("receipt parse error: {e}"))),
752 Ok(receipt) => {
753 let verdict = receipt.admit();
754 println!(
755 "vendor-oracle receipt: platform={} fixture_set={} → admission={}",
756 receipt.platform,
757 receipt.fixture_set,
758 verdict.as_str()
759 );
760 if verdict.is_admitted() {
761 Ok(())
762 } else {
763 Err(Error::config(format!(
765 "receipt not admitted: {}",
766 verdict.as_str()
767 )))
768 }
769 }
770 }
771}
772
773fn run_release_diff(args: ReleaseDiffArgs) -> Result<()> {
779 use crate::release_diff::{build_release_diff, ReleaseDiffOptions};
780 let (lo, hi) = args
782 .horizon
783 .split_once(',')
784 .and_then(|(a, b)| Some((a.trim().parse::<i32>().ok()?, b.trim().parse::<i32>().ok()?)))
785 .ok_or_else(|| {
786 Error::config(format!(
787 "invalid --horizon {:?} (expected LO,HI)",
788 args.horizon
789 ))
790 })?;
791 if hi < lo {
792 return Err(Error::config("--horizon HI must be >= LO"));
793 }
794 let old_db = crate::load_database(&args.old)?;
795 let new_db = crate::load_database(&args.new)?;
796 let opts = ReleaseDiffOptions {
797 horizon: (lo, hi),
798 split: args.split,
799 zone_filter: args.zone,
800 zdump_program: args.reference_zdump,
801 };
802 let report = build_release_diff(&old_db, &new_db, &opts)?;
803 match args.format {
804 ReportFormatArg::Json => print!("{}", report.to_json()),
805 ReportFormatArg::Text => {
806 println!(
807 "release-diff (horizon {}..{}, split {}, oracle={})",
808 report.horizon.0,
809 report.horizon.1,
810 report.split,
811 report.oracle_mode.mode_str()
812 );
813 for (kind, n) in report.kind_counts() {
814 if n > 0 {
815 println!(" {n:5} {kind}");
816 }
817 }
818 if !report.errors.is_empty() {
819 println!(
820 " {} identifier(s) not comparable (outside zic-rs subset):",
821 report.errors.len()
822 );
823 for e in &report.errors {
824 println!(" {} — {}", e.name, e.reason);
825 }
826 }
827 }
828 }
829 Ok(())
830}
831
832fn run_doctor(args: DoctorArgs) -> Result<()> {
833 use crate::doctor::{run_doctor as probe, DoctorOptions};
834 let report = probe(&DoctorOptions {
835 reference_zic: args.reference_zic,
836 reference_zdump: args.reference_zdump,
837 tzdata: args.tzdata,
838 })?;
839 match args.format {
840 ReportFormatArg::Json => print!("{}", report.to_json()),
841 ReportFormatArg::Text => print!("{}", report.to_text()),
842 }
843 Ok(())
845}
846
847fn run_size_report(args: SizeReportArgs) -> Result<()> {
848 use crate::size_report::{run_size_report as measure, SizeReportOptions};
849 let report = measure(&SizeReportOptions { out: args.out })?;
850 match args.format {
851 ReportFormatArg::Json => print!("{}", report.to_json()),
852 ReportFormatArg::Text => print!("{}", report.to_text()),
853 }
854 Ok(())
855}
856
857fn load_leap_table(path: Option<&std::path::Path>) -> Result<Option<crate::model::LeapTable>> {
865 let Some(p) = path else { return Ok(None) };
866 let bytes = std::fs::read(p).map_err(|e| Error::io(p, e))?;
867 Ok(Some(crate::source::parse_leap_source(&bytes, p)?))
868}
869
870fn parse_octal_mode(raw: Option<&str>) -> Result<Option<u32>> {
871 let Some(s) = raw else { return Ok(None) };
872 let digits = s.strip_prefix("0o").unwrap_or(s);
873 let bits = u32::from_str_radix(digits, 8)
874 .map_err(|_| Error::config(format!("--mode {s:?} is not a valid octal mode (e.g. 644)")))?;
875 if bits > 0o7777 {
876 return Err(Error::config(format!(
877 "--mode {s:?} is out of range (max 7777)"
878 )));
879 }
880 Ok(Some(bits))
881}
882
883fn reconcile_emit_style(style: EmitStyleArg, bloat: Option<BloatArg>) -> Result<crate::EmitStyle> {
888 match bloat {
889 None => Ok(style.into()),
890 Some(b) => {
891 let from_bloat: EmitStyleArg = b.into();
892 if style != EmitStyleArg::Default && style != from_bloat {
893 let bn = match b {
894 BloatArg::Slim => "slim",
895 BloatArg::Fat => "fat",
896 };
897 let sn = match style {
898 EmitStyleArg::ZicSlim => "zic-slim",
899 EmitStyleArg::ZicFat => "zic-fat",
900 EmitStyleArg::Default => "default",
901 };
902 return Err(Error::config(format!(
903 "-b {bn} conflicts with --emit-style {sn} (incompatible emission options)"
904 )));
905 }
906 Ok(from_bloat.into())
907 }
908 }
909}
910
911fn parse_redundant_until(raw: Option<&str>) -> Result<Option<i64>> {
915 let Some(s) = raw else { return Ok(None) };
916 let body = s.strip_prefix('@').ok_or_else(|| {
917 Error::config(format!(
918 "-R expects an @-prefixed Unix-seconds instant (e.g. @4102444800), got {s:?}"
919 ))
920 })?;
921 let secs = body
922 .parse::<i64>()
923 .map_err(|_| Error::config(format!("-R {s:?} is not a valid @<seconds> instant")))?;
924 Ok(Some(secs))
925}
926
927fn parse_range(raw: Option<&str>) -> Result<Option<crate::RangeSpec>> {
933 let Some(s) = raw else { return Ok(None) };
934 let parse_at = |part: &str, which: &str| -> Result<i64> {
935 part.strip_prefix('@')
936 .and_then(|b| b.parse::<i64>().ok())
937 .ok_or_else(|| {
938 Error::config(format!(
939 "-r {which} bound {part:?} must be an @-prefixed Unix-seconds instant (e.g. @0)"
940 ))
941 })
942 };
943 let (lo, hi) = match s.split_once('/') {
945 Some((lo_str, hi_str)) => {
946 let lo = if lo_str.is_empty() {
947 None
948 } else {
949 Some(parse_at(lo_str, "lo")?)
950 };
951 (lo, Some(parse_at(hi_str, "hi")?))
952 }
953 None => (Some(parse_at(s, "lo")?), None),
954 };
955 if lo.is_none() && hi.is_none() {
956 return Err(Error::config(
957 "-r requires @lo and/or /@hi (e.g. @0/@4102444800)",
958 ));
959 }
960 if let (Some(l), Some(h)) = (lo, hi) {
961 if h < l {
962 return Err(Error::config(format!("-r hi (@{h}) is before lo (@{l})")));
963 }
964 }
965 Ok(Some(crate::RangeSpec { lo, hi }))
966}
967
968fn run_compile(args: CompileArgs) -> Result<()> {
969 let zones = resolve_selection(&args)?;
971 let output_dir = args
972 .out
973 .ok_or_else(|| Error::config("--out is required; there is no default output directory"))?;
974 let db = crate::load_database(&args.input)?;
975
976 let config = CompileConfig {
977 input_paths: args.input.clone(),
978 output_dir,
979 zones,
980 link_mode: args.link_mode.into(),
981 overwrite: args.force,
982 unsupported_policy: args.unsupported.into(),
983 transition_limit: args.transition_limit,
984 emit_style: reconcile_emit_style(args.emit_style, args.bloat)?,
985 no_create_dirs: args.no_create_dirs,
986 localtime: args.localtime.clone(),
987 localtime_name: args.localtime_name.clone(),
988 file_mode: parse_octal_mode(args.mode.as_deref())?,
989 redundant_until: parse_redundant_until(args.redundant_until.as_deref())?,
990 range: parse_range(args.range.as_deref())?,
991 leaps: load_leap_table(args.leapseconds.as_deref())?,
992 };
993
994 let report = plan::run(&db, &config)?;
995
996 for z in &report.zones_compiled {
998 println!(
999 "compiled {} -> {} (TZif v{}, {} transitions)",
1000 z.name,
1001 z.output_path.display(),
1002 z.tzif_version as char, z.transition_count
1004 );
1005 }
1006 for l in &report.links_written {
1007 println!("linked {} -> {} ({:?})", l.link_name, l.target, l.mode);
1008 }
1009 for d in &report.diagnostics {
1015 if args.verbose || d.verbosity == crate::diagnostics::DiagnosticVerbosity::AlwaysOn {
1016 eprintln!("{d}");
1017 }
1018 }
1019
1020 if let Some(path) = &args.alias_map {
1022 let map = crate::manifest::build(&report, &config.output_dir)?;
1023 map.write_to(path)?;
1024 println!("alias-map -> {}", path.display());
1025 }
1026 if let Some(path) = &args.manifest {
1027 let requested = plan::select_zones(&db, &config.zones);
1030 let source_files = crate::collect_source_files(&config.input_paths)?;
1031 let variants = crate::manifest::SourceVariantArgs {
1036 backward_claim: args.backward.as_deref().map(|v| v == "included"),
1037 backward_source: args.backward_source.clone(),
1038 backzone_claim: args.backzone.as_deref().map(|v| v == "included"),
1039 packratlist_claim: args.packratlist.clone(),
1040 packratlist_source: args.packratlist_source.clone(),
1041 dataform_claim: args.dataform.clone(),
1042 };
1043 let manifest = crate::manifest::build_compile_manifest(
1044 &requested,
1045 &source_files,
1046 &report,
1047 &config,
1048 &db,
1049 args.tzdb_version.as_deref(),
1050 args.leapseconds.as_deref(),
1051 &variants,
1052 )?;
1053 manifest.write_to(path)?;
1054 println!("manifest -> {}", path.display());
1055 }
1056 Ok(())
1057}
1058
1059fn resolve_selection(args: &CompileArgs) -> Result<ZoneSelection> {
1061 match (&args.zone, &args.zones, args.all_supported) {
1062 (Some(z), None, false) => Ok(ZoneSelection::One(z.clone())),
1063 (None, Some(path), false) => {
1064 let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
1065 let names: Vec<String> = text
1066 .lines()
1067 .map(|l| l.trim())
1068 .filter(|l| !l.is_empty() && !l.starts_with('#'))
1069 .map(|l| l.to_string())
1070 .collect();
1071 Ok(ZoneSelection::Many(names))
1072 }
1073 (None, None, true) => Ok(ZoneSelection::AllSupported),
1074 _ => Err(Error::config(
1075 "specify exactly one of --zone, --zones, or --all-supported",
1076 )),
1077 }
1078}
1079
1080fn run_compare(args: CompareArgs) -> Result<()> {
1081 let db = crate::load_database(&args.input)?;
1082 let files = crate::collect_source_files(&args.input)?;
1085
1086 let mode = match args.mode {
1088 CompareModeArg::Structural => crate::compare::CompareMode::Structural,
1089 CompareModeArg::Zdump => {
1090 let (lo, hi) = parse_horizon(&args.horizon)?;
1091 crate::compare::CompareMode::Zdump {
1092 program: args.zdump.clone(),
1093 lo,
1094 hi,
1095 }
1096 }
1097 };
1098
1099 let work = tempfile::Builder::new()
1102 .prefix("zic-rs-compare-")
1103 .tempdir()
1104 .map_err(|e| Error::io(std::env::temp_dir(), e))?;
1105 let cmp = crate::compare::compare_zone(
1106 &db,
1107 &files,
1108 &args.zone,
1109 &args.reference_zic,
1110 work.path(),
1111 &mode,
1112 )?;
1113 println!("{}", cmp.summary());
1114 if cmp.is_match() {
1115 Ok(())
1116 } else {
1117 Err(Error::message(format!(
1118 "{}: output disagrees with reference zic",
1119 args.zone
1120 )))
1121 }
1122}
1123
1124fn parse_horizon(s: &str) -> Result<(i32, i32)> {
1126 let (lo, hi) = s
1127 .split_once(',')
1128 .ok_or_else(|| Error::config(format!("--horizon must be `LO,HI`, got {s:?}")))?;
1129 let lo: i32 = lo
1130 .trim()
1131 .parse()
1132 .map_err(|_| Error::config(format!("invalid horizon start {lo:?}")))?;
1133 let hi: i32 = hi
1134 .trim()
1135 .parse()
1136 .map_err(|_| Error::config(format!("invalid horizon end {hi:?}")))?;
1137 if lo > hi {
1138 return Err(Error::config(format!(
1139 "--horizon start {lo} exceeds end {hi}"
1140 )));
1141 }
1142 Ok((lo, hi))
1143}
1144
1145fn run_explain(args: ExplainArgs) -> Result<()> {
1146 let db = crate::load_database(&args.input)?;
1147 match plan::explain(&db, &args.zone) {
1148 Ok(s) => {
1149 println!("{s}");
1150 Ok(())
1151 }
1152 Err(d) => {
1153 println!("{d}");
1156 Err(Error::message(format!("{} is not supported", args.zone)))
1157 }
1158 }
1159}
1160
1161pub fn supported_syntax_text() -> String {
1164 "\
1165zic-rs supported syntax (current declared subset)
1166
1167Records (keywords accept zic-style unambiguous prefixes, incl. zishrink R/Z/L):
1168 Zone NAME STDOFF RULES FORMAT [UNTIL...] (single or multi-era via UNTIL continuations)
1169 RULES = '-' -> fixed-offset era (any constant standard offset)
1170 RULES = <clock> -> inline-save era: fixed type at STDOFF+SAVE, is_dst set, literal/%z FORMAT
1171 RULES = <name> -> rule set: finite (FROM..TO years) or recurring (TO = maximum)
1172 Rule NAME FROM TO - IN ON AT SAVE LETTER
1173 Link TARGET LINK-NAME (copy or symlink; chains resolved)
1174 The installed single-file tzdata.zi (R/Z/L record keys) is read directly.
1175
1176Offsets / times:
1177 -, integer hours, h:mm, h:mm:ss, signed; fractional seconds rounded to nearest.
1178 AT suffixes: w (wall, default), s (standard), u/g/z (universal).
1179 SAVE suffixes: s (standard), d (daylight); sign honoured.
1180
1181ON day forms:
1182 numeric day, lastSun..lastSat, Sun>=N, Sun<=N (with month spill).
1183
1184FORMAT:
1185 literal, %s (LETTER substitution), STD/DST slash, %z (numeric offset).
1186
1187Footer (POSIX TZ):
1188 fixed offset for finite rule tails; recurring std/dst rule (e.g. EST5EDT,M3.2.0,M11.1.0)
1189 for TO = maximum rule sets with POSIX-expressible (nth/last weekday) day forms.
1190
1191Multi-era zones:
1192 Cross-era state is carried correctly (UNTIL in the ending era's context with the
1193 prevailing save; footer from the final era). A final era whose finite rules all end
1194 before the era starts is classified by its EFFECTIVE in-era activations (recurring-only),
1195 which admits real zones such as Europe/London (first pinned IANA slice).
1196
1197Output:
1198 Valid TZif version 2/3 (content-driven: v1 stub block + v2/v3 block + POSIX TZ footer;
1199 v3 only when a recurring rule's day form requires it).
1200
1201 FROM = minimum is accepted as an obsolete spelling, coerced to 1900 (as reference zic).
1202
1203NOT yet supported (rejected with an explicit diagnostic, never approximated):
1204 inline save with a %s or STD/DST slash FORMAT (a negative inline save IS supported, law 7),
1205 24:00/negative compiled times, recurring rules whose ON day is a fixed numeric day-of-month
1206 (the Sun<=N/Sat<=N weekday forms ARE supported, law 10), and leap seconds (-L).
1207Deferred operational modes: ownership (-u, privileged/Unix-only) and the legacy posixrules
1208link (-p). File mode (-m, octal, Unix-only) IS supported. See docs/unsupported-syntax.md and
1209docs/roadmap.md.
1210"
1211 .to_string()
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::{
1217 parse_octal_mode, parse_redundant_until, reconcile_emit_style, BloatArg, EmitStyleArg,
1218 };
1219 use crate::EmitStyle;
1220
1221 #[test]
1222 fn range_parses_all_three_forms() {
1223 use super::parse_range;
1224 use crate::RangeSpec;
1225 assert_eq!(parse_range(None).unwrap(), None);
1226 assert_eq!(
1228 parse_range(Some("@0")).unwrap(),
1229 Some(RangeSpec {
1230 lo: Some(0),
1231 hi: None
1232 })
1233 );
1234 assert_eq!(
1236 parse_range(Some("@0/@4102444800")).unwrap(),
1237 Some(RangeSpec {
1238 lo: Some(0),
1239 hi: Some(4102444800)
1240 })
1241 );
1242 assert_eq!(
1244 parse_range(Some("/@100")).unwrap(),
1245 Some(RangeSpec {
1246 lo: None,
1247 hi: Some(100)
1248 })
1249 );
1250 }
1251
1252 #[test]
1253 fn range_rejects_malformed() {
1254 use super::parse_range;
1255 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()); }
1263
1264 #[test]
1265 fn redundant_until_requires_at_prefix() {
1266 assert_eq!(parse_redundant_until(None).unwrap(), None);
1267 assert_eq!(
1268 parse_redundant_until(Some("@946684800")).unwrap(),
1269 Some(946684800)
1270 );
1271 assert_eq!(parse_redundant_until(Some("@-1")).unwrap(), Some(-1));
1272 assert!(parse_redundant_until(Some("946684800")).is_err());
1274 assert!(parse_redundant_until(Some("@abc")).is_err());
1275 assert!(parse_redundant_until(Some("@")).is_err());
1276 }
1277
1278 #[test]
1279 fn bloat_alias_maps_onto_emit_style() {
1280 assert_eq!(
1282 reconcile_emit_style(EmitStyleArg::Default, Some(BloatArg::Slim)).unwrap(),
1283 EmitStyle::ZicSlim
1284 );
1285 assert_eq!(
1286 reconcile_emit_style(EmitStyleArg::Default, Some(BloatArg::Fat)).unwrap(),
1287 EmitStyle::ZicFat
1288 );
1289 assert_eq!(
1291 reconcile_emit_style(EmitStyleArg::ZicSlim, None).unwrap(),
1292 EmitStyle::ZicSlim
1293 );
1294 assert_eq!(
1296 reconcile_emit_style(EmitStyleArg::ZicSlim, Some(BloatArg::Slim)).unwrap(),
1297 EmitStyle::ZicSlim
1298 );
1299 }
1300
1301 #[test]
1302 fn bloat_conflicting_with_emit_style_is_error() {
1303 assert!(reconcile_emit_style(EmitStyleArg::ZicSlim, Some(BloatArg::Fat)).is_err());
1304 assert!(reconcile_emit_style(EmitStyleArg::ZicFat, Some(BloatArg::Slim)).is_err());
1305 }
1306
1307 #[test]
1308 fn parses_octal_with_and_without_leading_zero() {
1309 assert_eq!(parse_octal_mode(Some("644")).unwrap(), Some(0o644));
1310 assert_eq!(parse_octal_mode(Some("0644")).unwrap(), Some(0o644));
1311 assert_eq!(parse_octal_mode(Some("0o600")).unwrap(), Some(0o600));
1312 assert_eq!(parse_octal_mode(Some("755")).unwrap(), Some(0o755));
1313 assert_eq!(parse_octal_mode(None).unwrap(), None);
1314 }
1315
1316 #[test]
1317 fn rejects_non_octal_and_out_of_range() {
1318 assert!(parse_octal_mode(Some("999")).is_err());
1320 assert!(parse_octal_mode(Some("64a")).is_err());
1321 assert!(parse_octal_mode(Some("10000")).is_err());
1323 }
1324}