//! **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,
})
}
}