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

nordisk/korp-collectors

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:?}");
    }
}