src/requires.rs
//! **What a collector NEEDS before it can run** — the declaration, and the state
//! derived from it.
//!
//! # The hole this closes
//!
//! Nothing in this crate declared its own preconditions. A host could enumerate the
//! registry, render a ▶ Run button beside every row, and only find out at the wire
//! that the collector had no endpoint (`korp-rpc-21: GET → builder error`) or no API
//! key (a 401 dressed up as a parse failure). Both are facts the collector knows
//! **before** a request is built, and the UI could not ask for them.
//!
//! So a collector now DECLARES three things, and only three:
//!
//! * a **SOURCE** — the URL/endpoint it pulls from. Empty is not a default, it is a
//! missing declaration, and it is exactly what made `opensky-flights` fail at the
//! wire while the UI offered Run.
//! * its **SECRETS, BY NAME** — resolved through the existing [`SecretStore`] seam
//! (`daemon::SecretStore` / [`crate::daemon::EnvSecretStore`]), never by reading
//! `std::env` here. A name is required: "needs a token" that cannot say WHICH token
//! is not a declaration, and [`SecretReq::undeclared`] records that honestly rather
//! than passing it off as "no secret needed".
//! * its required **CONFIG FIELDS** — keys that must be non-empty in the persisted
//! `params` before a run can be built.
//!
//! # The state is DERIVED, never typed
//!
//! [`Readiness`] is computed from the declaration plus two live facts (is it enabled,
//! is it running). No collector, no config file and no wire message stores a
//! "runnable" flag, because a stored flag is a second opinion that goes stale the
//! moment a key is removed.
//!
//! The precedence is deliberate:
//!
//! | order | state | meaning |
//! |---|---|---|
//! | 1 | [`Readiness::Running`] | a live run/daemon is in flight — a fact, and it outranks every opinion |
//! | 2 | [`Readiness::NeedsConfig`] | something DECLARED is missing; a run cannot possibly succeed, so ▶ Run is refused |
//! | 3 | [`Readiness::Disabled`] | complete, but its enable checkbox is off — it will not start at boot; an ad-hoc run is still legal |
//! | 4 | [`Readiness::Runnable`] | complete and enabled |
//!
//! `NeedsConfig` outranks `Disabled` on purpose: "switch it on" is the wrong advice
//! for a collector that would then fail, and an operator who sees `off` learns nothing
//! about the key that is missing.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// Is a named secret present?
// ─────────────────────────────────────────────────────────────────────────────
/// **Can a named secret be resolved right now?** — the one question readiness asks of
/// a secret backend.
///
/// It is a separate, single-method trait rather than a use of
/// [`SecretStore`](crate::daemon::SecretStore) directly because readiness lives under
/// `parsers` (the ingest registry) while `SecretStore` lives under `daemon`. There is
/// still exactly ONE writer of the answer: the blanket impl in
/// [`crate::daemon`] forwards `present` to `SecretStore::secret`, so a host that swaps
/// in a vault store changes what readiness sees without readiness knowing about vaults.
pub trait SecretPresence {
/// `true` when `name` resolves to a non-empty secret.
fn present(&self, name: &str) -> bool;
}
/// **A backend with no secrets at all** — the honest answer for a fixture, and the
/// default a caller that has configured nothing should pass. Never a stand-in for "we
/// did not check": a collector assessed against this reports every declared secret as
/// missing, which is what an unconfigured host really is.
#[derive(Clone, Copy, Debug, Default)]
pub struct NoSecrets;
impl SecretPresence for NoSecrets {
fn present(&self, _name: &str) -> bool {
false
}
}
/// A presence backend over an explicit list of names — what a test uses to prove the
/// GREEN arm of a guard (`NoSecrets` alone can only ever prove the red one).
#[derive(Clone, Debug, Default)]
pub struct HaveSecrets(pub Vec<String>);
impl HaveSecrets {
/// Declare these names present.
pub fn of(names: &[&str]) -> Self {
HaveSecrets(names.iter().map(|s| s.to_string()).collect())
}
}
impl SecretPresence for HaveSecrets {
fn present(&self, name: &str) -> bool {
self.0.iter().any(|n| n == name)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SecretStore seam — where a collector's SECRET (API key, token, password) comes
// from, so no daemon reaches for `std::env::var` (or a config literal) directly and a
// vault can be plugged in later WITHOUT the leaf gaining a vault dependency.
// ─────────────────────────────────────────────────────────────────────────────
/// **A secret value that never prints, logs, or `Debug`s itself.**
///
/// The whole point is that a Trafikverket key (or any future token/password) cannot leak
/// through a stray `{:?}` on a config, a `println!`, or a panic message. So the inner
/// `String` is private, `Debug`/`Display` render a fixed redaction, and the bytes are
/// overwritten on drop. Read the value ONLY at the moment of use via [`expose`](SecretString::expose)
/// — and never hand THAT into anything that logs.
pub struct SecretString(String);
impl SecretString {
/// Wrap a secret value.
pub fn new(value: impl Into<String>) -> Self {
SecretString(value.into())
}
/// The one place the value is readable — the request builder, and nothing else.
/// (Deliberately named to make a `log!(… secret.expose() …)` grep-visible.)
pub fn expose(&self) -> &str {
&self.0
}
/// Whether the secret is empty (a present-but-blank value is treated as "no secret").
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretString(***redacted***)")
}
}
impl std::fmt::Display for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("***redacted***")
}
}
impl Drop for SecretString {
fn drop(&mut self) {
// Best-effort zeroization: overwrite the heap bytes before free, so the secret
// does not linger in freed memory. `String`'s buffer is contiguous and owned; a
// volatile-ish overwrite through the mutable bytes is enough for a leaf that does
// not want a `zeroize` dep. (A vault-backed store may hand out already-locked
// memory; this is the floor, not the ceiling.)
// SAFETY: we own the buffer and only write valid bytes into an existing allocation.
unsafe {
for b in self.0.as_bytes_mut() {
std::ptr::write_volatile(b, 0);
}
}
}
}
/// **How a daemon resolves a named secret** — object-safe so a host can plug any backend
/// in behind it. The leaf ships [`EnvSecretStore`] (env var / inline literal); korp — or
/// any host — can inject a vault-backed impl at runtime through [`CollectorCtx::secret`]
/// WITHOUT this crate depending on the vault.
///
// TODO(vault): the future host impl is a `VaultSecretStore` over **nornir-vault /
// OpenBao** (the constellation's OpenBao at `vault.g11.se`, per-site zone). It lives in
// the HOST (korp), NOT here — it reads a lease-scoped path like
// `secret/collectors/<name>/<key>` and returns a [`SecretString`]. This crate stays a
// leaf: the trait + the env default live here; the vault impl is injected via the ctx.
pub trait SecretStore: Send + Sync {
/// The secret named `name` (an env-var name / a vault path leaf), or `None` when it is
/// not set. `None` is a FACT the caller turns into a loud `Failed` — never a silent OK.
fn secret(&self, name: &str) -> Option<SecretString>;
}
/// **The default [`SecretStore`]: an environment variable, with an optional inline map**
/// checked first. `EnvSecretStore::new()` is env-only — today's behavior, unchanged, and
/// what [`CollectorCtx::in_memory`] wires. A host that would rather seed literals than set
/// process env uses [`with_inline`](EnvSecretStore::with_inline); the vault host swaps the
/// whole trait object.
#[derive(Clone, Default)]
pub struct EnvSecretStore {
inline: BTreeMap<String, String>,
}
impl EnvSecretStore {
/// An env-only store (no inline literals) — the unchanged default.
pub fn new() -> Self {
Self {
inline: BTreeMap::new(),
}
}
/// A store that answers from `inline` first (a present, non-empty literal), then the
/// process environment. Empty literals are ignored, so a blank Settings field never
/// shadows a real env var.
pub fn with_inline(inline: BTreeMap<String, String>) -> Self {
Self { inline }
}
}
impl SecretStore for EnvSecretStore {
fn secret(&self, name: &str) -> Option<SecretString> {
if let Some(v) = self.inline.get(name).filter(|s| !s.is_empty()) {
return Some(SecretString::new(v.clone()));
}
match std::env::var(name) {
Ok(v) if !v.is_empty() => Some(SecretString::new(v)),
_ => None,
}
}
}
/// **The ONE bridge from the secret seam to readiness.** Every [`SecretStore`] — the env
/// default, a host's vault-backed impl — answers
/// [`SecretPresence`](crate::requires::SecretPresence) by asking itself, so a host that
/// swaps the store changes what the UI calls "runnable" without readiness knowing that
/// vaults exist. A present-but-empty secret is NOT present: a blank Settings field must
/// not read as a configured key.
impl<T: SecretStore + ?Sized> SecretPresence for T {
fn present(&self, name: &str) -> bool {
self.secret(name).map(|s| !s.is_empty()).unwrap_or(false)
}
}
/// **A live [`SecretStore`] borrowed as a [`SecretPresence`].**
///
/// The blanket impl above gives every *sized* store the presence answer, and `dyn
/// SecretStore` gets it too — but Rust will not coerce a `&dyn SecretStore` into a
/// `&dyn SecretPresence` (one trait object cannot be unsized into another). This is the
/// one-line bridge, and it FORWARDS rather than re-deciding: `present` here is the
/// blanket impl's `present`, so "is this secret set?" still has exactly one writer and a
/// vault-backed host cannot get a second opinion.
pub struct StorePresence<'a>(pub &'a dyn SecretStore);
impl SecretPresence for StorePresence<'_> {
fn present(&self, name: &str) -> bool {
self.0.present(name)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// The declaration
// ─────────────────────────────────────────────────────────────────────────────
/// One secret a collector cannot run without, **named**.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretReq {
/// The secret's name as [`SecretStore`](crate::daemon::SecretStore) resolves it
/// (an env var name today, a vault path leaf later). **Empty means UNDECLARED**
/// — see [`SecretReq::undeclared`].
pub name: String,
/// One line an operator can act on: what the secret is and where to get it.
pub why: String,
}
impl SecretReq {
/// A named secret requirement.
pub fn new(name: impl Into<String>, why: impl Into<String>) -> Self {
SecretReq { name: name.into(), why: why.into() }
}
/// **The collector says it needs credentials but cannot name them.**
///
/// This is a real, reportable state and not a bug in the reader: a collector whose
/// `meta().auth` is anything but [`AuthKind::None`](crate::AuthKind::None) and
/// which has not overridden [`Collector::requirements`](crate::Collector::requirements)
/// is one an operator cannot configure, because nobody can say which variable to
/// set. It resolves to [`Readiness::NeedsConfig`] — never to `Runnable`, which is
/// the answer the old code gave by saying nothing at all.
pub fn undeclared(auth: &str) -> Self {
SecretReq {
name: String::new(),
why: format!(
"declares auth `{auth}` but names no secret — nothing to set, so it \
cannot be configured (the collector must declare the variable name)"
),
}
}
/// Is this requirement satisfied by `secrets`? An UNDECLARED requirement is never
/// satisfied — an unnameable secret cannot be supplied.
pub fn satisfied_by(&self, secrets: &dyn SecretPresence) -> bool {
!self.name.is_empty() && secrets.present(&self.name)
}
}
/// One persisted config field a collector cannot run without.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldReq {
/// The `params` key (the same key the ⚙ Settings schema edits).
pub key: String,
/// What it is / an example value.
pub why: String,
}
impl FieldReq {
/// A required config field.
pub fn new(key: impl Into<String>, why: impl Into<String>) -> Self {
FieldReq { key: key.into(), why: why.into() }
}
/// Satisfied when `params[key]` is present and not an empty string.
pub fn satisfied_by(&self, params: &serde_json::Value) -> bool {
match params.get(&self.key) {
None | Some(serde_json::Value::Null) => false,
Some(serde_json::Value::String(s)) => !s.trim().is_empty(),
Some(_) => true,
}
}
}
/// **Everything a collector declares it needs** — the whole of the declaration.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Requirements {
/// The endpoint/URL this collector pulls from. **Empty = undeclared**, and that is
/// a [`Missing::Source`], asserted before any request is built.
pub source: String,
/// Named secrets, resolved through the [`SecretPresence`] seam.
pub secrets: Vec<SecretReq>,
/// Persisted `params` keys that must be non-empty.
pub fields: Vec<FieldReq>,
}
impl Requirements {
/// A requirement set with a source and nothing else — the shape of an open,
/// keyless collector (`polisen-events`).
pub fn open(source: impl Into<String>) -> Self {
Requirements { source: source.into(), secrets: Vec::new(), fields: Vec::new() }
}
/// Add named secrets.
pub fn with_secrets(mut self, secrets: Vec<SecretReq>) -> Self {
self.secrets = secrets;
self
}
/// Replace the declared source with the one a run will ACTUALLY use.
///
/// The declaration carries the shipped default endpoint; a `CollectorCfg` carries
/// what this run was pointed at. Gating on the declaration would refuse a run
/// aimed at a working mirror, and pass one aimed at nothing.
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self
}
/// Add required config fields.
pub fn with_fields(mut self, fields: Vec<FieldReq>) -> Self {
self.fields = fields;
self
}
/// **Take the SECRETS and FIELDS declared by the INGEST collector of the same
/// name, keeping this declaration's own source.** The fix for two faces that
/// disagreed.
///
/// # The defect this closes, measured on `439b845`
///
/// A collector wears two faces in this crate: the *ingest* [`Collector`](crate::Collector)
/// (`registry()`, one-shot `collect()`) and the *daemon*
/// [`Collector`](crate::daemon::Collector) (`daemon_registry()`, the never-ending
/// `run()`). Six providers appear in BOTH under one name — `entsoe`,
/// `entsoe_day_ahead`, `entsoe_generation`, `nordpool`, `openaq-air-quality`,
/// `trafikverket-trainpos`.
///
/// The ingest face declared its secrets properly: 17 collectors naming
/// `ENTSOE_TOKEN`, `TRAFIKVERKET_API_KEY`, `BOLAGSVERKET_CLIENT_ID` and the rest.
/// **The daemon face declared `secrets: []` — all 22 of them, without exception.**
/// `CollectorConfig::requirements`'s doc said a daemon needing a key "MUST override
/// this and name the variable"; not one did, and nothing checked. So readiness
/// computed `Runnable` for a daemon with no key, the host offered ▶ Run, and the
/// loop nagged hourly forever.
///
/// # Why inheritance and not a second table
///
/// Writing the secrets out again on the daemon side would have fixed today and
/// guaranteed a drift tomorrow: two declarations of one fact, and the next provider
/// to rotate a variable name gets it changed in one of them. There is ONE writer of
/// "what does `entsoe` need" — the ingest collector, next to the parser that uses
/// it (the owner's part (1): declared as data, in one place, beside the collector) —
/// and this reads it. A daemon with no ingest twin (`osm-cut`, `wfs`) keeps exactly
/// what it declared, which for a keyless collector is the truth.
///
/// A daemon that DOES want to say something different still can: this is a builder
/// step in a default, not a final word, and `with_secrets` after it overrides.
///
/// The source is deliberately NOT inherited — a daemon's endpoint comes from its
/// live `config`, which an operator may have pointed at a mirror, while the ingest
/// twin carries the shipped default.
#[cfg(feature = "parsers")]
pub fn inheriting_credentials_of(mut self, collector_name: &str) -> Self {
if let Some((_, twin)) = crate::registry_requirements()
.into_iter()
.find(|(n, _)| n == collector_name)
{
if self.secrets.is_empty() {
self.secrets = twin.secrets;
}
if self.fields.is_empty() {
self.fields = twin.fields;
}
}
self
}
/// **The default declaration derived from what every collector already publishes**
/// — its `source_target().source_endpoint` and its `meta().auth`.
///
/// This is the reason no collector is left undeclared: a type that overrides
/// nothing still answers the question, and answers it from its own words. The
/// auth kind alone cannot name a variable, so anything but "no auth" derives an
/// [`undeclared`](SecretReq::undeclared) requirement — visible, refusable, and a
/// prompt to override.
pub fn derived(source_endpoint: &str, auth_kind: &str, auth_is_none: bool) -> Self {
Requirements {
source: source_endpoint.to_string(),
secrets: if auth_is_none {
Vec::new()
} else {
vec![SecretReq::undeclared(auth_kind)]
},
fields: Vec::new(),
}
}
/// **Is this "source" a documentation placeholder rather than a real endpoint?**
///
/// RFC 2606 reserves the `example` label for exactly this, and three collectors use
/// it honestly — `rss-atom`, `wfs` and `gtfs-rt-vehicles` are GENERIC readers whose
/// feed URL only the operator knows. (There were four: `ais-positions` declared
/// `ais-feed.example` because no live AIS feed had been found. It is now
/// `digitraffic-ais` on Fintraffic's keyless `meri.digitraffic.fi` endpoint —
/// measured 200/1001 vessels on 2026-09-03 — so a placeholder is no longer the
/// honest state for it.) Treating those strings
/// as declared sources would put a live ▶ Run beside a URL that resolves nowhere,
/// which is the same class of lie as an empty one: the failure would arrive at the
/// wire (DNS), not at the declaration.
///
/// Matched on a whole DNS label so no real host can trip it: `ais-feed.example` and
/// `example.se` are placeholders, `example-data.smhi.se` would be too but no such
/// host exists, and nothing like `opendata-download-metobs.smhi.se` matches.
pub fn is_placeholder(url: &str) -> bool {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let hostport = authority.rsplit('@').next().unwrap_or("");
let host = hostport.split(':').next().unwrap_or("");
host.split('.').any(|label| label.eq_ignore_ascii_case("example"))
}
/// Every requirement as a `(kind, name, why, satisfied)` row — what the detail
/// pane lists ("its declared requirements with which are satisfied").
pub fn checks(&self, secrets: &dyn SecretPresence, params: &serde_json::Value) -> Vec<Check> {
let mut out = vec![Check {
kind: "source".into(),
name: if self.source.is_empty() { "(undeclared)".into() } else { self.source.clone() },
why: "the endpoint the collector pulls from".into(),
satisfied: !self.source.trim().is_empty()
&& !Requirements::is_placeholder(&self.source),
}];
for s in &self.secrets {
out.push(Check {
kind: "secret".into(),
name: if s.name.is_empty() { "(undeclared)".into() } else { s.name.clone() },
why: s.why.clone(),
satisfied: s.satisfied_by(secrets),
});
}
for f in &self.fields {
out.push(Check {
kind: "field".into(),
name: f.key.clone(),
why: f.why.clone(),
satisfied: f.satisfied_by(params),
});
}
out
}
/// What is missing, by name — empty when the collector can be run.
pub fn missing(&self, secrets: &dyn SecretPresence, params: &serde_json::Value) -> Vec<Missing> {
let mut out = Vec::new();
if self.source.trim().is_empty() {
out.push(Missing::Source);
} else if Requirements::is_placeholder(&self.source) {
out.push(Missing::PlaceholderSource { url: self.source.clone() });
}
for s in &self.secrets {
if !s.satisfied_by(secrets) {
out.push(Missing::Secret { name: s.name.clone(), why: s.why.clone() });
}
}
for f in &self.fields {
if !f.satisfied_by(params) {
out.push(Missing::Field { key: f.key.clone(), why: f.why.clone() });
}
}
out
}
/// **The one writer of a refusal sentence** — `None` when every declared
/// requirement is met, otherwise the operator-facing line that NAMES what to set.
///
/// This exists so the daemon path, the one-shot runner and the roster all refuse
/// with the SAME words derived from the SAME declaration (LAW 5). Before it, the
/// refusal an operator saw depended on which of three places noticed: the registry
/// said `needs_config` with a named `Missing`, `daemon_run_loop` printed whatever
/// `SourceResolution::MissingSecret` had been handed, and `collect()` produced
/// `missing credential env var ENTSOE_TOKEN` from a fourth site. Three wordings,
/// one fact.
///
/// Every unmet item is listed, not just the first: an operator who fixes
/// `BOLAGSVERKET_CLIENT_ID` and is then told about `BOLAGSVERKET_CLIENT_SECRET` on
/// the next attempt has been made to do the work twice.
pub fn refusal(
&self,
secrets: &dyn SecretPresence,
params: &serde_json::Value,
) -> Option<String> {
let missing = self.missing(secrets, params);
if missing.is_empty() {
return None;
}
let lines: Vec<String> = missing.iter().map(|m| m.line()).collect();
Some(format!(
"NOT STARTABLE — {} unmet requirement{}: {}",
lines.len(),
if lines.len() == 1 { "" } else { "s" },
lines.join("; "),
))
}
/// **The derived state** (see the module table for the precedence and why).
pub fn readiness(
&self,
secrets: &dyn SecretPresence,
params: &serde_json::Value,
enabled: bool,
running: bool,
) -> Readiness {
if running {
return Readiness::Running;
}
let missing = self.missing(secrets, params);
if !missing.is_empty() {
return Readiness::NeedsConfig { missing };
}
if !enabled {
return Readiness::Disabled;
}
Readiness::Runnable
}
}
/// **The error a runner returns when it REFUSES to start a collector.**
///
/// A distinct type, not a bare `anyhow!("…")`, for one reason an operator feels: korp
/// must be able to tell "this collector was never startable" from "this collector tried
/// and the source was down". They look identical as strings and they mean opposite
/// things — one needs a key, the other needs patience — so a host downcasts to this
/// rather than matching on message text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotStartable {
/// The collector's registry name.
pub collector: String,
/// The operator line, naming every unmet requirement — from [`Requirements::refusal`].
pub reason: String,
}
impl std::fmt::Display for NotStartable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.collector, self.reason)
}
}
impl std::error::Error for NotStartable {}
/// One requirement as the detail pane renders it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Check {
/// `source` · `secret` · `field`.
pub kind: String,
/// The secret/field name, or the source URL.
pub name: String,
/// The operator-facing reason.
pub why: String,
/// Whether it is satisfied right now.
pub satisfied: bool,
}
/// One unmet requirement, **named** — never a bare "not configured".
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "missing", rename_all = "snake_case")]
pub enum Missing {
/// No source URL declared — the defect that produced `korp-rpc-21: GET → builder
/// error` on every collector absent from the host's known-source roster.
Source,
/// The declared source is a documentation placeholder (an RFC 2606 `example`
/// label), so the operator must supply the real feed URL. `rss-atom`, `wfs` and
/// `gtfs-rt-vehicles` are generic readers and this is the honest state for them
/// until a feed is configured. (`ais-positions` was the fourth until 2026-09-03,
/// when it became `digitraffic-ais` with a real keyless endpoint.)
PlaceholderSource { url: String },
/// A named secret that does not resolve (`name` empty ⇒ the collector never named it).
Secret { name: String, why: String },
/// A required `params` key that is absent or blank.
Field { key: String, why: String },
}
impl Missing {
/// The short name of the thing that is missing — what a button's hover shows.
pub fn label(&self) -> String {
match self {
Missing::Source => "source URL".to_string(),
Missing::PlaceholderSource { .. } => "the feed URL".to_string(),
Missing::Secret { name, .. } if name.is_empty() => "an unnamed secret".to_string(),
Missing::Secret { name, .. } => format!("secret {name}"),
Missing::Field { key, .. } => format!("field {key}"),
}
}
/// The full operator line: what is missing and why it matters.
pub fn line(&self) -> String {
match self {
Missing::Source => {
"no source URL is declared, so no request can be built".to_string()
}
// Two DIFFERENT collectors reach this line and the wording has to be true of
// both. `rss-atom`, `wfs` and `gtfs-rt-vehicles` are generic readers with no
// default feed. `scb-foretagsregistret` is not generic at all — SCB simply
// does not PUBLISH its endpoint, and issues it with the credential. The old
// wording asserted "this is a generic reader", which was false for the
// second kind and sent an operator looking for a feed to plug in.
Missing::PlaceholderSource { url } => format!(
"`{url}` is a documentation placeholder, not a live endpoint — the real \
URL must be configured (a generic reader has no default; a \
credential-gated source may publish none)"
),
Missing::Secret { name, why } if name.is_empty() => why.clone(),
Missing::Secret { name, why } => format!("{name} is not set — {why}"),
Missing::Field { key, why } => format!("`{key}` is empty — {why}"),
}
}
}
/// **The derived collector state.** Four answers, and no fifth: a collector is either
/// working, able to work, unable to work for a named reason, or switched off.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Readiness {
/// A run/daemon is in flight.
Running,
/// Every declared requirement is met and the collector is enabled.
Runnable,
/// At least one declared requirement is unmet — ▶ Run must be REFUSED, and the
/// reason named. `missing` is never empty in practice (an empty `NeedsConfig` is a
/// state with no cause, which is what a UI cannot explain) — except for
/// [`Readiness::default`] and an undecodable wire word, where "we do not know" is
/// itself the honest reason to refuse.
///
/// A STRUCT variant, not a newtype over the `Vec`: serde's internally-tagged
/// representation (`#[serde(tag = "state")]`) cannot serialize a tagged newtype
/// containing a sequence, and it fails at RUNTIME, not at compile time. This state
/// travels the wire as JSON, so a newtype here would have turned every blocked
/// collector into a serialisation error on the server. Found by
/// `readiness_round_trips_as_json`, which is why that test exists.
NeedsConfig { missing: Vec<Missing> },
/// Complete, but its enable checkbox is off — it will not start at boot.
Disabled,
}
impl Default for Readiness {
/// **A collector nobody has assessed is NOT runnable.**
///
/// The default has to be one of the four, and `Runnable` would make every
/// zero-valued row — a wire message from an older server, a `Default::default()` in
/// a builder, a decode that fell through — offer a ▶ Run nobody checked. The
/// unconfigured answer is the safe one and the true one: nothing is known about
/// this collector's requirements yet.
fn default() -> Self {
Readiness::NeedsConfig { missing: Vec::new() }
}
}
impl Readiness {
/// The stable machine key (the wire, `$KORP_STATE`, the CLI, tests).
pub fn key(&self) -> &'static str {
match self {
Readiness::Running => "running",
Readiness::Runnable => "runnable",
Readiness::NeedsConfig { .. } => "needs_config",
Readiness::Disabled => "disabled",
}
}
/// The inverse of [`key`](Self::key) for a wire word; an unknown word is
/// `NeedsConfig` with an explicit reason, never a silent `Runnable` (a newer
/// server's vocabulary must not make a client offer a button it cannot honour).
pub fn from_key(s: &str) -> Self {
match s {
"running" => Readiness::Running,
"runnable" => Readiness::Runnable,
"disabled" => Readiness::Disabled,
"needs_config" => Readiness::NeedsConfig { missing: Vec::new() },
other => Readiness::NeedsConfig { missing: vec![Missing::Field {
key: "state".into(),
why: format!("this build does not understand the state `{other}`"),
}] },
}
}
/// **May the operator press ▶ Run?** `NeedsConfig` is the one NO — a disabled
/// collector is complete and an ad-hoc run of it is legal.
pub fn runnable_now(&self) -> bool {
!matches!(self, Readiness::NeedsConfig { .. })
}
/// A status GLYPH, because colour alone is not a status (a red/green pair is
/// invisible to ~8% of men, and to every grep).
///
/// Every one of these four is a character korp's UI font is MEASURED to have, on a
/// real screenshot of this pane. That is not a stylistic note. `▷` (U+25B7) was the
/// first choice for `Runnable` and drew a TOFU BOX; `●` (U+25CF) was the first
/// choice for `Running` and drew one too — a status marker that renders as an empty
/// rectangle says less than the colour it was added to back up.
///
/// So the set is drawn only from glyphs already on this screen: `▶` is the Run
/// button's own arrow, `✔`/`✖` are the requirement checklist's marks, `⚠` is the
/// blocked row's sign, `○` is the matrix's empty dot. Read them as: playing ·
/// everything it needs is there · something is missing · switched off.
///
/// If a fifth state is ever added, screenshot it before believing its glyph.
pub fn glyph(&self) -> &'static str {
match self {
Readiness::Running => "▶",
Readiness::Runnable => "✔",
Readiness::NeedsConfig { .. } => "⚠",
Readiness::Disabled => "○",
}
}
/// The row colour beside the glyph.
pub fn rgb(&self) -> (u8, u8, u8) {
match self {
Readiness::Running => (90, 200, 120),
Readiness::Runnable => (120, 200, 235),
Readiness::NeedsConfig { .. } => (230, 180, 80),
Readiness::Disabled => (120, 120, 120),
}
}
/// A one-line label for the row.
pub fn label(&self) -> String {
match self {
Readiness::Running => "running".into(),
Readiness::Runnable => "runnable".into(),
Readiness::Disabled => "off".into(),
Readiness::NeedsConfig { missing: m } => {
let names: Vec<String> = m.iter().map(|x| x.label()).collect();
format!("needs config: {}", names.join(", "))
}
}
}
/// The refusal a ▶ Run button shows on hover — naming every missing thing.
/// `None` when the button is live.
pub fn refusal(&self) -> Option<String> {
match self {
Readiness::NeedsConfig { missing: m } if m.is_empty() => {
Some("this collector is not configured".to_string())
}
Readiness::NeedsConfig { missing: m } => Some(format!(
"cannot run — {}",
m.iter().map(|x| x.line()).collect::<Vec<_>>().join("; ")
)),
_ => None,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// The FILTER the roster offers
// ─────────────────────────────────────────────────────────────────────────────
/// **The left-pane filter** — running · runnable · not running · needs-config · all.
/// A pure predicate over [`Readiness`] so the list and any test agree by construction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RosterFilter {
/// Everything registered.
#[default]
All,
/// A run is in flight.
Running,
/// **Pressing ▶ Run would work** — every declared requirement is met. This
/// deliberately INCLUDES a collector whose enable checkbox is off
/// ([`Readiness::Disabled`]): the checkbox governs auto-start at boot, not whether
/// an ad-hoc run is legal, and a chip that answered `0` on a fresh box where
/// nothing had been enabled yet would be answering a question nobody asked.
Runnable,
/// Anything that is NOT running: runnable, needs-config and off alike.
NotRunning,
/// Blocked on a named missing secret/field/source.
NeedsConfig,
}
impl RosterFilter {
/// Every filter, in the order the chips are drawn.
pub const ALL: &'static [RosterFilter] = &[
RosterFilter::All,
RosterFilter::Running,
RosterFilter::Runnable,
RosterFilter::NotRunning,
RosterFilter::NeedsConfig,
];
/// The chip label.
pub fn label(self) -> &'static str {
match self {
RosterFilter::All => "all",
RosterFilter::Running => "running",
RosterFilter::Runnable => "runnable",
RosterFilter::NotRunning => "not running",
RosterFilter::NeedsConfig => "needs config",
}
}
/// The stable machine key.
pub fn key(self) -> &'static str {
match self {
RosterFilter::All => "all",
RosterFilter::Running => "running",
RosterFilter::Runnable => "runnable",
RosterFilter::NotRunning => "not_running",
RosterFilter::NeedsConfig => "needs_config",
}
}
/// Does a collector in state `r` belong in this filter?
pub fn keeps(self, r: &Readiness) -> bool {
match self {
RosterFilter::All => true,
RosterFilter::Running => matches!(r, Readiness::Running),
RosterFilter::Runnable => !matches!(r, Readiness::Running) && r.runnable_now(),
RosterFilter::NotRunning => !matches!(r, Readiness::Running),
RosterFilter::NeedsConfig => matches!(r, Readiness::NeedsConfig { .. }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tv() -> Requirements {
Requirements::open("https://api.trafikinfo.trafikverket.se/v2/data.json").with_secrets(vec![
SecretReq::new("TRAFIKVERKET_API_KEY", "a free Trafikverket API key"),
])
}
/// **RED↔GREEN, both arms.** A declared secret that is absent is `NeedsConfig` and
/// ▶ Run is refused; the SAME requirement with the secret present is `Runnable` and
/// Run is offered. Asserting only the red arm would pass on a `readiness` that
/// always answered `NeedsConfig`.
#[test]
fn a_missing_named_secret_refuses_run_and_a_present_one_allows_it() {
let req = tv();
let no = req.readiness(&NoSecrets, &json!({}), true, false);
assert_eq!(no.key(), "needs_config", "absent key must not be runnable");
assert!(!no.runnable_now(), "Run must be refused");
let refusal = no.refusal().expect("a refusal names the reason");
assert!(
refusal.contains("TRAFIKVERKET_API_KEY"),
"the refusal names the missing secret: {refusal}"
);
// The ROW label names it too — an operator scanning the list must not have to
// hover every amber row to learn which key is missing.
assert!(
no.label().contains("TRAFIKVERKET_API_KEY"),
"the row label names the missing secret: {}",
no.label()
);
let yes = req.readiness(&HaveSecrets::of(&["TRAFIKVERKET_API_KEY"]), &json!({}), true, false);
assert_eq!(yes.key(), "runnable", "the same collector with its key IS runnable");
assert!(yes.runnable_now());
assert!(yes.refusal().is_none(), "a runnable collector has no refusal text");
}
/// A collector with NO source URL is `NeedsConfig` **before any request is built**
/// — the `opensky-flights` / `korp-rpc-21` defect, caught at the declaration.
#[test]
fn an_undeclared_source_is_needs_config_not_a_wire_error() {
let blind = Requirements::open("");
let r = blind.readiness(&NoSecrets, &json!({}), true, false);
assert_eq!(r.key(), "needs_config");
assert!(matches!(blind.missing(&NoSecrets, &json!({}))[0], Missing::Source));
assert!(r.refusal().unwrap().contains("no source URL"));
// and the SAME requirement with a source is runnable — the guard is not blind.
assert_eq!(
Requirements::open("https://polisen.se/api/events")
.readiness(&NoSecrets, &json!({}), true, false)
.key(),
"runnable"
);
}
/// An auth kind with no named variable is reportable, not silently fine.
#[test]
fn an_undeclared_secret_can_never_be_satisfied() {
let r = Requirements::derived("https://api.vendor.se/v1", "Licensed", false);
assert_eq!(r.readiness(&NoSecrets, &json!({}), true, false).key(), "needs_config");
// Even a host with every secret in the world set cannot satisfy an unnamed one.
assert_eq!(
r.readiness(&HaveSecrets::of(&["ANYTHING", ""]), &json!({}), true, false).key(),
"needs_config",
"an unnamed requirement is not satisfiable by any name"
);
assert_eq!(Requirements::derived("https://api.vendor.se/v1", "None", true).readiness(&NoSecrets, &json!({}), true, false).key(), "runnable");
}
/// Precedence: running outranks everything; needs-config outranks off.
#[test]
fn the_state_precedence_is_running_then_needs_config_then_off() {
let req = tv();
assert_eq!(req.readiness(&NoSecrets, &json!({}), false, true).key(), "running");
assert_eq!(req.readiness(&NoSecrets, &json!({}), false, false).key(), "needs_config");
let ok = Requirements::open("https://x/y");
assert_eq!(ok.readiness(&NoSecrets, &json!({}), false, false).key(), "disabled");
assert_eq!(ok.readiness(&NoSecrets, &json!({}), true, false).key(), "runnable");
// a DISABLED collector is complete — an ad-hoc run of it is legal.
assert!(ok.readiness(&NoSecrets, &json!({}), false, false).runnable_now());
}
/// A required field is missing when absent OR blank, satisfied when filled.
#[test]
fn a_required_field_is_missing_when_blank() {
let req = Requirements::open("https://x/y")
.with_fields(vec![FieldReq::new("region", "which region to cut")]);
assert_eq!(req.readiness(&NoSecrets, &json!({}), true, false).key(), "needs_config");
assert_eq!(req.readiness(&NoSecrets, &json!({ "region": " " }), true, false).key(), "needs_config");
assert_eq!(req.readiness(&NoSecrets, &json!({ "region": "sweden" }), true, false).key(), "runnable");
}
/// The filter returns EXACTLY the collectors in each state over a fixture roster —
/// counted, and the counts add up to the roster.
#[test]
fn the_filter_returns_exactly_the_collectors_in_each_state() {
let roster: Vec<(&str, Readiness)> = vec![
("a-running", Readiness::Running),
("b-runnable", Readiness::Runnable),
("c-blocked", Readiness::NeedsConfig { missing: vec![Missing::Source] }),
("d-off", Readiness::Disabled),
("e-blocked", Readiness::NeedsConfig { missing: vec![Missing::Secret {
name: "K".into(),
why: "w".into(),
}] }),
];
let pick = |f: RosterFilter| -> Vec<&str> {
roster.iter().filter(|(_, r)| f.keeps(r)).map(|(n, _)| *n).collect()
};
assert_eq!(pick(RosterFilter::All).len(), 5);
assert_eq!(pick(RosterFilter::Running), vec!["a-running"]);
// `runnable` is "▶ would work", so the DISABLED-but-complete collector is in it
// — the checkbox is about boot, not about the button.
assert_eq!(pick(RosterFilter::Runnable), vec!["b-runnable", "d-off"]);
assert_eq!(pick(RosterFilter::NeedsConfig), vec!["c-blocked", "e-blocked"]);
assert_eq!(pick(RosterFilter::NotRunning), vec!["b-runnable", "c-blocked", "d-off", "e-blocked"]);
// The partition is total: running + not-running is the whole roster, with no
// collector in both (a filter pair that overlapped would let a row vanish).
assert_eq!(
pick(RosterFilter::Running).len() + pick(RosterFilter::NotRunning).len(),
roster.len()
);
}
/// Every declared requirement is listed with its satisfied flag — the right pane's
/// "which are satisfied" row set.
#[test]
fn checks_list_every_requirement_with_its_verdict() {
let req = tv().with_fields(vec![FieldReq::new("bbox", "the query box")]);
let checks = req.checks(&HaveSecrets::of(&["TRAFIKVERKET_API_KEY"]), &json!({}));
assert_eq!(checks.len(), 3, "source + secret + field");
assert!(checks[0].satisfied, "the source is declared");
assert!(checks[1].satisfied, "the key is present");
assert!(!checks[2].satisfied, "the field is blank");
}
/// A documentation-placeholder source is `NeedsConfig`, and a REAL host beside it
/// is not — the guard must discriminate, not reject everything with a dot in it.
#[test]
fn a_placeholder_source_is_needs_config_and_a_real_one_is_not() {
for ph in [
"https://example.se/feed.xml",
"https://ais-feed.example/positions",
"https://gtfs-rt.example/vehiclepositions.pb",
"https://example.wfs/geoserver/ows?service=WFS",
] {
assert!(Requirements::is_placeholder(ph), "`{ph}` is a placeholder");
let r = Requirements::open(ph).readiness(&NoSecrets, &json!({}), true, false);
assert_eq!(r.key(), "needs_config", "`{ph}` cannot be runnable");
assert!(r.refusal().unwrap().contains("placeholder"), "{:?}", r.refusal());
}
for real in [
"https://polisen.se/api/events",
"https://opendata-download-metobs.smhi.se/api/version/latest/parameter/1.json",
"https://api.trafikinfo.trafikverket.se/v2/data.json",
"https://exampleton.se/api",
] {
assert!(!Requirements::is_placeholder(real), "`{real}` is a real host");
assert_eq!(
Requirements::open(real).readiness(&NoSecrets, &json!({}), true, false).key(),
"runnable",
"`{real}` is runnable"
);
}
}
/// **`Readiness` must survive JSON, both ways.** It travels the wire as
/// `state_json`, so a representation that cannot serialize turns every blocked
/// collector into a server-side error instead of a refusal the user can read.
///
/// This is not hypothetical: `NeedsConfig` was first written as a tagged NEWTYPE
/// over `Vec<Missing>`, which compiles and then fails at RUNTIME with "cannot
/// serialize tagged newtype variant containing a sequence". Eight pane tests went
/// red on it. A struct variant is what serde's internal tagging can carry.
#[test]
fn every_state_round_trips_as_json() {
for r in [
Readiness::Running,
Readiness::Runnable,
Readiness::Disabled,
Readiness::default(),
Readiness::NeedsConfig {
missing: vec![
Missing::Source,
Missing::PlaceholderSource { url: "https://x.example/f".into() },
Missing::Secret { name: "K".into(), why: "the K key".into() },
Missing::Field { key: "url".into(), why: "the feed".into() },
],
},
] {
let js = serde_json::to_string(&r)
.unwrap_or_else(|e| panic!("{} must serialize: {e}", r.key()));
let back: Readiness = serde_json::from_str(&js)
.unwrap_or_else(|e| panic!("{} must come back: {e} from {js}", r.key()));
assert_eq!(back, r, "round trip is lossless: {js}");
assert_eq!(back.refusal(), r.refusal(), "the reasons survive the wire");
}
// …and the checklist too.
let checks = vec![Check {
kind: "secret".into(),
name: "K".into(),
why: "w".into(),
satisfied: false,
}];
let js = serde_json::to_string(&checks).unwrap();
assert_eq!(serde_json::from_str::<Vec<Check>>(&js).unwrap(), checks);
}
/// A default-constructed row must never offer Run — the zero value is the one a
/// decode falls back to, and `Runnable` there is a button over nothing.
#[test]
fn the_default_state_is_not_runnable() {
assert!(!Readiness::default().runnable_now());
assert_eq!(Readiness::default().key(), "needs_config");
assert!(Readiness::default().refusal().is_some());
}
/// A state word this build does not know must never read as runnable.
#[test]
fn an_unknown_wire_state_is_not_runnable() {
assert!(!Readiness::from_key("sideways").runnable_now());
assert!(Readiness::from_key("runnable").runnable_now());
assert_eq!(Readiness::from_key("running").key(), "running");
}
/// Colour alone is not a status: every state has a distinct glyph too.
#[test]
fn every_state_has_a_distinct_glyph() {
let g: Vec<&str> = [
Readiness::Running,
Readiness::Runnable,
Readiness::NeedsConfig { missing: vec![Missing::Source] },
Readiness::Disabled,
]
.iter()
.map(|r| r.glyph())
.collect();
let mut s = g.clone();
s.sort_unstable();
s.dedup();
assert_eq!(s.len(), g.len(), "four states, four distinct glyphs: {g:?}");
}
}