Gunnar

nordisk/korp-collectors

src/human_doc.rs

//! **What an operator needs to know before they can USE a collector** — the facts that
//! live on the source's own website and nowhere in the code.
//!
//! The owner, 2026-09-04: *"collectors get HUMAN DOCS … provider/authority name, contact
//! address/email for keys and questions, terms/licence URL, rate limit, data owner, how
//! to register, known quirks."*
//!
//! Everything korp already knew about a source was machine-facing: a URL, an
//! [`AuthKind`](crate::AuthKind), a licence STRING like `"CC-BY 4.0"`. None of it
//! answers the questions a person actually has in front of the Collectors pane — *who
//! runs this, who owns the data, where do I get a key, how often may I call it, what
//! will bite me.* Those answers existed, scattered across doc comments and the briefs
//! that led to each collector, and an operator could not reach any of them from the
//! app.
//!
//! # Why the "there is none" cases are TYPED
//!
//! The tempting shape is a struct of `String`s where an empty one means "nothing to
//! say". That shape cannot tell **"this API publishes no rate limit"** from **"nobody
//! has looked"**, and those two lead an operator to opposite actions: the first says
//! *go ahead*, the second says *find out before you hammer it*. So every field that can
//! legitimately be absent is an enum with a named variant for each kind of absence, and
//! [`RateLimit::Unknown`] / [`Contact::Unknown`] carry the honest admission rather than
//! hiding behind `""`.
//!
//! # What must never happen here
//!
//! **Nothing in this file may be invented.** A fabricated support address or
//! registration URL is worse than no documentation at all: it sends an operator to a
//! dead end wearing korp's authority. Every value is either taken from the source's own
//! published material, measured against the live endpoint, or explicitly `Unknown`.

use serde::{Deserialize, Serialize};

/// How a person reaches the people who run the source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Contact {
    /// A published support address.
    Email(String),
    /// A web form or support portal — the URL.
    Form(String),
    /// The source publishes no contact channel at all. This is a REAL answer for a
    /// bulk open-data endpoint and is not the same as nobody having looked.
    NonePublished,
    /// Nobody has established it yet. Says so, rather than leaving a blank that reads
    /// like "there is none".
    Unknown,
}

/// What an operator has to do before this collector can run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Registration {
    /// Keyless and open — nothing to register.
    NotNeeded,
    /// Sign up on a page and get credentials immediately.
    SelfService {
        /// Where to sign up.
        url: String,
    },
    /// Ask a human (an email, a form) and wait for a key.
    ByRequest {
        /// What to do, in one line an operator can act on.
        how: String,
    },
    /// A signed agreement / contract — not something an operator completes today.
    Agreement {
        /// What the agreement is and who grants it.
        how: String,
    },
}

/// How hard the source may be called.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RateLimit {
    /// A published cap, in the source's own words (`"60 requests/minute"`).
    Documented(String),
    /// The source explicitly publishes no limit.
    NonePublished,
    /// **The source publishes NO limit and enforces one anyway** — what a live probe
    /// found, in its own words (`"429 after ~136 requests at 10/s, no header"`).
    ///
    /// This is a third state and it is the dangerous one. `NonePublished` reads to an
    /// operator as *go ahead*, and for a source like GLEIF — which serves no
    /// `x-ratelimit-*` and no `retry-after` on a 200, then answers **HTTP 429** partway
    /// through a crawl (measured 2026-09-04, lane EC O4) — that reading is exactly
    /// wrong. **Absent headers are not an absent limit**, and a card that could only
    /// say "none published" could not say so.
    MeasuredNotPublished(String),
    /// Nobody has established it. Treat gently.
    Unknown,
}

impl RateLimit {
    /// The operator-facing line.
    #[must_use]
    pub fn line(&self) -> String {
        match self {
            RateLimit::Documented(s) => s.clone(),
            RateLimit::NonePublished => "no published limit".into(),
            // Never shortened to "no published limit": the whole point of this variant
            // is that the absence of a published cap is not the absence of a cap.
            RateLimit::MeasuredNotPublished(s) => {
                format!("none published, but ENFORCED — measured: {s}")
            }
            RateLimit::Unknown => "UNKNOWN — nobody has established it".into(),
        }
    }
}

impl Contact {
    /// The operator-facing line.
    #[must_use]
    pub fn line(&self) -> String {
        match self {
            Contact::Email(s) | Contact::Form(s) => s.clone(),
            Contact::NonePublished => "the source publishes no contact channel".into(),
            Contact::Unknown => "UNKNOWN — nobody has established it".into(),
        }
    }
}

impl Registration {
    /// The operator-facing line.
    #[must_use]
    pub fn line(&self) -> String {
        match self {
            Registration::NotNeeded => "none — the API is open and keyless".into(),
            Registration::SelfService { url } => format!("self-service: {url}"),
            Registration::ByRequest { how } => format!("by request: {how}"),
            Registration::Agreement { how } => format!("AGREEMENT REQUIRED: {how}"),
        }
    }
    /// Whether an operator can complete this themselves today.
    #[must_use]
    pub fn is_self_serve(&self) -> bool {
        matches!(self, Registration::NotNeeded | Registration::SelfService { .. })
    }
}

/// **The human-facing card for one collector.**
///
/// Rendered in the Collectors pane detail and in the manual. Built with
/// [`HumanDoc::new`] and the `with_*` setters so a new required field is a compile
/// error at every call site rather than a silently empty string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HumanDoc {
    /// Who RUNS the API (Trafikverket, Fintraffic, GBIF).
    pub authority: String,
    /// Who owns the DATA, when that differs from whoever serves it — a Digitraffic
    /// endpoint carries vessel reports Fintraffic collected; an SDMX gateway serves
    /// somebody else's statistics. Same string as `authority` when they are the same
    /// body, because saying so is an answer.
    pub owner: String,
    /// The terms / licence page. The `license` field on
    /// [`CollectorMeta`](crate::CollectorMeta) is a SPDX-ish tag; this is where a human
    /// reads what it actually permits.
    pub terms_url: String,
    /// The API documentation an operator should read before configuring this.
    pub docs_url: String,
    /// How to reach the people who run it.
    pub contact: Contact,
    /// What has to happen before it can run.
    pub registration: Registration,
    /// How hard it may be called.
    pub rate_limit: RateLimit,
    /// The things that bite, each one MEASURED or published — never a guess. This is
    /// where "no `Accept-Encoding: gzip` gets HTTP 406" belongs, and it is the field
    /// that saves the most time.
    pub quirks: Vec<String>,
}

impl HumanDoc {
    /// A card with the four facts every source has, and the honest defaults for the
    /// rest: no contact established, no registration needed, no known rate limit.
    /// Override each with its `with_*` setter.
    pub fn new(
        authority: impl Into<String>,
        owner: impl Into<String>,
        terms_url: impl Into<String>,
        docs_url: impl Into<String>,
    ) -> Self {
        HumanDoc {
            authority: authority.into(),
            owner: owner.into(),
            terms_url: terms_url.into(),
            docs_url: docs_url.into(),
            contact: Contact::Unknown,
            registration: Registration::NotNeeded,
            rate_limit: RateLimit::Unknown,
            quirks: Vec::new(),
        }
    }

    /// Same authority and data owner — the common case, said explicitly.
    pub fn own(
        authority: impl Into<String> + Clone,
        terms_url: impl Into<String>,
        docs_url: impl Into<String>,
    ) -> Self {
        let a = authority.clone();
        Self::new(authority, a, terms_url, docs_url)
    }

    #[must_use]
    pub fn with_contact(mut self, c: Contact) -> Self {
        self.contact = c;
        self
    }
    #[must_use]
    pub fn with_registration(mut self, r: Registration) -> Self {
        self.registration = r;
        self
    }
    #[must_use]
    pub fn with_rate_limit(mut self, r: RateLimit) -> Self {
        self.rate_limit = r;
        self
    }
    /// A measured or published gotcha. Repeatable.
    #[must_use]
    pub fn with_quirk(mut self, q: impl Into<String>) -> Self {
        self.quirks.push(q.into());
        self
    }

    /// The pane/manual JSON — one shape for the UI, the wire and the generated manual,
    /// so the three cannot drift.
    #[must_use]
    pub fn json(&self) -> serde_json::Value {
        serde_json::json!({
            "authority": self.authority,
            "owner": self.owner,
            "terms_url": self.terms_url,
            "docs_url": self.docs_url,
            "contact": self.contact.line(),
            "registration": self.registration.line(),
            "self_serve": self.registration.is_self_serve(),
            "rate_limit": self.rate_limit.line(),
            "quirks": self.quirks,
        })
    }
}