Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy-zoomies

osm-katana/src/bin/main.rs

use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

/// Which recorded defect budget `verify --geometry-quality` judges a cut against.
/// The budgets themselves live in `osm_katana::verify` with their provenance — this
/// is only the CLI's name for them.
#[derive(Clone, Copy, Debug, ValueEnum)]
enum BaselineArg {
    /// `GeometryBaseline::ZERO` — nothing tolerated.
    Zero,
    /// `GeometryBaseline::RECORDED` — the live Sweden census of 2026-09-02.
    Recorded,
}

#[derive(Parser)]
// `--version` / `-V` prints `osm-katana v<semver> (<git-hash>)` — the build-version
// law, one source (`osm_katana::VERSION_LINE`, stamped by `build.rs`).
#[command(name = "osm-katana", about = "Fast OSM format converter", version = osm_katana::VERSION_LINE)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Convert OSM (XML / BZ2 / GZ / PBF) → GeoParquet
    Convert {
        input: PathBuf,
        #[arg(short, long, default_value = "out")]
        output: PathBuf,
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        nodes: bool,
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        ways: bool,
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        relations: bool,
        /// Write the `node_refs` column of `ways.parquet`.
        ///
        /// MEASURED on korp's 2026-08-03 `sweden` cut: 237.1 MB of an 893.3 MB
        /// `ways.parquet` — **26.5 % of the file**, 1 009.3 MB of 3 648.3 MB
        /// uncompressed. Only two consumers read it: the relation ring assembly,
        /// which matches member-way endpoints by node id (so a cut that will be
        /// handed to `osm-katana relations` needs it), and `osm-katana verify`.
        /// A pure map-delivery cut needs neither.
        ///
        /// `--node-refs false` OMITS the column rather than writing empty lists —
        /// an absent column is honest, a present empty one would claim every way
        /// has no nodes. Defaults to `true`, so no published pack changes shape.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        node_refs: bool,
        #[arg(long, default_value = "zstd")]
        compression: String,
        #[arg(long, default_value_t = 0)]
        vtd_workers: usize,
        /// resolved (2-pass, ways → WKB) | raw (1-pass, keep node-ID list)
        #[arg(long, default_value = "resolved")]
        geometry: String,
        #[arg(long, default_value_t = false)]
        skip_changesets: bool,
        #[arg(long)]
        log: Option<PathBuf>,
        /// Region clip: a preset (europe|nordics) or a raw bbox
        /// min_lon,min_lat,max_lon,max_lat. Nodes outside are dropped at decode
        /// (during the parallel convert — zero extra pass). Mutually exclusive
        /// with --poly. Requires the default 'resolved' geometry.
        #[arg(long, conflicts_with = "poly")]
        clip: Option<String>,
        /// Region clip from a polygon file: Osmosis `.poly` or GeoJSON
        /// (Polygon/MultiPolygon). Clips to the region's REAL shape via a
        /// per-node ray-cast inside the workers (bbox pre-filtered). Mutually
        /// exclusive with --clip.
        #[arg(long)]
        poly: Option<PathBuf>,
        /// Keep the `node_coords.arrow` intermediate after the convert finishes.
        ///
        /// It is the mmap-backed node store pass 2 resolves way geometry through,
        /// so it is always WRITTEN; this only controls whether it is kept. It is
        /// large — 75 MB for Stockholm, **1.68 GB for Sweden** — and buys nothing
        /// for a shipped GeoParquet pack. `--keep-node-coords false` deletes it
        /// (and any `_nc_chunk_*.bin` spill files) once the parquet is closed.
        /// Defaults to `true`, so existing behaviour is unchanged.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        keep_node_coords: bool,

        /// Skip pass 1 when a complete `node_coords.arrow` from THIS input and
        /// THIS clip is already in the output dir.
        ///
        /// Pass 1 exists only to build that file (plus `nodes.parquet`); a run
        /// that already has them can open the store straight off disk and go
        /// directly to pass 2. On the Sweden extract that is 1 365 s of wall not
        /// paid twice, which is what makes an interrupted convert resumable
        /// instead of merely restartable.
        ///
        /// Reuse is gated on a provenance record written beside the artefact and
        /// is refused — out loud, naming the reason — on any mismatch in input
        /// path, length, mtime or clip, and on any artefact missing its magic at
        /// both ends. `--reuse-node-coords false` forces a full pass 1.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        reuse_node_coords: bool,

        /// Hilbert-pack the base tables before returning — the same layout
        /// `osm-katana spatial-pack` and `osm-katana networks` produce.
        ///
        /// Convert writes in PBF order, so every row group spans the whole
        /// extract and a viewport query has nothing to prune on. MEASURED on the
        /// Sweden cut: central Stockholm out of the unpacked 902.13 MB
        /// `ways.parquet` is a full scan — **902.13 MB, 3 538.2 ms**. Packed, the
        /// same answer reads 71 of 1 139 row groups: **27.08 MB, 144.0 ms**.
        ///
        /// The price, also measured: 78.3 s for the three Sweden tables and up to
        /// 15.6 GB resident while the largest one is sorted.
        /// `--pack false` leaves the tables in convert order.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        pack: bool,
    },

    /// **Spatially pack** a GeoParquet dir/file: sort rows on a Hilbert curve
    /// over their geometry and add a GeoParquet 1.1 `covering.bbox` column, so a
    /// reader can prune whole row groups by min/max statistics and fetch only the
    /// byte ranges its viewport touches. Content-preserving — same rows, same
    /// values, new order (`verify --digest` `set` is unchanged).
    SpatialPack {
        /// Input GeoParquet directory (nodes/ways/relations.parquet) or a single file.
        input: PathBuf,
        /// Output directory (or file, if `input` is a file).
        #[arg(short, long, default_value = "packed")]
        output: PathBuf,
        /// Rows per row group. Smaller ⇒ finer pruning, more footer bytes.
        /// Default measured — see `spatial::PACK_ROW_GROUP_ROWS`.
        #[arg(long, default_value_t = osm_katana::spatial::PACK_ROW_GROUP_ROWS)]
        row_group_rows: usize,
        /// Emit the `covering.bbox` column. `false` = Hilbert ordering only
        /// (schema unchanged) — useful to isolate ordering from covering.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        covering: bool,
        /// Emit the generalisation columns `geometry_z6`/`z9`/`z12` (DELTA 2).
        /// `false` is the measurement's RED arm — the same viewport read then has only
        /// full-precision `geometry` to project, and the decode cost comes back.
        /// Ignored for point-only tables, which have nothing to simplify.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        generalise: bool,
        /// Move `node_refs` out of the packed table into `<name>.refs.parquet` beside
        /// it (row 22). MEASURED on the live Sweden cut: `node_refs` is **437.3 MB
        /// compressed, 28.4 % of `ways.parquet`** — more than every rung column
        /// together — and nothing korp serves reads it. The two consumers that do,
        /// `osm-katana relations` and `osm-katana verify`, read the sidecar.
        #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
        node_refs_sidecar: bool,
        #[arg(long, default_value = "zstd")]
        compression: String,
    },

    /// **Cut the road / rail / trail networks** out of a region extract as their own
    /// GeoParquet tables — stored ONCE, Hilbert-sorted with a `covering.bbox` column, and
    /// never duplicated into a tile. LOD for a network is a `WHERE class IN (…)`, not a
    /// pyramid, so one cut serves every zoom. See `osm_katana::network`.
    Networks {
        /// Region extract directory (the one holding `ways.parquet`).
        input: PathBuf,
        /// Output directory for `roads.parquet` / `rails.parquet` / `trails.parquet`
        /// plus `networks.json`. Write it BESIDE a served tile pyramid, never into it.
        #[arg(short, long, default_value = "networks")]
        output: PathBuf,
        /// Only these networks (default: all three).
        #[arg(long, value_delimiter = ',')]
        only: Option<Vec<String>>,
        /// Rows per row group — the bbox-pruning granularity. Default measured —
        /// see `spatial::PACK_ROW_GROUP_ROWS`.
        #[arg(long, default_value_t = osm_katana::spatial::PACK_ROW_GROUP_ROWS)]
        row_group_rows: usize,
        #[arg(long, default_value = "zstd")]
        compression: String,
        /// Hard ceiling on selected rows held in memory. A stop, not a warning.
        #[arg(long, default_value_t = 40_000_000)]
        max_rows: usize,
        /// Skip the adjacency (`graph_edges` / `graph_nodes`). Off by default — a network
        /// table without a graph can only answer "which lines cross this box".
        #[arg(long)]
        no_graph: bool,
    },

    /// **Cut the `buildings` table** out of a region extract — one row per
    /// `building=*` / `building:part=*` feature, with `height_m`, `min_height_m`,
    /// `levels` and a `height_source` stamp saying where each number came from.
    ///
    /// The point is the stamp. MEASURED on the Sweden cut 2026-08-31: 95.68 % of
    /// buildings carry no height information at all, 4.15 % have it derived from
    /// `building:levels`, 0.24 % carry a tagged `height`. `buildings.json` reports that
    /// histogram, so an operator learns the coverage without opening the parquet. See
    /// `osm_katana::side_outputs::buildings` and `osm_katana::shared::height`.
    Buildings {
        /// Region extract directory (the one holding `ways.parquet`).
        input: PathBuf,
        /// Output directory for `buildings.parquet` + `buildings.json`. Write it BESIDE
        /// a served tile pyramid, never into it.
        #[arg(short, long, default_value = "buildings")]
        output: PathBuf,
        /// Rows per row group — the bbox-pruning granularity. Default measured —
        /// see `spatial::PACK_ROW_GROUP_ROWS`.
        #[arg(long, default_value_t = osm_katana::spatial::PACK_ROW_GROUP_ROWS)]
        row_group_rows: usize,
        #[arg(long, default_value = "zstd")]
        compression: String,
        /// Hard ceiling on selected rows held in memory. A stop, not a warning.
        #[arg(long, default_value_t = 40_000_000)]
        max_rows: usize,
        /// Leave `building:part` rows out. They are the rows carrying `min_height`, so
        /// dropping them makes a tower's parts vanish — off by default.
        #[arg(long)]
        no_parts: bool,
        /// Carry the raw `roof:shape` value as its own column. **Off by default and
        /// measured**: it MOVES the recipe token, so every buildings table on every box
        /// reads Stale and is re-cut. The 3D lane's switch; see the module docs on
        /// `roof_shape_field` for why the raw string and not a bucket code.
        #[arg(long)]
        roof_shape: bool,
        /// Skip DELTA 2's generalisation columns (`geometry_z6` / `z9` / `z12`). Off by
        /// default — a z6 read of 3.8 M footprints without them pays the full WKB decode.
        #[arg(long)]
        no_generalise: bool,
    },

    /// **Ask the road/rail GRAPH a question a table of geometries cannot answer.**
    ///
    /// Reads `graph_nodes.parquet` + `graph_edges.parquet` (cut by `networks`) into a CSR
    /// that respects `oneway`, and runs one of:
    ///
    /// * `unguarded-crossings` — every `railway=level_crossing` that the ADJACENCY says is
    ///   a real joint (a road edge and a rail edge both end there) and that carries no
    ///   barrier, light, bell or supervision, with the road classes that meet there.
    /// * `nearest-crossing` — Dijkstra from `--lon`/`--lat` to the closest unguarded
    ///   crossing **along the network**, reported beside the crow-fly distance to the same
    ///   node. The gap between the two numbers is the whole argument for the graph.
    Graph {
        /// The networks directory holding `graph_nodes.parquet` and `graph_edges.parquet`.
        dir: PathBuf,
        /// `unguarded-crossings` | `nearest-crossing`.
        #[arg(long, default_value = "unguarded-crossings")]
        question: String,
        #[arg(long, allow_hyphen_values = true)]
        lon: Option<f64>,
        #[arg(long, allow_hyphen_values = true)]
        lat: Option<f64>,
        /// How many rows of detail to print.
        #[arg(long, default_value_t = 15)]
        limit: usize,
    },

    /// **Cut a slippy-tile GeoParquet pyramid** out of a converted extract, and
    /// write the `tiles/manifest.json` beside it.
    ///
    /// This is the same [`osm_katana::tiles::tile_region`] korp's `--tile-maps`
    /// calls — one writer of a pyramid, so a cut done by hand and a cut done by the
    /// server produce the same objects and the same [`osm_katana::tiles::TileRecipe`]
    /// (LAW 5). It exists because measuring a pyramid, or cutting one BESIDE a live
    /// one, previously meant either driving korp (which ships to S3 and restarts a
    /// server) or a bench (which deletes what it produced).
    ///
    /// It writes files and nothing else: no S3, no manifest upload, no service.
    Tile {
        /// Converted extract directory holding `ways.parquet` / `nodes.parquet`.
        input: PathBuf,
        /// Output directory. The pyramid lands under `<output>/tiles/z{Z}/{X}/{Y}.parquet`.
        #[arg(short, long, default_value = "tiled")]
        output: PathBuf,
        /// Zoom levels to cut, comma-separated. Omitted ⇒
        /// [`osm_katana::tiles::DEFAULT_TILE_ZOOMS`].
        ///
        /// **`Option` rather than a `default_value` literal on purpose (LAW 5).** A
        /// clap `default_value` must be a literal, so spelling the ladder here made
        /// this CLI a SECOND writer of the default zoom set: when `DEFAULT_TILE_ZOOMS`
        /// gained `15` on 2026-08-03 this string still said `6,9,12`, so a hand cut and
        /// the server's cut would have produced pyramids with different recipes — the
        /// exact disagreement this subcommand exists to make impossible.
        #[arg(long)]
        zooms: Option<String>,
        /// Region name written into the manifest.
        #[arg(long, default_value = "region")]
        region_name: String,
        /// Region bbox `min_lon,min_lat,max_lon,max_lat`. Tiles outside it are refused
        /// at the one point a tile comes into existence. Without it a leaked foreign
        /// object IS tiled where it lies.
        #[arg(long)]
        region: Option<String>,
        /// Resident tile-payload budget in GiB; over it the biggest tiles spill to a
        /// per-shard scratch file. `0` ⇒ unbounded. Omitted ⇒ the library's
        /// `default_tile_mem_budget()`, which scales with this box's MemAvailable —
        /// NOT a 4.0 spelled here, which is how this binary and korp's
        /// `run_tile_maps` came to cut with two different budgets (LAW 5).
        #[arg(long)]
        budget_gib: Option<f64>,
        /// Scan workers. `0` ⇒ all cores.
        #[arg(long, default_value_t = 0)]
        workers: usize,
        /// Simplification basis in pixels per tile — part of the `TileRecipe`, so
        /// changing it makes every existing pyramid read as STALE. Omitted ⇒
        /// [`osm_katana::tiles::DEFAULT_TILE_RASTER_PX`].
        ///
        /// **`Option` rather than `default_value_t = 2048`, for the same reason
        /// `--zooms` is (LAW 5).** A clap `default_value_t` is a literal, so `2048`
        /// spelled here made this CLI a SECOND writer of a `TileRecipe` axis: the day
        /// the library's simplification basis moves, a hand cut and korp's cut stamp
        /// different tokens and each judges the other's pyramid STALE. The literal
        /// cannot be checked against the constant by anything — but "not given" can be,
        /// because "not given" is read at run time.
        #[arg(long)]
        raster_px: Option<u32>,
        #[arg(long, default_value = "zstd")]
        compression: String,
        /// Hand `highway=*` and `railway=*` to a network table instead of duplicating
        /// them into every tile they cross. Part of the `TileRecipe`. Omitted ⇒ the
        /// library's default (`TileOptions::default().exclude_networks`).
        ///
        /// `Option` for the same reason as `--raster-px`: `default_value_t = true` is a
        /// literal restatement of a library default, and it also erased the difference
        /// between "the operator did not say" and "the operator asked for the default"
        /// — which are not the same request when the default is what moves.
        #[arg(long, action = clap::ArgAction::Set)]
        exclude_networks: Option<bool>,
    },

    /// Convert OSM XML (or .osm.bz2) → zstd-compressed PBF
    Xml2Pbf {
        input: PathBuf,
        output: Option<PathBuf>,
        #[arg(long, default_value_t = false)]
        skip_changesets: bool,
    },

    /// Convert OSM PBF → GeoParquet
    Pbf2Geo {
        input: PathBuf,
        #[arg(short, long, default_value = "out")]
        output: PathBuf,
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        nodes: bool,
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        ways: bool,
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        relations: bool,
        #[arg(long, default_value = "zstd")]
        compression: String,
    },

    /// Convert GeoParquet → Arrow IPC
    Geo2Arrow { input: PathBuf, output: PathBuf },

    /// Shrink a GeoParquet dir into a small, embed-ready GeoParquet (for a wasm
    /// demo's `include_bytes!`). Trims to geometry + a slimmed tag set, keeps
    /// named POIs first, caps the feature count, and zstd-compresses — the
    /// gatling (1 reader → N workers → 1 writer) way.
    Optimize {
        /// Input GeoParquet directory (with nodes.parquet / ways.parquet).
        input: PathBuf,
        /// Output GeoParquet FILE.
        #[arg(short, long, default_value = "optimized.parquet")]
        output: PathBuf,
        /// Keep only node points (read nodes.parquet, skip ways/relations).
        #[arg(long, default_value_t = false)]
        points_only: bool,
        /// Fold ways.parquet in too (ignored with --points-only). Default on.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        ways: bool,
        /// Rank named (name/place/POI-tagged) features first under --max-features.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        named_first: bool,
        /// Keep ONLY named features (drops everything without a name/place/POI tag).
        #[arg(long, default_value_t = false)]
        named_only: bool,
        /// **Buildings mode** — keep every `building=*` way (regardless of name) +
        /// the roads/water ways, preserving `height` / `building:levels` so the
        /// 3D map viewer can extrude real city footprints. Named POIs are kept too;
        /// unnamed clutter is dropped. Buildings are ranked first under
        /// `--max-features`. Overrides `--named-only`.
        #[arg(long, default_value_t = false)]
        buildings: bool,
        /// Cap total output features (0 = unlimited), named-first if --named-first.
        #[arg(long, default_value_t = 0)]
        max_features: usize,
        /// Parquet compression: zstd (default) | snappy | none.
        #[arg(long, default_value = "zstd")]
        compression: String,
        /// Optional phase-log JSONL path (cores-busy is always echoed to stderr).
        #[arg(long)]
        log: Option<PathBuf>,
    },

    /// **Cut a region's administrative boundary out of a planet** as an Osmosis
    /// `.poly` — from a `.osm.pbf` OR, block-parallel, straight from a
    /// `.osm.bz2`.
    ///
    /// This is where a clip polygon is supposed to come from: the same OSM data
    /// the map is cut from, not a download. `--admin-level 2` is the sovereign
    /// national border, whose outer ring is by definition the official one —
    /// islands and all.
    AdminPoly {
        /// Planet or extract: `.osm.bz2` (block-parallel bz2 sweep) or `.osm.pbf`.
        input: PathBuf,
        /// Region name, matched case-insensitively against `name` and `name:en`
        /// (so `sweden` finds `name:en=Sweden` / `name=Sverige`).
        #[arg(short, long)]
        name: String,
        /// Required `admin_level` (2 = country). Omit to accept any level.
        #[arg(long)]
        admin_level: Option<String>,
        /// Where to write the `.poly` (stdout summary either way).
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Polygon name written into the `.poly` header (default: `--name`).
        #[arg(long)]
        poly_name: Option<String>,
        /// bz2 worker fan-out (0 = every core but one, for the reader).
        #[arg(long, default_value_t = 0)]
        workers: usize,
        /// Phase-log JSONL (per-pass wall time + cores busy), same shape as
        /// `convert --log` so the two are comparable.
        #[arg(long)]
        log: Option<PathBuf>,
    },

    /// Inspect / verify GeoParquet output directory
    /// **What is actually IN these tables — a census whose books close.**
    ///
    /// Every population reported sums to the file's own footer `num_rows`, every line
    /// carries an explicit `UNACCOUNTED`, and a partition that does not close prints NO
    /// percentage and exits non-zero. See `osm_katana::census` for the wrong 57.0 %
    /// figure this verb exists to make impossible.
    ///
    /// Rows, bytes, row groups, column bytes and covering extents are FOOTER ONLY — no
    /// data page is read, and that is the whole speed of the tool. `--tags`,
    /// `--geometry` and `--classes` each read a real column and cost accordingly.
    Census {
        /// Directory to census. In `--pyramid` mode this is the pyramid ROOT (the
        /// directory holding `z6/`, `z9/`, …) — never a flattened copy of it.
        #[arg(default_value = "out")]
        dir: PathBuf,
        /// Census a tile pyramid (`z<Z>/<X>/<Y>.parquet`) instead of an extract's
        /// tables. Files that are not at a pyramid address are REFUSED rather than
        /// sampled, and every tile's covering box is checked against the address its
        /// path names.
        #[arg(long)]
        pyramid: bool,
        /// Count tags — all three spellings of "no tags" (NULL cell, empty string,
        /// `{}`), and a source with no `tags` column at all fails OPEN into a bucket of
        /// its own rather than being called untagged. Reads the `tags` column.
        #[arg(long)]
        tags: bool,
        /// Geometry-type histogram from each row's WKB header (Point / LineString /
        /// Polygon / Multi*). Reads the `geometry` column, which is the fat one.
        #[arg(long)]
        geometry: bool,
        /// Per-class breakdown via the ONE classifier (`shared::class`); implies
        /// `--tags`.
        #[arg(long)]
        classes: bool,
        /// Also write the machine-readable census here, so a future agent quotes a file
        /// instead of re-deriving a figure with `cp`. Includes the input identities —
        /// paths, sizes, mtimes and the recipe token where one exists.
        #[arg(long)]
        json: Option<PathBuf>,
        /// Scan workers. `0` ⇒ one per core (gatling's default).
        #[arg(long, default_value_t = 0)]
        workers: usize,
    },

    Verify {
        #[arg(default_value = "out")]
        dir: PathBuf,
        /// Also census every geometry column's SHAPE QUALITY (`geometry` + every
        /// `geometry_z*` rung column): decode failures, the point/line/polygon
        /// histogram, unclosed rings, sub-4-vertex rings, collapsed (zero-area)
        /// outers and self-intersecting rings — with books that close per column.
        ///
        /// The VERDICT comes from `verify::check_cut_geometry`, the same call the cut
        /// chain's post-condition step makes, judged against `--geometry-baseline`:
        /// decode failures, unclosed rings, self-intersections ABOVE the record and
        /// unbalanced books are FAULTS (exit non-zero). Sub-4-vertex rings and
        /// collapsed areas are reported and gate nothing.
        #[arg(long)]
        geometry_quality: bool,
        /// Which recorded defect budget `--geometry-quality` judges against.
        ///
        /// `recorded` (default) is the live Sweden census of 2026-09-02 — it tolerates
        /// exactly the defects that were counted then, prints each one as a DEBT on
        /// every run, and fails on one more. `zero` tolerates nothing: what a fresh cut
        /// off the fixed encoder must pass, and what proves a cut clean.
        #[arg(long, value_enum, default_value_t = BaselineArg::Recorded)]
        geometry_baseline: BaselineArg,
        /// Also print a layout-invariant CONTENT digest per table (`table rows
        /// digest`). Two converts of the same input agree here iff they produced
        /// the same rows in the same order — the equivalence gate a raw file
        /// checksum cannot give, because row-GROUP boundaries move run-to-run
        /// (the writer encodes groups on the gatling workers). Use it to prove a
        /// perf change did not alter output.
        #[arg(long)]
        digest: bool,
    },

    /// **Clip and assemble the relation table.** Reads `<dir>/relations.parquet`
    /// + `<dir>/ways.parquet`, keeps only the relations a member of this extract
    /// can reach, and fills the `geometry` column of `type=multipolygon` /
    /// `type=boundary` relations with an assembled WKB MultiPolygon (holes and
    /// all). Writes `relations.resolved.parquet` beside the input unless `-o`
    /// says otherwise — it never overwrites its own source.
    ///
    /// MEASURED, korp's 2026-08-03 `sweden` cut: 14 300 803 relations in,
    /// 268 857 out (1.880 %). The rest is the planet's relation space carried
    /// through a regional extract.
    Relations {
        /// GeoParquet directory holding relations.parquet + ways.parquet.
        dir: PathBuf,
        /// Output file. Default `<dir>/relations.resolved.parquet`.
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Drop everything that is not an assembled area (no routes, no
        /// restrictions). Smaller output; deletes the bus network.
        #[arg(long)]
        areas_only: bool,
        /// Do NOT load nodes.parquet's id column as a second clip oracle. Saves
        /// ~820 MB on Sweden; drops the ~3.7 % of relations (stop areas) whose
        /// only in-cut members are nodes.
        #[arg(long)]
        no_node_oracle: bool,
        /// Ceiling on the member-way coordinate table, in vertices (24 B each).
        #[arg(long, default_value_t = 400_000_000)]
        max_member_vertices: usize,
        /// Also write the way-grain BRIDGE table to this path: one row per way
        /// that is a member of a kept route relation, carrying the way's own
        /// geometry byte for byte plus the relation ids it belongs to. Joins to
        /// the way-side network tables on `id`; `network` = "route_members" so the
        /// id namespaces cannot be confused. Default
        /// `<dir>/networks/route_members.parquet`.
        #[arg(long, num_args = 0..=1, default_missing_value = "")]
        route_members: Option<String>,
        /// Log every ring that could not be closed (Sweden emits ~10^5).
        #[arg(long)]
        verbose_rings: bool,
        #[arg(long, default_value = "zstd")]
        compression: String,
        /// Hilbert-pack `relations.resolved.parquet` before returning — the same
        /// layout the base tables and the network tables carry. MEASURED on the
        /// Sweden cut: 274 206 rows in 3.96 s, +1.1 % on disk, and the
        /// central-Stockholm viewport then reads 10 of 34 row groups.
        /// `--pack false` leaves it in `relations.parquet` order.
        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        pack: bool,
    },

    /// Build an inverted text/tag search index over a GeoParquet dir (or a single
    /// optimized `.parquet`) and save it as JSON. With `--query`, also run the
    /// query and print the top hits (id, name, lon/lat). The gatling way
    /// (one LPT-scheduled unit per row group, results in row-group order, so the
    /// index is reproducible). Feature-gated: `--features search-index`.
    #[cfg(feature = "search-index")]
    Search {
        /// Input GeoParquet dir (nodes.parquet / ways.parquet) or a single .parquet.
        input: PathBuf,
        /// Output index FILE (JSON).
        #[arg(short, long, default_value = "search.idx.json")]
        output: PathBuf,
        /// Optional query to run after building; prints the top matching hits.
        #[arg(short, long)]
        query: Option<String>,
        /// Max hits to print for `--query`.
        #[arg(long, default_value_t = 20)]
        limit: usize,
    },
}

/// Resolve `--clip`/`--poly` into an optional shared [`osm_katana::Clip`].
/// `--clip` is a bbox/preset; `--poly` loads a `.poly` / GeoJSON polygon. The two
/// are mutually exclusive (enforced by clap); at most one is `Some`.
fn parse_clip(
    clip: Option<&str>,
    poly: Option<&std::path::Path>,
) -> anyhow::Result<Option<std::sync::Arc<osm_katana::Clip>>> {
    use osm_katana::{Bounds, Clip, Polygon};
    if let Some(path) = poly {
        let p = Polygon::from_file(path)?;
        eprintln!(
            "clip: polygon {path:?} — {} ring(s), {} vertices, bbox {:?}",
            p.ring_count(),
            p.vertex_count(),
            p.bbox(),
        );
        return Ok(Some(std::sync::Arc::new(Clip::Poly(p))));
    }
    if let Some(s) = clip {
        let b = Bounds::parse(s)?;
        eprintln!("clip: bbox {b:?}");
        return Ok(Some(std::sync::Arc::new(Clip::Bbox(b))));
    }
    Ok(None)
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    // Introspection: name the CLI verb before it's consumed by the dispatch match.
    let verb = match &cli.cmd {
        Cmd::Convert { .. } => "convert",
        Cmd::SpatialPack { .. } => "spatial-pack",
        Cmd::Networks { .. } => "networks",
        Cmd::Buildings { .. } => "buildings",
        Cmd::Graph { .. } => "graph",
        Cmd::Tile { .. } => "tile",
        Cmd::Xml2Pbf { .. } => "xml2pbf",
        Cmd::Pbf2Geo { .. } => "pbf2geo",
        Cmd::Geo2Arrow { .. } => "geo2arrow",
        Cmd::Optimize { .. } => "optimize",
        Cmd::AdminPoly { .. } => "admin-poly",
        Cmd::Census { .. } => "census",
        Cmd::Verify { .. } => "verify",
        Cmd::Relations { .. } => "relations",
        #[cfg(feature = "search-index")]
        Cmd::Search { .. } => "search",
    };
    let result: anyhow::Result<()> = match cli.cmd {
        Cmd::Convert {
            input,
            output,
            nodes,
            ways,
            relations,
            node_refs,
            compression,
            vtd_workers,
            geometry,
            skip_changesets,
            log,
            clip,
            poly,
            keep_node_coords,
            reuse_node_coords,
            pack,
        } => {
            let clip = parse_clip(clip.as_deref(), poly.as_deref())?;
            let opts = osm_katana::ConvertOptions {
                output_dir: output,
                include_nodes: nodes,
                include_ways: ways,
                include_rels: relations,
                include_node_refs: node_refs,
                compression,
                vtd_workers,
                geometry,
                skip_changesets,
                log_path: log,
                clip,
                keep_node_coords,
                reuse_node_coords,
                cancel: None,
                pack,
            };
            osm_katana::convert(&input, &opts)
        }

        Cmd::SpatialPack {
            input,
            output,
            row_group_rows,
            covering,
            generalise,
            node_refs_sidecar,
            compression,
        } => {
            use osm_katana::spatial::{PackOptions, spatial_pack, spatial_pack_dir};
            let opts = PackOptions {
                compression,
                row_group_rows,
                covering,
                generalise,
                node_refs_sidecar,
            };
            let report = if input.is_dir() {
                spatial_pack_dir(&input, &output, &opts)?
            } else {
                let st = spatial_pack(&input, &output, &opts)?;
                vec![(input.display().to_string(), st)]
            };
            for (name, s) in &report {
                println!(
                    "{name:>20}: {:>9} rows  {:>5} row groups  {:>12} bytes{}",
                    s.rows,
                    s.row_groups,
                    s.bytes,
                    if s.rows_no_geometry > 0 {
                        format!("  ({} without geometry, sorted last)", s.rows_no_geometry)
                    } else {
                        String::new()
                    },
                );
            }
            Ok(())
        }

        Cmd::Networks {
            input,
            output,
            only,
            row_group_rows,
            compression,
            max_rows,
            no_graph,
        } => {
            use osm_katana::network::{ALL_NETWORKS, NetworkOptions, extract_networks};
            let networks = match &only {
                None => ALL_NETWORKS.to_vec(),
                Some(names) => {
                    let picked: Vec<_> = ALL_NETWORKS
                        .iter()
                        .filter(|s| names.iter().any(|n| n == s.name))
                        .copied()
                        .collect();
                    // An unknown name must be an ERROR, not an empty cut: a typo that
                    // silently produces nothing looks exactly like a clean run.
                    for n in names {
                        anyhow::ensure!(
                            ALL_NETWORKS.iter().any(|s| s.name == n),
                            "unknown network `{n}` — known: {}",
                            ALL_NETWORKS
                                .iter()
                                .map(|s| s.name)
                                .collect::<Vec<_>>()
                                .join(", ")
                        );
                    }
                    picked
                }
            };
            let opts = NetworkOptions {
                networks,
                compression,
                row_group_rows,
                max_rows,
                graph: !no_graph,
            };
            let t0 = std::time::Instant::now();
            let stats = extract_networks(&input, &output, &opts)?;
            for s in &stats {
                println!(
                    "{:>7}: {:>9} rows  {:>4} row groups  {:>12} bytes  ({} refused, {} \
                     without geometry)",
                    s.name, s.rows, s.row_groups, s.bytes, s.refused, s.rows_no_geometry
                );
                println!("         recipe {}", s.recipe);
                let top: Vec<String> = s
                    .classes
                    .iter()
                    .take(12)
                    .map(|(c, n)| format!("{c}={n}"))
                    .collect();
                println!("         classes[{}] {}", s.classes.len(), top.join(" "));
                if let Some(g) = &s.graph {
                    println!(
                        "         graph: {} ways / {} node refs → {} distinct nodes, of \
                         which {} shared and {} endpoints",
                        g.ways, g.node_refs, g.distinct_nodes, g.shared_nodes, g.endpoint_nodes
                    );
                    println!(
                        "                {} GRAPH NODES ({:.1} % of the nodes touched, {} \
                         forced joints), {} edges, {} cross-network joints",
                        g.graph_nodes,
                        100.0 * g.graph_nodes as f64 / g.distinct_nodes.max(1) as f64,
                        g.forced_joints,
                        g.graph_edges,
                        g.joints
                    );
                    println!(
                        "                oneway: {} forward, {} backward; {} ways could \
                         not be sliced (geometry != node_refs) and became one edge each",
                        g.oneway_forward, g.oneway_backward, g.whole_way_fallback
                    );
                    println!(
                        "                speed: {} of {} edges from a tagged maxspeed; \
                         road {:.0} km, of which {:.0} km tagged ({:.1} %) — the rest \
                         drive class defaults (SPEED_TABLE)",
                        g.speed_tagged_edges,
                        g.graph_edges,
                        g.road_m / 1000.0,
                        g.speed_tagged_road_m / 1000.0,
                        100.0 * g.speed_tagged_road_m / g.road_m.max(1.0)
                    );
                }
            }
            println!(
                "wall {:.1}s → {}",
                t0.elapsed().as_secs_f64(),
                output.display()
            );
            Ok(())
        }

        Cmd::Buildings {
            input,
            output,
            row_group_rows,
            compression,
            max_rows,
            no_parts,
            roof_shape,
            no_generalise,
        } => {
            use osm_katana::side_outputs::buildings::{BuildingOptions, extract_buildings};
            let opts = BuildingOptions {
                compression,
                row_group_rows,
                max_rows,
                include_parts: !no_parts,
                generalise: !no_generalise,
                roof_shape,
            };
            let t0 = std::time::Instant::now();
            let s = extract_buildings(&input, &output, &opts)?;
            println!(
                "buildings: {} rows  {} row groups  {} bytes  ({} without geometry, {} \
                 parts, {} with min_height)",
                s.rows, s.row_groups, s.bytes, s.rows_no_geometry, s.parts, s.with_min_height
            );
            println!("           recipe {}", s.recipe);
            // THE number. Printed as a ratio and not as a count, because "9 095 buildings
            // have a height" reads like a lot and 0.24 % does not.
            for (k, n) in &s.height_sources {
                println!(
                    "           {k:<20} {n:>9}  {:>7.4} %",
                    100.0 * *n as f64 / s.rows.max(1) as f64
                );
            }
            let top: Vec<String> = s
                .kinds
                .iter()
                .take(12)
                .map(|(c, n)| format!("{c}={n}"))
                .collect();
            println!("           kinds[{}] {}", s.kinds.len(), top.join(" "));
            println!(
                "wall {:.1}s → {}",
                t0.elapsed().as_secs_f64(),
                output.display()
            );
            Ok(())
        }

        Cmd::Graph {
            dir,
            question,
            lon,
            lat,
            limit,
        } => {
            use osm_katana::graph::{
                Graph, NET_ROAD, edge_classes, haversine_m, unguarded_crossings,
            };
            let t0 = std::time::Instant::now();
            let g = Graph::load(&dir)?;
            let load = t0.elapsed().as_secs_f64();
            println!(
                "graph: {} nodes, {} edges → {} directed arcs, loaded in {load:.2}s",
                g.node_id.len(),
                g.edges,
                g.csr.len()
            );

            match question.as_str() {
                "unguarded-crossings" => {
                    let t = std::time::Instant::now();
                    let cls = edge_classes(&dir)?;
                    let mut u = unguarded_crossings(&g, &cls);
                    let all_lc = (0..g.node_id.len())
                        .filter(|&i| g.rail_class[i].as_deref() == Some("level_crossing"))
                        .count();
                    let joint_lc = (0..g.node_id.len())
                        .filter(|&i| {
                            g.rail_class[i].as_deref() == Some("level_crossing")
                                && g.node_kind[i] == 3
                        })
                        .count();
                    println!(
                        "\n{all_lc} level crossings are graph nodes; {joint_lc} of them are \
                         real joints (a road edge AND a rail edge end there); \
                         {} of those carry no barrier, light, bell or supervision \
                         ({:.1} %)   [{:.2}s]",
                        u.len(),
                        100.0 * u.len() as f64 / joint_lc.max(1) as f64,
                        t.elapsed().as_secs_f64()
                    );
                    let mut by_class: std::collections::BTreeMap<String, usize> =
                        std::collections::BTreeMap::new();
                    for c in &u {
                        for k in &c.road_classes {
                            *by_class.entry(k.clone()).or_insert(0) += 1;
                        }
                    }
                    println!("\nby road class meeting the track (the adjacency's answer):");
                    let mut v: Vec<_> = by_class.into_iter().collect();
                    v.sort_by(|a, b| b.1.cmp(&a.1));
                    for (k, n) in v {
                        println!("  {k:24} {n:>6}");
                    }
                    // The ones that matter most: a through road, not a farm track.
                    u.sort_by(|a, b| b.road_edges.cmp(&a.road_edges).then(a.node.cmp(&b.node)));
                    println!("\ntop {limit} by incident road edges:");
                    for c in u.iter().take(limit) {
                        println!(
                            "  node {:>12}  {:>9.5},{:>8.5}  road_edges={} rail_edges={} \
                             classes={}",
                            c.node,
                            c.lon,
                            c.lat,
                            c.road_edges,
                            c.rail_edges,
                            c.road_classes.join("+")
                        );
                    }
                }
                "nearest-crossing" => {
                    let (lon, lat) = match (lon, lat) {
                        (Some(a), Some(b)) => (a, b),
                        _ => anyhow::bail!("nearest-crossing needs --lon and --lat"),
                    };
                    let t = std::time::Instant::now();
                    let start = g
                        .nearest_node(lon, lat, NET_ROAD)
                        .ok_or_else(|| anyhow::anyhow!("no road node in the graph"))?;
                    let snap = haversine_m((lon, lat), g.node_xy[start]);
                    println!(
                        "\nstart {lon},{lat} snaps to the nearest ROAD node {} ({snap:.0} m \
                         away) in {:.2}s",
                        g.node_id[start],
                        t.elapsed().as_secs_f64()
                    );
                    let t = std::time::Instant::now();
                    let target = |i: usize| {
                        g.rail_class[i].as_deref() == Some("level_crossing")
                            && g.node_kind[i] == 3
                            && !osm_katana::graph::is_guarded(
                                g.node_tags[i].as_deref().unwrap_or(""),
                            )
                    };
                    match g.nearest_matching(start, NET_ROAD, target) {
                        None => println!("no unguarded crossing is reachable from here"),
                        Some((i, d, settled)) => {
                            let crow = haversine_m(g.node_xy[start], g.node_xy[i]);
                            println!(
                                "nearest UNGUARDED level crossing: node {} at {:.5},{:.5}\n  \
                                 along the ROAD network  {:>10.0} m\n  as the crow flies  \
                                 {:>10.0} m   ({:.2}x further by road)\n  {settled} nodes \
                                 settled in {:.2}s",
                                g.node_id[i],
                                g.node_xy[i].0,
                                g.node_xy[i].1,
                                d,
                                crow,
                                d / crow.max(1.0),
                                t.elapsed().as_secs_f64()
                            );
                            println!("  tags {}", g.node_tags[i].as_deref().unwrap_or("-"));
                        }
                    }
                }
                other => anyhow::bail!(
                    "unknown question `{other}` — known: unguarded-crossings, \
                     nearest-crossing"
                ),
            }
            Ok(())
        }

        Cmd::Tile {
            input,
            output,
            zooms,
            region_name,
            region,
            budget_gib,
            workers,
            raster_px,
            compression,
            exclude_networks,
        } => {
            use osm_katana::tiles::{self, TileOptions, TileSource};
            // No `--zooms` ⇒ the library's ladder, read at run time, so this binary and
            // korp's `run_tile_maps` can never cut at different levels (LAW 5).
            let zooms: Vec<u8> = match zooms.as_deref() {
                None => tiles::DEFAULT_TILE_ZOOMS.to_vec(),
                Some(s) => {
                    let v: Vec<u8> = s.split(',').filter_map(|t| t.trim().parse().ok()).collect();
                    anyhow::ensure!(!v.is_empty(), "--zooms parsed to nothing");
                    v
                }
            };
            let region = match region.as_deref() {
                None => None,
                Some(s) => {
                    let v: Vec<f64> = s.split(',').filter_map(|t| t.trim().parse().ok()).collect();
                    anyhow::ensure!(
                        v.len() == 4,
                        "--region wants min_lon,min_lat,max_lon,max_lat, got {s:?}"
                    );
                    Some(osm_katana::Bounds::new(v[0], v[1], v[2], v[3]))
                }
            };
            #[allow(clippy::float_arithmetic)]
            let budget = match budget_gib {
                Some(g) => (g * (1u64 << 30) as f64) as u64,
                None => tiles::default_tile_mem_budget(),
            };
            // Neither `--raster-px` nor `--exclude-networks` given ⇒ the LIBRARY's value,
            // read at run time from the one `TileOptions::default()` korp also inherits
            // from, so a hand cut and the server's cut stamp the same `TileRecipe`
            // (LAW 5). `..Default::default()` alone cannot express that for a field the
            // struct literal names, so the fallback is spelled per field.
            let defaults = TileOptions::default();
            let opts = TileOptions {
                zooms,
                compression,
                region,
                workers,
                mem_budget_bytes: budget,
                tile_raster_px: raster_px.unwrap_or(defaults.tile_raster_px),
                exclude_networks: exclude_networks.unwrap_or(defaults.exclude_networks),
                ..defaults
            };
            let t0 = std::time::Instant::now();
            let st = tiles::tile_region(TileSource::Extract(&input), &output, &opts)?;
            let wall = t0.elapsed().as_secs_f64();
            // The manifest is written from the STATS, never from the options: it may
            // only ever claim the zooms and the recipe the objects beside it were
            // actually written with.
            let manifest = output.join(tiles::manifest_key());
            if let Some(d) = manifest.parent() {
                std::fs::create_dir_all(d)?;
            }
            std::fs::write(
                &manifest,
                tiles::manifest_json(
                    &region_name,
                    &st.produced_zooms,
                    &st.recipe,
                    st.deepest_tile.as_deref(),
                ),
            )?;
            let tiles_total: usize = st.zooms.iter().map(|z| z.tiles).sum();
            let bytes_total: u64 = st.zooms.iter().map(|z| z.total_bytes).sum();
            #[allow(clippy::float_arithmetic)]
            {
                println!(
                    "osm-katana tile: {tiles_total} tiles · {:.2} GB on disk · {wall:.2} s wall \
                     (scan {:.2} s at {:.1} cores, write {:.2} s at {:.1} cores)\n\
                     \trecipe {} · manifest {}",
                    bytes_total as f64 / 1e9,
                    st.scan_wall_s,
                    st.scan_cpu_cores,
                    st.write_wall_s,
                    st.write_cpu_cores,
                    st.recipe,
                    manifest.display(),
                );
                for z in &st.zooms {
                    println!(
                        "\tz{:<3} {:>9} tiles {:>12} bytes  mean {:>8} B",
                        z.zoom,
                        z.tiles,
                        z.total_bytes,
                        if z.tiles > 0 {
                            z.total_bytes / z.tiles as u64
                        } else {
                            0
                        },
                    );
                }
                println!("{}", st.memory_attribution());
            }
            Ok(())
        }

        Cmd::Xml2Pbf {
            input,
            output,
            skip_changesets,
        } => {
            use std::time::Instant;
            let is_bz2 = input
                .extension()
                .is_some_and(|e| e.eq_ignore_ascii_case("bz2"));
            let n = std::thread::available_parallelism()
                .map(|n| n.get().saturating_sub(1).max(1))
                .unwrap_or(1);
            let file_mb = std::fs::metadata(&input)
                .map(|m| m.len() / 1_048_576)
                .unwrap_or(0);
            if is_bz2 {
                let pbf = output.unwrap_or_else(|| {
                    let stem = PathBuf::from(input.file_stem().unwrap_or_default());
                    let inner = stem.file_stem().unwrap_or_default();
                    input.with_file_name(format!("{}.osm.pbf", inner.to_string_lossy()))
                });
                println!(
                    "input: {} ({file_mb} MB bz2)  output: {}  workers: {n}",
                    input.display(),
                    pbf.display()
                );
                let t = Instant::now();
                let (total, _) =
                    osm_katana::xml_to_pbf::xml_to_pbf_bz2_path(&input, &pbf, n, skip_changesets)?;
                let pbf_mb = std::fs::metadata(&pbf)
                    .map(|m| m.len() / 1_048_576)
                    .unwrap_or(0);
                println!(
                    "done: {:.1}s  {total} elements  {pbf_mb} MB PBF",
                    t.elapsed().as_secs_f64()
                );
            } else {
                let pbf = output.unwrap_or_else(|| input.with_extension("osm.pbf"));
                println!(
                    "input: {} ({file_mb} MB xml)  output: {}  workers: {n}",
                    input.display(),
                    pbf.display()
                );
                let t = Instant::now();
                let (total, _) = osm_katana::xml_to_pbf::xml_to_pbf_raw_path(&input, &pbf, n)?;
                let pbf_mb = std::fs::metadata(&pbf)
                    .map(|m| m.len() / 1_048_576)
                    .unwrap_or(0);
                println!(
                    "done: {:.1}s  {total} elements  {pbf_mb} MB PBF",
                    t.elapsed().as_secs_f64()
                );
            }
            Ok(())
        }

        Cmd::Pbf2Geo {
            input,
            output,
            nodes,
            ways,
            relations,
            compression,
        } => {
            let opts = osm_katana::ConvertOptions {
                output_dir: output,
                include_nodes: nodes,
                include_ways: ways,
                include_rels: relations,
                include_node_refs: true,
                compression,
                vtd_workers: 0,
                geometry: "resolved".into(),
                skip_changesets: false,
                log_path: None,
                clip: None,
                keep_node_coords: true,
                reuse_node_coords: true,
                cancel: None,
                pack: true,
            };
            osm_katana::convert(&input, &opts)
        }

        Cmd::Geo2Arrow { input, output } => osm_katana::geo2arrow(&input, &output),

        Cmd::Optimize {
            input,
            output,
            points_only,
            ways,
            named_first,
            named_only,
            buildings,
            max_features,
            compression,
            log,
        } => {
            let opts = osm_katana::OptimizeOptions {
                output,
                points_only,
                include_ways: ways,
                named_first,
                named_only,
                buildings,
                max_features,
                compression,
                log_path: log,
            };
            osm_katana::optimize(&input, &opts)
        }

        Cmd::AdminPoly {
            input,
            name,
            admin_level,
            output,
            poly_name,
            workers,
            log,
        } => {
            let path = input
                .to_str()
                .ok_or_else(|| anyhow::anyhow!("non-UTF8 input path"))?;
            let opts = osm_katana::AdminPolyOptions {
                admin_level: admin_level.as_deref(),
                n_workers: workers,
                log_path: log.as_deref(),
            };
            let t0 = std::time::Instant::now();
            let derived = osm_katana::stream_admin_region_polygon_detailed(path, &name, &opts)?;
            let wall = t0.elapsed().as_secs_f64();
            match derived {
                None => {
                    // A FACT, not a silent success: nothing matched, so nothing is written.
                    anyhow::bail!(
                        "no boundary=administrative relation named {name:?}{} in {path} \
                         (scanned in {wall:.1}s) — nothing written",
                        admin_level
                            .as_deref()
                            .map(|l| format!(" at admin_level={l}"))
                            .unwrap_or_default(),
                    );
                }
                Some(d) => {
                    let b = d.polygon.bbox();
                    eprintln!(
                        "admin-poly: {name} — {} relation(s), {} ring(s), {} vertices, \
                         {} member ways, {} boundary nodes, {} unclosed chain(s), {wall:.1}s",
                        d.relations.len(),
                        d.polygon.ring_count(),
                        d.polygon.vertex_count(),
                        d.member_ways,
                        d.boundary_nodes,
                        d.open_rings,
                    );
                    for r in &d.relations {
                        eprintln!(
                            "  relation {} admin_level={} — {} outer / {} inner member ways",
                            r.id, r.admin_level, r.outer_ways, r.inner_ways,
                        );
                    }
                    eprintln!("  bbox {:?}", b);
                    let text = d.polygon.to_poly_str(poly_name.as_deref().unwrap_or(&name));
                    match output {
                        Some(p) => {
                            std::fs::write(&p, &text)?;
                            eprintln!("  wrote {} ({} bytes)", p.display(), text.len());
                        }
                        None => print!("{text}"),
                    }
                    Ok(())
                }
            }
        }

        Cmd::Census {
            dir,
            pyramid,
            tags,
            geometry,
            classes,
            json,
            workers,
        } => {
            use osm_katana::census;
            let opts = census::CensusOptions {
                tags,
                geometry,
                classes,
                workers,
                // The red arm, reachable from a shell: `OSM_KATANA_CENSUS_SABOTAGE=
                // drop-tagged` makes the real counting loop lose a whole population, and
                // the verb must then refuse. A closure check that cannot be made to fail
                // is decoration (LAW 2).
                sabotage: census::Sabotage::from_env(),
            };
            let mode = if pyramid {
                census::Mode::Pyramid
            } else {
                census::Mode::Tables
            };
            let c = census::census(&dir, mode, &opts)?;
            print!("{}", c.report());
            if let Some(p) = &json {
                std::fs::write(p, c.to_json()?)?;
                eprintln!("census json → {}", p.display());
            }
            // The judgement and the printed table come from ONE value, so they can never
            // say different things — the same shape `verify` was fixed into.
            let faults = c.faults();
            if !faults.is_empty() {
                anyhow::bail!("census REFUSED — {}", faults.join("; "));
            }
            Ok(())
        }

        Cmd::Verify { dir, digest, geometry_quality, geometry_baseline } => {
            // The judgement lives in the library (`verify::verify_dir`), so the line
            // printed here and the exit code returned below come from ONE call and
            // cannot disagree. Before that existed this loop printed `0 set, N null`
            // and exited 0 — a verb named `verify` that could not report red.
            let verdicts =
                osm_katana::verify::verify_dir(&dir, &osm_katana::verify::EXTRACT_TABLES)?;
            for tv in &verdicts {
                let name = &tv.name;
                match &tv.counts {
                    None => println!("{name:>20}: (absent)  <-- FAIL"),
                    Some(v) => {
                        print!("{name:>20}: {:>9} rows", v.rows);
                        if v.has_geom {
                            print!("  | geometry: {} set, {} null", v.geom_set, v.geom_null);
                        }
                        if v.has_refs {
                            print!(
                                "  | node_refs: {} non-empty, {} ids",
                                v.refs_nonempty, v.refs_total
                            );
                        }
                        if let Some(f) = tv.fault {
                            print!("  <-- FAIL: {f}");
                        }
                        println!();
                    }
                }
            }
            let mut failed: Vec<String> = verdicts
                .iter()
                .filter_map(|tv| tv.fault.map(|f| format!("{}: {f}", tv.name)))
                .collect();
            if geometry_quality {
                // The verb and the CHAIN judge geometry through the SAME call
                // (`verify::check_cut_geometry`), so a cut the daemon refuses can never
                // be one this verb calls clean. Before row 39 the verb had its own rule
                // (`GeomQuality::fault`) and the chain had none at all.
                let base = match geometry_baseline {
                    BaselineArg::Zero => &osm_katana::verify::GeometryBaseline::ZERO,
                    BaselineArg::Recorded => &osm_katana::verify::GeometryBaseline::RECORDED,
                };
                let g = osm_katana::verify::check_cut_geometry(
                    &dir,
                    &osm_katana::verify::EXTRACT_TABLES,
                    base,
                )?;
                println!();
                println!(
                    "geometry quality — baseline `{}` ({}); `<4v`/`area0` are reported, \
                     not gated:",
                    g.baseline, base.provenance,
                );
                for (name, q) in &g.census {
                    let name = name.replace(".parquet::", "::");
                    print!(
                        "{name:>40}: {:>9} rows | {} null · {} pt · {} ln · {} poly",
                        q.rows, q.null, q.points, q.lines, q.polys,
                    );
                    print!(
                        " | decode_fail {} · unclosed {} · <4v {} · area0 {} · selfx {} · big? {}",
                        q.decode_fail,
                        q.unclosed_ring,
                        q.ring_under_4,
                        q.zero_area,
                        q.self_intersecting,
                        q.big_ring_unchecked,
                    );
                    if q.unaccounted() != 0 {
                        print!("  <-- BOOKS DO NOT CLOSE ({} unaccounted)", q.unaccounted());
                    }
                    println!();
                }
                // A tolerated defect is a DEBT with a number on it, printed on every
                // run — never a silent pass. This is the whole difference between a
                // baseline and switching the gate off.
                for a in base.allow {
                    let owed: Vec<String> = [
                        ("unclosed", a.unclosed_ring.recorded().unwrap_or(0)),
                        ("selfx", a.self_intersecting.recorded().unwrap_or(0)),
                    ]
                    .into_iter()
                    .filter(|(_, n)| *n > 0)
                    .map(|(k, n)| format!("{k} {n}"))
                    .collect();
                    if !owed.is_empty() {
                        println!(
                            "  DEBT tolerated by this baseline: {}::{} — {}",
                            a.table.trim_end_matches(".parquet"),
                            a.column,
                            owed.join(" · "),
                        );
                    }
                }
                for n in &g.notes {
                    println!("  {n}");
                }
                for f in &g.findings {
                    println!("  <-- FAIL: {f}");
                    failed.push(format!("{}::{} {}", f.table, f.column, f.defect));
                }
            }
            if !failed.is_empty() {
                anyhow::bail!(
                    "verify: {} of {} tables unfit — {}",
                    failed.len(),
                    verdicts.len(),
                    failed.join("; ")
                );
            }
            if digest {
                println!(
                    "content digest (layout-invariant). `set` = order-independent multiset \
                     digest — THE equivalence gate; `ord` also pins row order:"
                );
                for (name, d) in osm_katana::digest::digest_dir(&dir)? {
                    match d {
                        Some(d) => println!(
                            "{name:>20}: {:>9} rows  set {}  ord {}",
                            d.rows,
                            d.set_hex(),
                            d.hex()
                        ),
                        None => println!("{name:>20}: (absent)"),
                    }
                }
            }
            Ok(())
        }

        Cmd::Relations {
            dir,
            output,
            areas_only,
            no_node_oracle,
            max_member_vertices,
            route_members,
            verbose_rings,
            compression,
            pack,
        } => {
            let out = output.unwrap_or_else(|| dir.join("relations.resolved.parquet"));
            anyhow::ensure!(
                out != dir.join("relations.parquet"),
                "refusing to write over the source relations.parquet — the stage reads it \
                 TWICE (clip, then emit) and overwriting it mid-run would race its own input"
            );
            let opts = osm_katana::RelationResolveOptions {
                compression,
                max_member_vertices,
                keep_non_areas: !areas_only,
                members_via_nodes: !no_node_oracle,
                route_members: route_members.map(|p| {
                    if p.is_empty() {
                        dir.join("networks").join("route_members.parquet")
                    } else {
                        PathBuf::from(p)
                    }
                }),
                verbose_ring_errors: verbose_rings,
                pack,
            };
            let stats = osm_katana::resolve_relations(&dir, &out, &opts)?;
            println!("{}", stats.summary());
            println!("wrote {out:?}");
            println!("\nkept by kind (top 25):");
            let mut kinds: Vec<_> = stats.by_kind.iter().collect();
            kinds.sort_by(|a, b| b.1.cmp(a.1));
            for (k, v) in kinds.into_iter().take(25) {
                println!("  {k:24} {v:>9}");
            }
            if let Some(rm) = &stats.route_members {
                println!("\nroute_members bridge — rows per class (the way side's namespace):");
                let mut c: Vec<_> = rm.by_class.iter().collect();
                c.sort_by(|a, b| b.1.cmp(a.1));
                for (k, v) in c {
                    println!("  {k:24} {v:>9}");
                }
            }
            if !stats.by_route.is_empty() {
                println!(
                    "\nkept route relations by mode (top 25) — the join key with the \
                          way-side network layer:"
                );
                let mut r: Vec<_> = stats.by_route.iter().collect();
                r.sort_by(|a, b| b.1.cmp(a.1));
                for (k, v) in r.into_iter().take(25) {
                    println!("  route={k:20} {v:>9}");
                }
            }
            Ok(())
        }

        #[cfg(feature = "search-index")]
        Cmd::Search {
            input,
            output,
            query,
            limit,
        } => {
            use osm_katana::side_outputs::search::SearchIndex;
            let idx = SearchIndex::build(&input)?;
            idx.save(&output)?;
            println!(
                "search: indexed {} features, {} tokens → {}",
                idx.len(),
                idx.token_count(),
                output.display(),
            );
            if let Some(q) = query {
                let hits = idx.search_limit(&q, limit);
                println!("query {q:?}: {} hit(s)", hits.len());
                for h in hits {
                    println!(
                        "  #{:<8} {:<40} {:.5},{:.5}",
                        h.id,
                        h.name.as_deref().unwrap_or("(unnamed)"),
                        h.lon,
                        h.lat,
                    );
                }
            }
            Ok(())
        }
    };
    // Introspection marker: report the CLI verb's terminal health to the matrix.
    osm_katana::functional_status(
        "osm-katana",
        verb,
        result.is_ok(),
        if result.is_ok() {
            "cli command completed"
        } else {
            "cli command failed"
        },
    );
    result
}

#[cfg(test)]
mod cli_tests {
    use super::*;

    fn tile_flags(argv: &[&str]) -> (Option<u32>, Option<bool>) {
        let cli = Cli::try_parse_from(argv).expect("the tile subcommand must parse");
        match cli.cmd {
            Cmd::Tile {
                raster_px,
                exclude_networks,
                ..
            } => (raster_px, exclude_networks),
            _ => panic!("expected `tile`"),
        }
    }

    /// ★ **"not given" and "given the library's current value" must not be the same
    /// parse** — for the two `TileRecipe` axes this CLI exposes.
    ///
    /// Both flags used to be `default_value_t` literals (`2048`, `true`). Two things
    /// followed, and the second is the one that bites:
    ///
    /// 1. the literals were a SECOND writer of a recipe axis whose first writer is
    ///    `TileOptions::default()` — the same defect `--zooms` was fixed for when
    ///    `DEFAULT_TILE_ZOOMS` gained `15` and this file still said `6,9,12`;
    /// 2. an omitted flag parsed to exactly the value an explicit flag parsed to, so
    ///    nothing downstream could tell "the operator did not say" from "the operator
    ///    asked for today's default" — and those stop being the same request the moment
    ///    the default moves, which is the only moment that matters.
    ///
    /// **This is the arm that went red before the change and could not have been faked
    /// green:** with `u32`/`bool` fields both sides were `2048`/`true`, so `assert_ne!`
    /// failed on equal values. It compiles either way, so the red is reproducible by
    /// reverting the two `#[arg]`s alone.
    #[test]
    fn an_omitted_recipe_flag_is_distinguishable_from_an_explicit_one() {
        let defaults = osm_katana::tiles::TileOptions::default();
        let omitted = tile_flags(&["osm-katana", "tile", "extract"]);
        let explicit = tile_flags(&[
            "osm-katana",
            "tile",
            "extract",
            "--raster-px",
            &defaults.tile_raster_px.to_string(),
            "--exclude-networks",
            &defaults.exclude_networks.to_string(),
        ]);
        assert_ne!(
            omitted.0, explicit.0,
            "`--raster-px` omitted parses identically to `--raster-px {}` — the CLI \
             cannot tell silence from a request, and the literal it substitutes is a \
             second writer of a TileRecipe axis",
            defaults.tile_raster_px
        );
        assert_ne!(
            omitted.1, explicit.1,
            "`--exclude-networks` omitted parses identically to `--exclude-networks {}`",
            defaults.exclude_networks
        );
        // …and silence must resolve to the LIBRARY's value, not to a fresh literal
        // hiding one layer down.
        assert_eq!(
            omitted.0.unwrap_or(defaults.tile_raster_px),
            defaults.tile_raster_px
        );
        assert_eq!(
            omitted.1.unwrap_or(defaults.exclude_networks),
            defaults.exclude_networks
        );
        // Non-vacuity: an explicit flag still has to be HONOURED, or the arms above
        // would pass on a CLI that ignores its own options.
        let odd = tile_flags(&[
            "osm-katana",
            "tile",
            "extract",
            "--raster-px",
            "512",
            "--exclude-networks",
            "false",
        ]);
        assert_eq!(odd.0, Some(512));
        assert_eq!(odd.1, Some(false));
    }
}