Skip to main content

tzcompile/
cli.rs

1//! Command-line interface — a thin shell over the `tzcompile` library.
2//!
3//! The CLI parses arguments, builds a [`CompileConfig`], and calls the library. It holds no
4//! compiler logic of its own; everything substantive lives in the library so it can be
5//! reused and tested directly. Subcommands:
6//!
7//! * `compile` — compile selected zones to a TZif tree under `--out`;
8//! * `compare` — compile a zone and diff it against reference `zic` (the oracle);
9//! * `explain` — say what a zone would compile to, or why it is unsupported;
10//! * `supported-syntax` — print the subset this version implements;
11//! * `support-report` — map a whole source file: which identifiers compile, and why the rest don't.
12
13use 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/// Top-level CLI definition.
22#[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// `Compile(CompileArgs)` is much larger than the unit/small variants because `CompileArgs` carries
34// the full flag surface (T12.5c added the `--packratlist*` provenance flags). Boxing the variant
35// would break clap's `Subcommand` derive (it expects the bare `Args` type), and a `Command` value is
36// constructed exactly once per process from argv — the size difference is operationally irrelevant —
37// so we accept it deliberately rather than distort the CLI type.
38#[allow(clippy::large_enum_variant)]
39#[derive(Debug, Subcommand)]
40pub enum Command {
41    /// Compile zones from tzdata source into a TZif output tree.
42    Compile(CompileArgs),
43    /// Compile a zone and compare it against reference `zic`.
44    Compare(CompareArgs),
45    /// Describe what a zone would compile to (or why it is unsupported).
46    Explain(ExplainArgs),
47    /// Print the syntax subset supported by this version.
48    SupportedSyntax,
49    /// Report which identifiers in a source file compile, and bucket the rest by reason.
50    SupportReport(SupportReportArgs),
51    /// Inventory how zic-rs TZif output differs *structurally* from reference `zic` (T8).
52    StructuralReport(StructuralReportArgs),
53    /// Emit typed `zdump`-backed **semantic witnesses** (offset/is_dst/abbreviation at probe instants)
54    /// for a compact zone set — the behaviour axis of the conformance engine (T15.3). Degrades visibly
55    /// (`oracle_mode: unavailable`) when reference `zic`/`zdump` are absent; never errors on that.
56    SemanticReport(SemanticReportArgs),
57    /// Validate emitted TZif against **RFC 9636** structural invariants (T15.4) — a *separate* axis from
58    /// semantic behaviour. Emits typed structural/footer/reader-compat/leap-expiry/version verdicts;
59    /// validates zic-rs output **and** reference `zic` output (so the validator respects the real
60    /// producer profile, not only zic-rs's assumptions).
61    TzifValidate(TzifValidateArgs),
62    /// Validate auxiliary tables (`zone.tab`/`zone1970.tab`/`zonenow.tab`/`iso3166.tab`) for **table
63    /// structural admissibility only** (T16.4) — a *separate* surface from compile/semantic/structural.
64    /// These are policy/index/reference artifacts, **not** compile inputs; a conformant row proves the
65    /// row is well-formed, never that the named zone was compiled or is historically equivalent.
66    AuxTableValidate(AuxTableValidateArgs),
67    /// Emit the canonical **`vendor-oracle-receipt-v1`** sample (T16.5) — the schema example an external
68    /// vendor/platform lab fills in. The core repo *admits* receipts (verifies the contract + rules); it
69    /// **does not** run QEMU/VMs (`does_not_ship_or_operate_vendor_qemu_labs_in_core_repo`).
70    VendorOracleSample,
71    /// **Ingest + admit** an externally-produced `vendor-oracle-receipt-v1` JSON file (T16.5b) — the
72    /// convergence point: an external lab generates a receipt, the core repo *admits or rejects* it by
73    /// typed reason. Fail-closed parsing (a parse error is distinct from an inadmissible receipt); the
74    /// core runs no VMs.
75    VendorOracleAdmit(VendorOracleAdmitArgs),
76    /// **Diff two tzdb releases** (T16.6a). Compiles each identifier in `--old` and `--new` and
77    /// classifies the change: structural (always) + behavioural past/future (with a `zdump` oracle).
78    /// Read-only; never installs. JSON `zic-rs-release-diff-v1`.
79    ReleaseDiff(ReleaseDiffArgs),
80    /// **Read-only environment probe** (T16.6b) — reference `zic`/`zdump` present? optional `tzdata.zi`
81    /// version+hash? It admits/validates nothing and always exits 0 (a diagnosis, not a gate).
82    Doctor(DoctorArgs),
83    /// **Read-only bundle footprint** (T21.2) — measure a produced `--out` tree (TZif/link/other counts,
84    /// bytes, version histogram, largest file) + a deterministic `bundle_hash`. For container/embedded
85    /// image builders (`docs/container-embedded-builder.md`). Never compiles/writes/admits.
86    SizeReport(SizeReportArgs),
87}
88
89#[derive(Debug, Args)]
90pub struct CompileArgs {
91    /// Source file(s) or directory(ies) of tzdata.
92    #[arg(long = "input", required = true, num_args = 1..)]
93    pub input: Vec<PathBuf>,
94    /// Output directory root. Required — there is no system default.
95    #[arg(long = "out")]
96    pub out: Option<PathBuf>,
97    /// Compile a single named zone.
98    #[arg(long = "zone")]
99    pub zone: Option<String>,
100    /// Compile the zones listed (one per line) in this file.
101    #[arg(long = "zones")]
102    pub zones: Option<PathBuf>,
103    /// Compile every zone this version supports (others are reported and skipped).
104    #[arg(long = "all-supported", default_value_t = false)]
105    pub all_supported: bool,
106    /// How to materialise links: `copy` (default) or `symlink`.
107    #[arg(long = "link-mode", default_value = "copy")]
108    pub link_mode: LinkModeArg,
109    /// Overwrite existing output files.
110    #[arg(long = "force", default_value_t = false)]
111    pub force: bool,
112    /// On unsupported syntax: `error` (default, fail closed) or `skip`.
113    #[arg(long = "unsupported", default_value = "error")]
114    pub unsupported: UnsupportedArg,
115    /// Maximum transitions emitted per zone.
116    #[arg(long = "transition-limit", default_value_t = DEFAULT_TRANSITION_LIMIT)]
117    pub transition_limit: usize,
118    /// Explicit-transition emission style (T8-slim). `default` = behaviour-matched (CORE.1-gated);
119    /// `zic-slim` reproduces reference `zic`'s slim explicit-transition set; `zic-fat` == default.
120    /// Never changes behaviour — only how many explicit transitions precede the POSIX footer.
121    #[arg(long = "emit-style", value_enum, default_value_t = EmitStyleArg::Default)]
122    pub emit_style: EmitStyleArg,
123    /// Emission bloat (reference `zic`'s `-b {slim|fat}`): a thin alias onto `--emit-style`
124    /// (`slim` → `zic-slim`, `fat` → `zic-fat`). Not the TZif version, not the `-R` redundant tail.
125    /// If both `-b` and a conflicting `--emit-style` are given, that is an error.
126    #[arg(long = "bloat", short = 'b', value_enum)]
127    pub bloat: Option<BloatArg>,
128    /// Redundant-tail bound (reference `zic`'s `-R @hi`): under slim emission, keep otherwise-droppable
129    /// footer-governed transitions out to this instant (`@<unix-seconds>`), for readers that ignore the
130    /// POSIX footer. Only affects `--emit-style zic-slim`/`-b slim` (zic-rs's default is already fat).
131    /// Not bloat (`-b`) and not range truncation (`-r`); never changes behaviour or the TZif version.
132    #[arg(long = "redundant-until", short = 'R')]
133    pub redundant_until: Option<String>,
134    /// Range truncation (reference `zic`'s `-r '[@lo][/@hi]'`): restrict emitted timestamps to the
135    /// `@`-prefixed Unix-second window. **Parse-only for now (T10.4b)** — a well-formed value is
136    /// recognised but not yet applied (the compile fails closed rather than emit un-truncated output);
137    /// truncation + the `-00` unspecified-local-time placeholder land in T10.4d.
138    #[arg(long = "range", short = 'r')]
139    pub range: Option<String>,
140    /// Leap-seconds source (reference `zic`'s `-L leapseconds`): the **`right/` build profile**. When
141    /// given, the parsed leap table is applied to **every** compiled zone. Opt-in; **never** the
142    /// default — without it, ordinary canonical-zone output is unchanged (no leap table).
143    #[arg(long = "leapseconds", short = 'L')]
144    pub leapseconds: Option<PathBuf>,
145    /// Do not create the `--out` tree (reference `zic`'s `-D`); require it to already exist.
146    #[arg(long = "no-create-dirs", short = 'D', default_value_t = false)]
147    pub no_create_dirs: bool,
148    /// Install policy (reference `zic`'s `-l <zone>`): also create a `localtime` link in `--out`
149    /// pointing at this (also-selected) zone. Opt-in; never affects canonical-zone behaviour.
150    #[arg(long = "localtime", short = 'l')]
151    pub localtime: Option<String>,
152    /// Name of the `localtime` link (reference `zic`'s `-t`, default `localtime`). Constrained to a
153    /// safe relative name under `--out` — zic-rs will not write to an arbitrary/system path.
154    #[arg(long = "localtime-name", short = 't')]
155    pub localtime_name: Option<String>,
156    /// File permission bits for created files (reference `zic`'s `-m`), as **octal** (e.g. `644`).
157    /// Unix-only; applies to compiled TZif files and copied links (not symlinks). Symbolic chmod
158    /// expressions are not supported.
159    #[arg(long = "mode", short = 'm')]
160    pub mode: Option<String>,
161    /// Also write an alias/canonical manifest (zones vs links + hashes) to this path.
162    #[arg(long = "alias-map")]
163    pub alias_map: Option<PathBuf>,
164    /// Also write a compile-provenance manifest (source hash, build-profile identity, oracle) here.
165    #[arg(long = "manifest")]
166    pub manifest: Option<PathBuf>,
167    /// Optional **claimed** tzdb release (e.g. `2026b`) recorded in the manifest and reconciled
168    /// against the version *detected* from the source — surfaces a `detected_differs_from_claim`
169    /// rather than silently stamping a release.
170    #[arg(long = "tzdb-version")]
171    pub tzdb_version: Option<String>,
172    /// Optional **claimed** `backward` (legacy-alias) source-set membership, recorded in the
173    /// manifest's `source_profile` as a bare claim (`included`/`excluded`) — never trusted as
174    /// detection. **Provenance only:** does not change what is compiled or linked.
175    #[arg(long = "backward", value_parser = ["included", "excluded"])]
176    pub backward: Option<String>,
177    /// Optional file the caller asserts is the `backward` source. The manifest hash-checks whether
178    /// its *bytes* participated in this build (→ `detected: present|absent`); it does **not** assert
179    /// the file is genuinely the IANA `backward`, and it does **not** affect compilation.
180    #[arg(long = "backward-source")]
181    pub backward_source: Option<PathBuf>,
182    /// Optional **claimed** `backzone` (`PACKRATDATA`) source-set membership, recorded in the manifest's
183    /// `source_profile.backzone_evidence` as a bare claim (`included`/`excluded`) — never trusted as
184    /// detection. Detection is hash-anchored to the pinned reference release (T12.5b). **Provenance
185    /// only:** does not change what is compiled or linked.
186    #[arg(long = "backzone", value_parser = ["included", "excluded"])]
187    pub backzone: Option<String>,
188    /// Optional **claimed** `backzone` *scope* (`PACKRATLIST`): `full` (all backzone) / `subset`
189    /// (filtered, e.g. `PACKRATLIST=zone.tab`) / `none` (no backzone). Recorded in
190    /// `source_profile.packratlist_evidence` as a bare claim — never trusted as detection (T12.5c).
191    /// **Provenance only.**
192    #[arg(long = "packratlist", value_parser = ["full", "subset", "none"])]
193    pub packratlist: Option<String>,
194    /// Optional file the caller **explicitly admits** as the `PACKRATLIST` subset source (e.g.
195    /// `zone.tab`). Detection confirms its *bytes* participated alongside `backzone` (→ `subset`); mere
196    /// presence of `zone.tab` among inputs is **not** admission. **Provenance only** (T12.5c).
197    #[arg(long = "packratlist-source")]
198    pub packratlist_source: Option<PathBuf>,
199    /// Optional **claimed** `DATAFORM` *encoding* form: `main` / `vanguard` / `rearguard`. Recorded in
200    /// `source_profile.dataform_evidence` as a bare claim — never trusted as detection. Detection is
201    /// hash-backed against the pinned 2026b `.zi` artifacts via the compiled inputs (there is no
202    /// `--dataform-source`: the `.zi` artifacts *are* compile sources). **Provenance only** (T12.5d).
203    #[arg(long = "dataform", value_parser = ["main", "vanguard", "rearguard"])]
204    pub dataform: Option<String>,
205    /// Verbose diagnostics (reference `zic`'s `-v`): also surface `VerboseOnly` warnings — e.g. the
206    /// "fewer than 3 characters" abbreviation warning and the transition-count client-compat warning.
207    /// Default (quiet) prints only always-on diagnostics, matching `zic` without `-v` (T13.6). Never
208    /// affects compiled output, exit status, or which warnings are *collected* — only what is printed.
209    #[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    /// Program name/path of the reference `zic`.
220    #[arg(long = "reference-zic", default_value = "zic")]
221    pub reference_zic: String,
222    /// Comparison mode: `zdump` (behaviour over a horizon; the default and the real
223    /// correctness oracle) or `structural` (decoded-TZif model diff; fixed-offset/debug).
224    #[arg(long = "mode", default_value = "zdump")]
225    pub mode: CompareModeArg,
226    /// Year horizon `LO,HI` for `zdump` mode (inclusive). Behaviour is only compared within
227    /// this declared window.
228    #[arg(long = "horizon", default_value = "1900,2100")]
229    pub horizon: String,
230    /// Program name/path of `zdump` (zdump mode only).
231    #[arg(long = "zdump", default_value = "zdump")]
232    pub zdump: String,
233}
234
235/// `--mode` value.
236#[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    /// Source file(s) of tzdata — typically the installed `/usr/share/zoneinfo/tzdata.zi`.
253    #[arg(long = "input", required = true, num_args = 1..)]
254    pub input: Vec<PathBuf>,
255    /// Output format.
256    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
257    pub format: ReportFormatArg,
258    /// Annotate each unsupported/fail-closed bucket with the deep `zic` semantic law it represents
259    /// (text mode). The JSON form always carries a `deep_semantic` field. See
260    /// `docs/zic-deep-semantics.md`.
261    #[arg(long = "explain-buckets", default_value_t = false)]
262    pub explain_buckets: bool,
263}
264
265/// `--format` value for `support-report`.
266#[derive(Debug, Clone, Copy, clap::ValueEnum)]
267pub enum ReportFormatArg {
268    Text,
269    Json,
270}
271
272#[derive(Debug, Args)]
273pub struct StructuralReportArgs {
274    /// Source file(s) of tzdata — typically the installed `/usr/share/zoneinfo/tzdata.zi`.
275    #[arg(long = "input", required = true, num_args = 1..)]
276    pub input: Vec<PathBuf>,
277    /// Program name/path of the reference `zic` to compare against (required — this is a
278    /// comparison, not a self-report).
279    #[arg(long = "reference-zic", default_value = "zic")]
280    pub reference_zic: String,
281    /// Restrict the inventory to a single canonical zone (default: every canonical zone).
282    #[arg(long = "zone")]
283    pub zone: Option<String>,
284    /// Emission style for *our* side of the comparison (T8-slim). `default` keeps the
285    /// behaviour-matched output; `zic-slim` reproduces reference `zic`'s slim explicit-transition
286    /// set, collapsing the `slim/fat-timecnt` class.
287    #[arg(long = "emit-style", value_enum, default_value_t = EmitStyleArg::Default)]
288    pub emit_style: EmitStyleArg,
289    /// Output format.
290    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
291    pub format: ReportFormatArg,
292}
293
294/// `semantic-report` (T15.3): typed `zdump`-backed semantic witnesses for a compact zone set.
295#[derive(Debug, clap::Args)]
296pub struct SemanticReportArgs {
297    /// Source file(s) of tzdata.
298    #[arg(long = "input", required = true, num_args = 1..)]
299    pub input: Vec<PathBuf>,
300    /// Reference `zic` program (compiles the oracle side).
301    #[arg(long = "reference-zic", default_value = "zic")]
302    pub reference_zic: String,
303    /// Reference `zdump` program (the footer-aware behaviour oracle).
304    #[arg(long = "zdump", default_value = "zdump")]
305    pub zdump: String,
306    /// Zones to witness (repeatable). Default: a small curated set (those present in the input).
307    #[arg(long = "zone")]
308    pub zone: Vec<String>,
309    /// Output format (JSON is the machine-readable conformance surface).
310    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
311    pub format: ReportFormatArg,
312}
313
314/// `tzif-validate` (T15.4): RFC 9636 structural validation of zic-rs + reference TZif output.
315#[derive(Debug, clap::Args)]
316pub struct TzifValidateArgs {
317    /// Source file(s) of tzdata.
318    #[arg(long = "input", required = true, num_args = 1..)]
319    pub input: Vec<PathBuf>,
320    /// Reference `zic` program — its output is validated too (the producer-profile guard).
321    #[arg(long = "reference-zic", default_value = "zic")]
322    pub reference_zic: String,
323    /// Zones to validate (repeatable). Default: a small curated set (those present in the input).
324    #[arg(long = "zone")]
325    pub zone: Vec<String>,
326    /// Output format.
327    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
328    pub format: ReportFormatArg,
329}
330
331/// `aux-table-validate` (T16.4): structural validation of auxiliary `.tab` files. Each flag points at a
332/// table file directly — they are **not** `--input` compile sources (the category law).
333#[derive(Debug, clap::Args)]
334pub struct AuxTableValidateArgs {
335    /// Path to `zone.tab`.
336    #[arg(long = "zone-tab")]
337    pub zone_tab: Option<PathBuf>,
338    /// Path to `zone1970.tab` (cross-validated against `iso3166.tab` when both are given).
339    #[arg(long = "zone1970-tab")]
340    pub zone1970_tab: Option<PathBuf>,
341    /// Path to `zonenow.tab` (now/future-agreement table — `XX` codes allowed).
342    #[arg(long = "zonenow-tab")]
343    pub zonenow_tab: Option<PathBuf>,
344    /// Path to `iso3166.tab`.
345    #[arg(long = "iso3166-tab")]
346    pub iso3166_tab: Option<PathBuf>,
347    /// Output format.
348    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
349    pub format: ReportFormatArg,
350}
351
352/// `vendor-oracle-admit` (T16.5b): ingest + admit an external receipt JSON file.
353#[derive(Debug, clap::Args)]
354pub struct VendorOracleAdmitArgs {
355    /// Path to an external `vendor-oracle-receipt-v1` JSON file.
356    #[arg(long = "receipt", required = true)]
357    pub receipt: PathBuf,
358}
359
360/// `release-diff` (T16.6a): diff two tzdb releases per identifier.
361#[derive(Debug, clap::Args)]
362pub struct ReleaseDiffArgs {
363    /// The OLD release source (`tzdata.zi` file or a source directory).
364    #[arg(long = "old", required = true)]
365    pub old: Vec<PathBuf>,
366    /// The NEW release source (`tzdata.zi` file or a source directory).
367    #[arg(long = "new", required = true)]
368    pub new: Vec<PathBuf>,
369    /// Restrict to a single identifier.
370    #[arg(long = "zone")]
371    pub zone: Option<String>,
372    /// Behaviour horizon in years (default `1900,2040`, CORE.1's).
373    #[arg(long = "horizon", default_value = "1900,2040")]
374    pub horizon: String,
375    /// Past/future split **year** (deterministic; default `2025`). Never host-`now`.
376    #[arg(long = "split", default_value_t = 2025)]
377    pub split: i32,
378    /// Reference `zdump` for the behaviour axis. Omit ⇒ behaviour not assessed (structural still runs).
379    #[arg(long = "reference-zdump")]
380    pub reference_zdump: Option<String>,
381    /// Output format.
382    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Json)]
383    pub format: ReportFormatArg,
384}
385
386/// `doctor` (T16.6b): read-only environment probe.
387#[derive(Debug, clap::Args)]
388pub struct DoctorArgs {
389    /// Reference `zic` program to probe (default `zic`).
390    #[arg(long = "reference-zic", default_value = "zic")]
391    pub reference_zic: String,
392    /// Reference `zdump` program to probe (default `zdump`).
393    #[arg(long = "reference-zdump", default_value = "zdump")]
394    pub reference_zdump: String,
395    /// Optional explicit `tzdata.zi` to probe for version + hash (never read implicitly).
396    #[arg(long = "tzdata")]
397    pub tzdata: Option<PathBuf>,
398    /// Output format.
399    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
400    pub format: ReportFormatArg,
401}
402
403#[derive(Debug, Args)]
404pub struct SizeReportArgs {
405    /// The produced output tree to measure (an existing directory written by `compile --out`).
406    #[arg(long = "out")]
407    pub out: PathBuf,
408    /// Output format.
409    #[arg(long = "format", value_enum, default_value_t = ReportFormatArg::Text)]
410    pub format: ReportFormatArg,
411}
412
413/// `--emit-style` value (T8-slim).
414#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
415pub enum EmitStyleArg {
416    /// Behaviour-matched default (CORE.1-gated; fat-ish).
417    Default,
418    /// Reference `zic` slim: truncate the footer-governed recurring tail.
419    ZicSlim,
420    /// Reference `zic` fat (currently == default).
421    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/// Reference `zic`'s `-b {slim|fat}` *bloat* value (T10.2). This is the **emission policy** knob —
435/// whether otherwise-redundant transitions are kept (`fat`) or dropped (`slim`) — and is a thin
436/// alias onto [`EmitStyleArg`]. It is **not** the TZif version (content-driven) and **not** the
437/// `-R` redundant-tail range. `zic`'s own default is `slim`; zic-rs keeps a behaviour-matched
438/// fat-style default and only changes emission when asked.
439#[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/// `--link-mode` value.
455#[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/// `--unsupported` value.
471#[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
486/// Run the CLI. Returns `Ok(())` on success; the caller maps `Err` to a process exit code.
487pub 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
515/// `support-report`: load the source, sniff the tzdb release, build the frontier map, print it.
516fn run_support_report(args: SupportReportArgs) -> Result<()> {
517    // Sniff the tzdb release from the *raw* bytes (comments are stripped during parsing). Use the
518    // first input file's header, where `zic`'s single-file output records `# version <X>`.
519    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
532/// `structural-report`: compile every canonical zone with zic-rs and reference `zic`, decode
533/// both, and classify the structural differences (T8). This is a *separate axis* from the
534/// behaviour oracle — see `src/structural.rs` and `docs/structural-parity.md`.
535fn 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    // Reference `zic` takes files, not directories — expand inputs the same way `compare` does.
547    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
568/// T15.3 — emit typed `zdump`-backed semantic witnesses for a compact zone set. Does **not** error when
569/// the oracle is absent: the report renders `oracle_mode: unavailable` (visible, never silent).
570fn run_semantic_report(args: SemanticReportArgs) -> Result<()> {
571    let db = crate::load_database(&args.input)?;
572    let files = crate::collect_source_files(&args.input)?;
573    // Curated default: a small, meaningful spread (UTC + DST + multi-era + sub-hour), filtered to those
574    // actually present in the input — *make the mechanism public, do not sweep every zone*.
575    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
623/// T15.4 — RFC 9636 TZif structural validation of zic-rs + reference output for a compact zone set.
624fn 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
678/// T16.4 — validate auxiliary tables for **structural admissibility only** (policy/index artifacts, not
679/// compile inputs). Reads each table file directly; `zone1970.tab` is cross-validated against
680/// `iso3166.tab` when both are supplied. A missing flag simply omits that table (no error).
681fn 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    // Build the ISO-3166 code set first (also validated below), for zone1970 cross-validation.
689    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        // Cross-validate zone.tab country codes against the same-release iso3166.tab when supplied.
695        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
741/// T16.5b — ingest + admit an external vendor-oracle receipt JSON file (the convergence point: external
742/// lab generates the receipt, the core repo admits/rejects it). **Parse failure ≠ inadmissible** — a
743/// malformed receipt is a parse error (exit 1, distinct message); a well-formed-but-failing receipt parses
744/// and is reported non-admitted (exit 1, admission reason). An admitted receipt exits 0.
745fn 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        // Distinct path 1: the bytes are not a well-formed v1 receipt.
751        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                // Distinct path 2: parsed fine, but the evidence does not meet the admission rules.
764                Err(Error::config(format!(
765                    "receipt not admitted: {}",
766                    verdict.as_str()
767                )))
768            }
769        }
770    }
771}
772
773// `release-diff` is a **witness/report, not a gate**: finding differences between OLD and NEW is the
774// *output*, so it still exits 0. A non-zero exit means only an *operational* failure (a malformed
775// `--horizon`, an unreadable source, an inverted horizon) — never "the releases differ." Identifiers
776// outside zic-rs's compile subset are reported as `errors[]` rows, not a process failure. (Contrast
777// `doctor`, also non-gating but always-0; and `compile`, which *is* a gate on the source.)
778fn run_release_diff(args: ReleaseDiffArgs) -> Result<()> {
779    use crate::release_diff::{build_release_diff, ReleaseDiffOptions};
780    // Parse "LO,HI" years (an operational/config error if malformed — distinct from "no differences").
781    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    // `doctor` is a diagnosis, not a gate: always exit 0 (absent tools are reported, not errors).
844    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
857/// Parse `--mode` (reference `zic`'s `-m`) as an **octal** permission string (e.g. `644`, `0644`,
858/// `0o600`) into permission bits. A leading `0o` is tolerated; a leading `0` is fine (octal radix).
859/// Rejects non-octal input and values above `0o7777` as a config error — caught *before* any write.
860/// zic-rs accepts only this octal subset; symbolic chmod expressions (`u+rwx`) are not parsed.
861/// Load + parse an explicit leap-seconds source (reference `zic`'s `-L`), the `right/` build profile
862/// (T11.6). `None` → ordinary profile (no leap table). A missing/unreadable file or a malformed
863/// leap-source is a config error (caught before any output).
864fn 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
883/// Reconcile the two spellings of the emission-bloat knob (T10.2). `--emit-style` is the native
884/// surface; `-b {slim|fat}` is the reference-`zic` alias. When only one is given it decides; when
885/// both are given they must agree (else "incompatible options", mirroring `zic`'s own `-b` check).
886/// `-b` against a left-at-`default` `--emit-style` simply wins.
887fn 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
911/// Parse reference `zic`'s `-R @hi` redundant-tail bound: an `@`-prefixed Unix-seconds instant
912/// (e.g. `@4102444800`). Matches `zic`'s `redundant_time_option`, which **requires** the `@`. A
913/// missing `@` or non-integer body is a config error — caught before any write.
914fn 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
927/// Parse reference `zic`'s `-r '[@lo][/@hi]'` range spec into [`crate::RangeSpec`] (T10.4b). Grammar:
928/// an optional `@lo`, an optional `/@hi`, at least one present; each `@`-prefixed Unix seconds. This
929/// is **parse + validation only** — the bounds are not yet applied (T10.4d); the `hi -= 1` /
930/// `limitrange` resolution is deferred. Rejects empty input, a missing `@`, a non-integer body,
931/// trailing junk, and `hi < lo`.
932fn 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    // Grammar `[@lo][/@hi]`: split on the first `/`. No `/` → the whole thing is `@lo`.
944    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    // Resolve the (borrowing) selection before consuming any owned fields of `args`.
970    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    // Deterministic, human-readable summary on stdout; diagnostics on stderr.
997    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, // stored as the raw version byte (e.g. b'2')
1003            z.transition_count
1004        );
1005    }
1006    for l in &report.links_written {
1007        println!("linked {} -> {} ({:?})", l.link_name, l.target, l.mode);
1008    }
1009    // T13.6 — verbosity filter (mirrors reference `zic`'s `noise`/`-v` gating): always surface
1010    // `AlwaysOn` diagnostics; surface `VerboseOnly` ones (e.g. the "fewer than 3 characters"
1011    // abbreviation warning, the transition-count warning) only under `--verbose`/`-v`. The report
1012    // still *collects* everything (programmatic consumers + the comparison harness see it all); this
1013    // only governs what the CLI prints, so default output matches `zic` and `-v` matches `zic -v`.
1014    for d in &report.diagnostics {
1015        if args.verbose || d.verbosity == crate::diagnostics::DiagnosticVerbosity::AlwaysOn {
1016            eprintln!("{d}");
1017        }
1018    }
1019
1020    // Optional producer-side artifacts (T3.4b/c). These describe *this* invocation only.
1021    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        // `requested` is the resolved identifier list (so `--all-supported` is recorded in
1028        // full); the oracle is `not-run` because `compile` does not invoke `compare`.
1029        let requested = plan::select_zones(&db, &config.zones);
1030        let source_files = crate::collect_source_files(&config.input_paths)?;
1031        // Source-variant evidence axes (T12.4d `backward`; T12.5b `backzone`) — provenance only, from
1032        // explicit flags. `--backward`/`--backzone` are bare claims; `--backward-source` is a file whose
1033        // bytes are hash-checked. None affect compilation; absence leaves each axis `unknown` (backzone
1034        // detection is hash-anchored to the pinned 2026b reference regardless of the claim flag).
1035        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
1059/// Turn the three mutually-exclusive selection flags into a [`ZoneSelection`].
1060fn 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    // Reference `zic` takes files, not directories — expand inputs to the same flat file
1083    // list our own parser used, so both compilers see identical source.
1084    let files = crate::collect_source_files(&args.input)?;
1085
1086    // Resolve the comparison mode (default zdump — the real behaviour oracle).
1087    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    // A unique, self-cleaning working directory for scratch output (absolute path, which
1100    // `zdump` requires).
1101    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
1124/// Parse a `LO,HI` year horizon.
1125fn 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            // An "unsupported" explanation is informational, not a crash: print it and
1154            // exit non-zero so scripts can detect it.
1155            println!("{d}");
1156            Err(Error::message(format!("{} is not supported", args.zone)))
1157        }
1158    }
1159}
1160
1161/// The human-readable supported-syntax summary (also the source of truth for
1162/// `docs/supported-syntax.md`'s prose).
1163pub 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        // @lo only
1227        assert_eq!(
1228            parse_range(Some("@0")).unwrap(),
1229            Some(RangeSpec {
1230                lo: Some(0),
1231                hi: None
1232            })
1233        );
1234        // @lo/@hi
1235        assert_eq!(
1236            parse_range(Some("@0/@4102444800")).unwrap(),
1237            Some(RangeSpec {
1238                lo: Some(0),
1239                hi: Some(4102444800)
1240            })
1241        );
1242        // /@hi only
1243        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()); // empty
1256        assert!(parse_range(Some("0/@1")).is_err()); // lo missing @
1257        assert!(parse_range(Some("@0/1")).is_err()); // hi missing @
1258        assert!(parse_range(Some("@abc")).is_err()); // non-integer
1259        assert!(parse_range(Some("@0/")).is_err()); // trailing slash, no @hi
1260        assert!(parse_range(Some("@10/@5")).is_err()); // hi < lo
1261        assert!(parse_range(Some("@1/@2/@3")).is_err()); // trailing junk
1262    }
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        // Missing `@` and non-integer bodies are errors (zic requires the `@`).
1273        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        // `-b` alone decides; `--emit-style` left at Default.
1281        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        // No `-b`: `--emit-style` passes through.
1290        assert_eq!(
1291            reconcile_emit_style(EmitStyleArg::ZicSlim, None).unwrap(),
1292            EmitStyle::ZicSlim
1293        );
1294        // Both given and agreeing: fine.
1295        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        // 8 and 9 are not octal digits.
1319        assert!(parse_octal_mode(Some("999")).is_err());
1320        assert!(parse_octal_mode(Some("64a")).is_err());
1321        // Above 0o7777 (perm + setuid/setgid/sticky bits).
1322        assert!(parse_octal_mode(Some("10000")).is_err());
1323    }
1324}