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

nordisk/korp-collectors

tests/osm_cut_is_the_pipeline.rs

//! **The OSM collector cuts a REGION, it does not build a tile pyramid.**
//!
//! The owner, 2026-09-02: *"the collector osm tiles is legacy? should it not be the new
//! 3 files from osm download?"* — he is right. korp's map v2 deleted the pyramid
//! (`e66f846..1d53b1b`: the tiling gate, the tiled/monolith routes, `src/map_tiles.rs`,
//! the S3 tile store and 111.8 GB on disk), and the one map source is the packed cut at
//! `~/.korp/work/<region>/` produced by the lettered `a..g` pipeline. The collector was
//! still cutting a `z6/z9/z12` pyramid into a bucket nobody reads.
//!
//! These are the RED gates for the replacement. Each one FAILED before the rename and
//! the rewrite:
//!
//! | gate | red on the old code |
//! |---|---|
//! | [`the_chain_letters_are_the_pipelines_a_to_i`] | `Step::ALL` was 5 long and `'e'` meant *tiles* |
//! | [`the_osm_collector_is_named_for_the_cut_it_makes`] | the registry had `osm-tiles`, no `osm-cut` |
//! | [`a_legacy_osm_tiles_config_entry_resolves_to_the_cut_collector`] | `daemon_with_config("osm-tiles", …)` built the pyramid daemon |
//! | [`no_collector_string_promises_a_tile_a_pyramid_or_a_zoom`] | title/description/schema said *tile*, *pyramid*, *zoom levels* |
//! | [`the_cut_collector_declares_the_seven_steps_not_a_zoom_ladder`] | the schema had `zooms` + `bucket`, no step vocabulary |

use korp_collectors::{daemon, state};

/// **The chain the collector reports IS korp's `a..g` pipeline.**
///
/// `korp::pipeline::Step` letters them: a planet · b coords · c tables · d relations ·
/// e networks · f packed · g address. This crate is korp-free by charter, so the two
/// enums are peers — and the LETTERS are the whole contract between them. The leaf's
/// enum stopped at `e = tiles`, which meant `korp-server --invalidate sweden:e` and a
/// collector row at `'e'` named two different artefacts.
#[test]
fn the_chain_letters_are_the_pipelines_a_to_i() {
    let letters: Vec<char> = state::Step::ALL.iter().map(|s| s.letter()).collect();
    assert_eq!(
        letters,
        vec!['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'],
        "the leaf's chain must letter exactly like korp::pipeline::Step.\n\
         \n\
         IF YOU ARE HERE BECAUSE YOU JUST ADDED A STEP: this list is hand-copied ON \
         PURPOSE (this crate is korp-free by charter and cannot import \
         korp::pipeline::Step), so adding a step means visiting FOUR sites, and this \
         assertion has now been the last one found TWICE — h from f7aa5c8 until \
         2026-09-03, i from 439b845 until 2026-09-06, and a plain `cargo test` in this \
         repo was red for every lane both times. The four:\n\
           1. korp-collectors src/state.rs  — `Step` enum + `ALL` + `letter()` + `parse()`\n\
           2. korp-collectors THIS TEST     — the letter list, the artefact assertion, \
              and the parse round-trip below\n\
           3. korp src/pipeline.rs          — `Step` enum + `ALL` + `outputs()` (the \
              PURGE SCOPE: a step whose target is not filesystem paths must declare it \
              EMPTY, as Planet and Packed do)\n\
           4. korp tests/collector_restart_engine_red.rs — the match over `Step`, which \
              is an ARM and not a fixture; it was left short by row 34 too\n\
         korp's own cut_driver::the_leaf_and_korp_letter_the_same_chain derives from \
         BOTH enums, so it follows automatically once 1 and 3 agree — it is the one \
         site you do NOT have to touch."
    );
    // …and each letter must name the same artefact korp names.
    let by_letter = |c: char| {
        state::Step::ALL
            .iter()
            .copied()
            .find(|s| s.letter() == c)
            .unwrap_or_else(|| panic!("no step {c}"))
    };
    assert_eq!(by_letter('e'), state::Step::Networks, "e is NETWORKS, not tiles");
    assert_eq!(by_letter('f'), state::Step::Packed);
    assert_eq!(by_letter('g'), state::Step::Address);
    // h joined the chain in f7aa5c8 (the land polygon cut from the region's own
    // coastline). korp's `pipeline::Step` letters it 'h' too — VERIFIED against
    // korp/src/pipeline.rs:165, which is the only thing that makes this a contract
    // and not a copy. This assertion, and the letter list above, sat one step short
    // from f7aa5c8 until 2026-09-03: the leaf was right and the test was red.
    assert_eq!(by_letter('h'), state::Step::Landmass, "h is LANDMASS");
    // **i joined the chain in 439b845** (the buildings table, last in the chain because
    // it reads the packed base table). VERIFIED the same way h was, against the only
    // thing that makes this a contract and not a copy: `korp/src/pipeline.rs:158`,
    // `pub const ALL: [Step; 9]`, whose ninth element is `Step::Buildings`.
    //
    // **This is the SECOND time this list has been one step short, and the note above
    // records the first.** The leaf was right both times and the test was red both
    // times — h from f7aa5c8 until 2026-09-03, i from 439b845 until now, during which a
    // plain `cargo test` in this repo was red for every lane. The list is hardcoded on
    // purpose (this crate is korp-free by charter, so it CANNOT import
    // `korp::pipeline::Step`, and a list copied by hand is exactly what a contract
    // between two crates that must not depend on each other looks like) — so the cost of
    // that charter is that whoever adds a step must come here. That is the price, and it
    // is worth naming rather than designing around: a test that derived this list from
    // the leaf's own enum would agree with itself forever and check nothing.
    assert_eq!(by_letter('i'), state::Step::Buildings, "i is BUILDINGS");
    // The operator's spellings korp accepts must round-trip here too.
    for (spelling, want) in [
        ("e", state::Step::Networks),
        ("networks", state::Step::Networks),
        ("f", state::Step::Packed),
        ("packed", state::Step::Packed),
        ("g", state::Step::Address),
        ("address", state::Step::Address),
        ("h", state::Step::Landmass),
        ("landmass", state::Step::Landmass),
        ("i", state::Step::Buildings),
        ("buildings", state::Step::Buildings),
    ] {
        assert_eq!(state::Step::parse(spelling), Some(want), "parse({spelling:?})");
    }
    // And the pyramid spellings must be GONE — parsing them would keep a legacy
    // `--invalidate sweden:tiles` silently meaning something.
    for gone in ["tiles", "pyramid"] {
        assert_eq!(
            state::Step::parse(gone),
            None,
            "'{gone}' is not a step of this pipeline any more"
        );
    }
}

/// **The registry offers a collector named for the cut it makes.**
#[cfg(feature = "osm-daemon")]
#[test]
fn the_osm_collector_is_named_for_the_cut_it_makes() {
    let names: Vec<String> = daemon::daemon_registry()
        .iter()
        .map(|c| c.name().to_string())
        .collect();
    assert!(
        names.iter().any(|n| n == korp_collectors::osm_cut::NAME),
        "the registry must offer '{}': {names:?}",
        korp_collectors::osm_cut::NAME
    );
    assert!(
        !names.iter().any(|n| n == "osm-tiles"),
        "the pyramid collector must be GONE from the registry: {names:?}"
    );
}

/// **A live server that already has `collectors."osm-tiles"` persisted keeps its
/// setting.** The rename must not silently disable a configured collector — the
/// operator's row resolves to the new daemon, carrying its config across.
#[cfg(feature = "osm-daemon")]
#[test]
fn a_legacy_osm_tiles_config_entry_resolves_to_the_cut_collector() {
    let cfg = serde_json::json!({ "regions": ["sweden"], "zooms": [6, 9, 12] });
    let d = daemon::daemon_with_config("osm-tiles", true, cfg.clone())
        .expect("a persisted 'osm-tiles' entry must still resolve");
    assert_eq!(
        d.name(),
        korp_collectors::osm_cut::NAME,
        "the legacy key resolves to the CUT collector, not a second daemon"
    );
    assert!(d.enabled(), "and it keeps the operator's enabled flag");

    // The migration is a pure, testable function — korp calls the same one when it
    // loads its settings, so both sides rename by one writer.
    assert_eq!(
        korp_collectors::osm_cut::migrate_collector_name("osm-tiles"),
        korp_collectors::osm_cut::NAME
    );
    assert_eq!(
        korp_collectors::osm_cut::migrate_collector_name("dcat"),
        "dcat",
        "every other name is left alone"
    );
}

/// **Nothing the operator reads promises a tile.** `korp/tests/no_tiles_law.rs` holds
/// that line on korp's user-visible strings; this is the same law on the leaf's, which
/// is where the collector's title, description and every ⚙ Settings field hint are
/// actually written.
#[cfg(feature = "osm-daemon")]
#[test]
fn no_collector_string_promises_a_tile_a_pyramid_or_a_zoom() {
    let d = daemon::daemon_by_name(korp_collectors::osm_cut::NAME).expect("the cut collector");
    let mut strings = vec![d.title(), d.description(), d.name().to_string()];
    for f in d.schema().fields {
        strings.push(f.key);
        strings.push(f.label);
        strings.push(f.hint);
    }
    strings.push(d.defaults().to_string());
    for s in &strings {
        let low = s.to_ascii_lowercase();
        for word in ["tile", "pyramid", "slippy", "zoom"] {
            assert!(
                !low.contains(word),
                "a user-visible collector string still says {word:?}: {s:?}"
            );
        }
    }
}

/// **The declaration is the seven steps and the regions, not a zoom ladder and a
/// bucket.** The cut writes FILES under `~/.korp/work/<region>/`; there is no tiles
/// bucket to name any more.
#[cfg(feature = "osm-daemon")]
#[test]
fn the_cut_collector_declares_the_seven_steps_not_a_zoom_ladder() {
    let d = daemon::daemon_by_name(korp_collectors::osm_cut::NAME).expect("the cut collector");
    let keys: Vec<String> = d.schema().fields.iter().map(|f| f.key.clone()).collect();
    for gone in ["zooms", "bucket"] {
        assert!(
            !keys.iter().any(|k| k == gone),
            "the cut has no {gone:?} axis: {keys:?}"
        );
    }
    for want in ["regions", "planet", "work_dir", "through_step"] {
        assert!(
            keys.iter().any(|k| k == want),
            "the cut declares {want:?}: {keys:?}"
        );
    }
    let defaults = d.defaults();
    let last = *korp_collectors::state::Step::ALL
        .last()
        .expect("the chain has at least one step");
    assert_eq!(
        defaults["through_step"],
        serde_json::Value::String(last.letter().to_string()),
        "the default cut runs the WHOLE chain, ending in {} ({})",
        last.letter(),
        last.label(),
    );
}

/// **A stock cut must be able to produce every step the chain declares.**
///
/// MEASURED on oden 2026-09-10. The chain had grown to `i` (buildings) while
/// `through_step` still defaulted to the frozen letter `g`, so korp-server's resume walk
/// printed
///
/// ```text
/// RESUME AT step h
/// ```
///
/// for a step the stock cut daemon would never run. Nothing on that box could resume it,
/// and it took a hand-edited `/home/korp/.config/korp/config.json` to make the chain
/// completable — a fix for exactly one host.
///
/// This is the invariant, and it holds for `j` too: whatever the LAST step of the chain
/// is, the default reaches it. RED direction: pin `default_through_step` to any step
/// other than `Step::ALL.last()`.
#[test]
fn the_stock_cut_reaches_the_last_step_of_the_chain() {
    use korp_collectors::state::Step;
    let last = *Step::ALL.last().expect("the chain has at least one step");
    let def = korp_collectors::osm_cut::default_through_step();
    assert_eq!(
        def, last,
        "the stock cut stops at {} ({}) while the chain ends at {} ({}) — every step \
         after the default has NO producer on a stock install",
        def.letter(),
        def.label(),
        last.letter(),
        last.label(),
    );
    // And the letter the config field actually carries agrees with the enum, so the
    // Settings default and the resolver cannot drift the way `g` and `i` did.
    assert_eq!(korp_collectors::osm_cut::default_through_step_letter(), last.letter().to_string());
}