tests/daemon_names.rs
//! **The daemon roster and the collector registry must agree on every name.**
//!
//! korp has TWO rosters. `korp_collectors::registry()` yields `Collector`s — the
//! things that `collect()` a `Bounds` into a `BatchSink` and declare a `Freshness`.
//! `daemon::daemon_registry()` yields `daemon::Collector`s — the things that own a
//! never-ending `run()` loop. Different traits, different jobs, and a source that has
//! both (`polisen-events` has a daemon AND a REST preset) is ONE source seen from two
//! sides.
//!
//! The name is the only thing tying the two halves together. It is the Settings key,
//! the registry key and the matrix cell, so when the two rosters spell a source
//! differently nothing errors — the daemon simply stops having a twin, and every
//! feature reaching across the seam (freshness, read marks, business keys, the graph
//! mapping) quietly skips it. That is what makes drift here expensive: invisible at
//! compile time, and it degrades one collector at a time.
//!
//! MEASURED 2026-09-03, before the fix — 21 daemons against 46 collectors: 16 exact
//! twins, 0 separator variants, and **5 daemons whose name is a PREFIX of the twin's**
//! (`dcat` vs `dcat-ap-se`, `pxweb` vs `pxweb-jsonstat2`, `gbif` vs `gbif-occurrences`,
//! `worldbank` vs `worldbank-indicators` AND `worldbank-sdmx`, `osm-admin`). The
//! daemons carry the short source name, the registry the qualified one. `worldbank` is
//! the case that shows why it has to be fixed rather than papered over with a fuzzy
//! lookup: the short name matches TWO different collectors reading two different APIs,
//! so there is no answer to "which twin" that a lookup could guess.
#![cfg(feature = "daemon")]
use korp_collectors::{daemon, registry};
/// The canonical form for comparing two spellings of one source: case-folded, every
/// separator dropped. `entsoe_day_ahead` and `entsoe-day-ahead` fold together.
fn fold(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
/// Is `short` the qualified name `long` with its qualifier cut off? Only at a
/// SEPARATOR boundary, so `gbif` matches `gbif-occurrences` but `entso` does not
/// match `entsoe`.
fn is_qualified_form_of(long: &str, short: &str) -> bool {
long.len() > short.len()
&& long.starts_with(short)
&& matches!(long.as_bytes()[short.len()], b'-' | b'_')
}
// Gated on `osm` because the census is only as wide as the roster it can SEE. It ran
// green under `--features daemon` over a repo with FIVE drifted names: the fifth twin
// (`osm-admin-boundaries`) is behind that feature and was not compiled, so `osm-admin`
// read as a daemon with no twin rather than one whose twin it cannot name. A
// feature-gated collector is exactly where a name census goes blind. Declaring the
// feature set it needs is honest; running anyway and reporting an all-clear is not, and
// so is leaving it permanently red in the narrower build, which is how a guard becomes
// something people learn to skip past.
#[cfg(feature = "osm")]
#[test]
fn a_daemon_and_its_registry_twin_spell_the_source_the_same_way() {
let daemons: Vec<String> = daemon::daemon_registry()
.iter()
.map(|c| c.name().to_string())
.collect();
let collectors: Vec<String> = registry().iter().map(|c| c.meta().name.clone()).collect();
// Non-vacuity FIRST: this test is worthless if either roster came back empty, and
// an empty roster is exactly what a mis-set feature flag produces.
assert!(
daemons.len() >= 20,
"the daemon roster came back with {} entries — the census is reading nothing",
daemons.len(),
);
assert!(
collectors.len() >= 40,
"the collector registry came back with {} entries",
collectors.len(),
);
let mut drift: Vec<String> = Vec::new();
for d in &daemons {
if collectors.contains(d) {
continue; // an exact twin — the two halves agree
}
// No exact twin. A name that differs only by SEPARATOR is drift…
if let Some(c) = collectors.iter().find(|c| fold(c) == fold(d)) {
drift.push(format!("daemon `{d}` vs registry `{c}` (separator)"));
continue;
}
// …and so is a name that is the twin's, with the qualifier cut off. Every
// candidate is listed, because the ambiguous ones are the point.
let quals: Vec<&String> = collectors
.iter()
.filter(|c| is_qualified_form_of(c, d))
.collect();
if !quals.is_empty() {
drift.push(format!("daemon `{d}` vs registry {quals:?} (unqualified)"));
}
// A daemon with no candidate at all is NOT drift: most daemons are the only
// implementation of their source, which is a fine thing to be.
}
assert!(
drift.is_empty(),
"{} daemon name(s) do not match their registry twin — the twin is unreachable \
by name, so freshness, read marks and business keys all skip it:\n {}",
drift.len(),
drift.join("\n "),
);
}
/// The keys this repo is known to have persisted under, named HERE rather than read
/// from `RENAMED_DAEMONS`. That duplication is deliberate and it is the only reason
/// this test can fail: a guard that loops over the rename table cannot notice a
/// MISSING entry, because deleting the row deletes the assertion with it. Measured —
/// the first version of this test did exactly that, and dropping the `pxweb` row left
/// it green.
///
/// These four are history, not policy. Nothing should ever be removed from this list;
/// a fifth rename adds a fifth line.
const KEYS_ALREADY_IN_CONFIG_FILES: &[&str] =
&["dcat", "pxweb", "worldbank", "gbif", "osm-admin"];
/// **A rename is a migration.** Every collector name is a key in somebody's
/// `config.json`, so a renamed daemon must still answer to what it was called —
/// otherwise an operator's enabled collector reads as "not a registered daemon" on the
/// next start and switches itself off without saying anything.
#[test]
fn a_renamed_daemon_still_answers_to_its_persisted_key() {
for old in KEYS_ALREADY_IN_CONFIG_FILES {
let new = daemon::migrated_name(old);
assert_ne!(
new, *old,
"`{old}` is a key already written into config files and it migrates to \
nothing — add it to `daemon::RENAMED_DAEMONS`",
);
let found = daemon::daemon_by_name(old).unwrap_or_else(|| {
panic!("`{old}` (a key already in config files) resolves to no daemon")
});
assert_eq!(found.name(), new, "`{old}` must land on the renamed daemon");
// And the new name is one the collector registry actually knows — the whole
// point of the rename. A typo in the table would migrate a live key onto a name
// nothing answers to, which is strictly worse than the drift it replaced.
//
// Skipped for a twin this build did not COMPILE: `osm-admin-boundaries` is
// behind the `osm` feature, and "the registry does not carry it" then says
// something about the feature set, not about the rename. The migration itself
// is still asserted above in every build, which is the part that must never
// depend on a feature — a persisted key has to resolve wherever it is read.
if !registry().iter().any(|c| c.meta().name == new) {
assert!(
!cfg!(feature = "osm") && new.starts_with("osm-"),
"`{old}` migrates to `{new}`, which the collector registry does not know",
);
}
}
}
/// The table's own entries must each be honest, too: this catches a rename ADDED
/// wrongly, where the one above catches a rename FORGOTTEN. Both directions, because
/// they fail differently.
#[test]
fn every_rename_in_the_table_points_at_a_daemon_that_exists() {
assert!(
daemon::RENAMED_DAEMONS.len() >= KEYS_ALREADY_IN_CONFIG_FILES.len(),
"the rename table has shrunk below the keys known to be in config files",
);
for (old, new) in daemon::RENAMED_DAEMONS {
assert!(
daemon::daemon_registry().iter().any(|c| c.name() == *new),
"`{old}` migrates to `{new}`, which is not a registered daemon",
);
}
}