Skip to main content

doiget_core/
lib.rs

1//! # doiget-core
2//!
3//! Core library for [doiget](https://github.com/QAtlasHub/doiget): an Open Access
4//! first paper-fetcher with strict capability gating, fail-closed provenance logging,
5//! and a BiblioFetch.jl-compatible store layout.
6//!
7//! Phase 0 ships only this skeleton. Real implementations land in Phase 1.
8//! See `docs/PUBLIC_API.md` for the semver-locked surface and `docs/ARCHITECTURE.md`
9//! for the high-level design.
10
11#![warn(missing_docs)]
12#![forbid(unsafe_code)]
13
14use serde::{Deserialize, Serialize};
15use sha2::Digest;
16
17// --- Modules ---
18pub mod canonical;
19pub mod credentials;
20pub mod discovery;
21pub mod dry_run;
22pub mod http;
23pub mod orchestrator;
24pub mod paper_tex_source;
25pub mod paper_text;
26pub mod provenance;
27pub mod rate_limiter;
28pub mod refs;
29pub mod remediation;
30pub mod resolver_cache;
31pub mod source;
32pub mod sources;
33pub mod store;
34pub mod user_extension;
35pub mod verify_config;
36
37// Phase 4 citation graph (ADR-0010). Compile-gated by the `citation`
38// Cargo feature, which itself enables the `metadata` feature so the
39// Tier-2 source impls are available.
40#[cfg(feature = "citation")]
41pub mod citation_graph;
42
43// Re-export the canonical-tuple audit-identity types at the crate root
44// per ADR-0024 / `docs/PUBLIC_API.md` §1. The types themselves live in
45// the [`canonical`] submodule.
46pub use crate::canonical::{CanonicalRef, SourceType};
47
48/// Crate version. Used by `doiget-cli --version` and `doiget_health`.
49pub const VERSION: &str = env!("CARGO_PKG_VERSION");
50
51/// TOML schema version this build writes. See `docs/STORE.md` §3.
52pub const SCHEMA_VERSION: &str = "1.0";
53
54/// Hard-coded rate limit. See `docs/LEGAL.md` §6 safeguard 8.
55pub const MAX_CONCURRENT_FETCHES: u32 = 5;
56
57/// Hard-coded rate limit. See `docs/LEGAL.md` §6 safeguard 8.
58pub const MAX_FETCHES_PER_SECOND: f32 = 5.0;
59
60/// Maximum batch size for `doiget batch` and `doiget_batch_fetch`.
61pub const MCP_BATCH_MAX_SIZE: usize = 100;
62
63/// Slice 2 alias for [`MCP_BATCH_MAX_SIZE`] using the
64/// spec-language name (`docs/MCP_TOOLS.md` §1 / Slice 2 plan). The
65/// numeric value MUST equal [`MCP_BATCH_MAX_SIZE`]; an internal test
66/// pins the equivalence so the two constants cannot drift.
67pub const MAX_BATCH_REFS: usize = MCP_BATCH_MAX_SIZE;
68
69/// Maximum queued MCP requests beyond `MAX_CONCURRENT_FETCHES`. Excess returns
70/// `ErrorCode::RateLimited`. See `docs/SECURITY.md` §1.4 / `docs/MCP_TOOLS.md`.
71pub const MCP_QUEUE_DEPTH_MAX: usize = 100;
72
73/// MCP server stdin-EOF graceful-shutdown deadline, in seconds. See ADR-0001
74/// and `docs/MCP_TOOLS.md` §8.
75pub const MCP_STDIN_EOF_SHUTDOWN_SEC: u64 = 5;
76
77/// Maximum DOI suffix length accepted at validation. See `docs/SECURITY.md` §1.1.
78pub const DOI_SUFFIX_MAX_LEN: usize = 256;
79
80/// Maximum PDF body size accepted by the fetcher, in bytes. See
81/// `docs/SECURITY.md` §1.2 (Oversized PDF).
82pub const PDF_MAX_BYTES: u64 = 100_000_000;
83
84/// Time-to-live for entries in `~/.cache/doiget/resolver/`. See
85/// `docs/CACHE.md` §3.
86pub const RESOLVER_CACHE_TTL_DAYS: u32 = 7;
87
88/// Time-to-live for entries in `~/.cache/doiget/citations/`. See
89/// `docs/CACHE.md` §3.
90pub const CITATION_CACHE_TTL_DAYS: u32 = 30;
91
92// ---------------------------------------------------------------------------
93// Ref
94// ---------------------------------------------------------------------------
95
96/// A reference to a paper, either by DOI or arXiv id.
97///
98/// See `docs/SECURITY.md` §1.1 for input-validation rules.
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase", tag = "kind", content = "id")]
101pub enum Ref {
102    /// A DOI (e.g., `10.1234/example`).
103    Doi(Doi),
104    /// An arXiv id (e.g., `2401.12345`).
105    Arxiv(ArxivId),
106}
107
108/// A validated DOI string.
109///
110/// Construct via `Doi::parse(s)` (Phase 1+). The inner field is intentionally
111/// `pub(crate)` to forbid bypass construction; tests inside `doiget-core` may
112/// still use `Doi(s)` for fixture purposes.
113///
114/// Wire format: bare string (`#[serde(transparent)]`), e.g. `"10.1234/example"`.
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(transparent)]
117pub struct Doi(pub(crate) String);
118
119/// A validated arXiv id string.
120///
121/// Construct via `ArxivId::parse(s)` (Phase 1+). Inner field is `pub(crate)`.
122///
123/// Wire format: bare string (`#[serde(transparent)]`), e.g. `"2401.12345"`.
124#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
125#[serde(transparent)]
126pub struct ArxivId(pub(crate) String);
127
128impl Doi {
129    /// The DOI registrant prefix — everything before the first `/`, e.g.
130    /// `"10.1103"` for `10.1103/PhysRevLett.116.061102`.
131    ///
132    /// Used to scope publisher-specific Tier-3 TDM sources to the DOIs
133    /// their publisher actually registered (#442). `Doi` is only ever
134    /// constructed through [`Doi::parse`], which requires the
135    /// `10.<registrant>/<suffix>` shape, so the `/` is always present;
136    /// the fallback returns the whole string rather than panicking.
137    #[must_use]
138    pub fn prefix(&self) -> &str {
139        self.0.split_once('/').map_or(self.0.as_str(), |(p, _)| p)
140    }
141
142    /// Returns the DOI as a string slice.
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146
147    /// Parses and validates a DOI string per `docs/SECURITY.md` §1.1.
148    ///
149    /// Accepts:
150    /// - Bare DOIs: `10.<registrant>/<suffix>` where `<registrant>` is 4–9
151    ///   digits and `<suffix>` is a non-empty sequence of characters drawn
152    ///   from `[A-Za-z0-9._/():-]` (the `:` covers legacy Kluwer
153    ///   `10.1023/A:NNNN` and EDP Sciences `10.1051/jphys:NNNN` DOIs).
154    /// - The `doi:` URI scheme prefix; it is stripped before validation, so
155    ///   the stored value never carries a scheme. (Matches the convention
156    ///   established in `docs/SAFEKEY.md` §3 step 0.)
157    ///
158    /// Rejects:
159    /// - Inputs missing the literal `10.` prefix (after optional scheme
160    ///   strip).
161    /// - Suffixes longer than [`DOI_SUFFIX_MAX_LEN`] bytes.
162    /// - Empty suffixes.
163    /// - Any character outside the suffix charset above (including control
164    ///   characters, whitespace, and non-ASCII).
165    ///
166    /// # Errors
167    ///
168    /// Returns a [`RefParseError`] variant that names the specific rejection
169    /// category. Tier 1+ callers should map any [`RefParseError`] to
170    /// [`ErrorCode::InvalidRef`] when surfacing to MCP / CLI.
171    pub fn parse(s: &str) -> Result<Self, RefParseError> {
172        let stripped = parse::strip_doi_scheme(s);
173        parse::validate_doi(stripped)?;
174        Ok(Doi(stripped.to_string()))
175    }
176}
177
178impl std::fmt::Display for ArxivId {
179    /// Displays the validated id as its canonical string (e.g.
180    /// `2401.12345`) so it can be interpolated into messages — notably the
181    /// `FetchError::TextUnavailable` `#[error]` template (review #318).
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(&self.0)
184    }
185}
186
187impl ArxivId {
188    /// Returns the arXiv id as a string slice.
189    pub fn as_str(&self) -> &str {
190        &self.0
191    }
192
193    /// Parses and validates an arXiv id per `docs/SECURITY.md` §1.1 and the
194    /// pattern published in `docs/MCP_TOOLS.md`.
195    ///
196    /// Accepts:
197    /// - New-style ids: `YYMM.NNNNN[vN]` where the date block is 4 digits, the
198    ///   sequence number is 4–5 digits, and the optional version `vN` is one
199    ///   or more digits. Examples: `2401.12345`, `2401.12345v2`.
200    /// - Old-style ids: `subject-class/YYMMNNN[vN]` where the subject class
201    ///   is a lowercase token (with optional internal hyphens and an
202    ///   optional `.XX` two-uppercase-letter group), and the numeric body
203    ///   is exactly 7 digits with optional `vN`. Examples:
204    ///   `cond-mat/9501001`, `astro-ph.CO/0703123v2`.
205    /// - The `arxiv:` / `arXiv:` URI scheme prefix; it is stripped before
206    ///   validation.
207    ///
208    /// Rejects:
209    /// - Inputs that match neither the new-style nor old-style shape.
210    /// - Inputs containing characters outside the per-shape charset
211    ///   (control chars, whitespace, non-ASCII).
212    /// - Empty input.
213    ///
214    /// # Errors
215    ///
216    /// Returns a [`RefParseError`] variant that names the specific rejection
217    /// category.
218    pub fn parse(s: &str) -> Result<Self, RefParseError> {
219        let stripped = parse::strip_arxiv_scheme(s);
220        parse::validate_arxiv(stripped)?;
221        Ok(ArxivId(stripped.to_string()))
222    }
223}
224
225impl Ref {
226    /// Parses a string into a [`Ref`], auto-detecting DOI vs arXiv.
227    ///
228    /// Detection rules:
229    /// 1. If the input begins with the case-insensitive `doi:` scheme, the
230    ///    remainder is parsed as a DOI.
231    /// 2. If the input begins with the `arxiv:` or `arXiv:` scheme, the
232    ///    remainder is parsed as an arXiv id.
233    /// 3. Otherwise, if the input starts with `10.` it is treated as a bare
234    ///    DOI; this matches the heuristic in `docs/SAFEKEY.md` §4 (Julia
235    ///    reference) and is stable because DOIs always begin `10.`.
236    /// 4. Failing all of the above, parsing falls back to arXiv.
237    ///
238    /// The returned [`Ref`] never carries the URI scheme — `as_str()` on the
239    /// inner `Doi` / `ArxivId` is always the bare identifier.
240    ///
241    /// # Errors
242    ///
243    /// Returns a [`RefParseError`] from the underlying [`Doi::parse`] or
244    /// [`ArxivId::parse`] call. When the input has an explicit scheme
245    /// (`doi:` / `arxiv:`), the matching parser is dispatched and its error
246    /// surfaces directly. When the input is bare and ambiguous, the
247    /// heuristic in rule 3/4 selects the parser; an unparsable bare input
248    /// surfaces the arXiv parser's error (a non-`10.` ref that also fails
249    /// arXiv validation is never a valid DOI).
250    pub fn parse(s: &str) -> Result<Self, RefParseError> {
251        // Reject empty up front so all three parsers see a meaningful slice;
252        // without this, `strip_*_scheme("")` returns "" and we'd get a
253        // confusing "missing 10. prefix" error for empty input.
254        if s.is_empty() {
255            return Err(RefParseError::Empty);
256        }
257
258        if parse::has_doi_scheme(s) {
259            return Doi::parse(s).map(Ref::Doi);
260        }
261        if parse::has_arxiv_scheme(s) {
262            return ArxivId::parse(s).map(Ref::Arxiv);
263        }
264        if s.starts_with("10.") {
265            return Doi::parse(s).map(Ref::Doi);
266        }
267        // Last resort. The input declared no scheme and has no `10.`
268        // prefix, so trying arXiv is a guess -- and reporting the guess's
269        // failure verbatim tells a user who mistyped a DOI about arXiv
270        // (#477). Report what is actually known: it matched neither.
271        ArxivId::parse(s)
272            .map(Ref::Arxiv)
273            .map_err(|_| RefParseError::UnrecognisedShape)
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Parser internals
279// ---------------------------------------------------------------------------
280
281mod parse {
282    use super::{RefParseError, DOI_SUFFIX_MAX_LEN};
283
284    /// Case-insensitive `doi:` prefix detector. Matches both `doi:` and
285    /// `DOI:` (and any case mix); the spec in `docs/SAFEKEY.md` §3 only
286    /// names the lowercase form, but the field convention is to be lenient
287    /// in what we accept (the scheme is dropped at the boundary anyway).
288    pub(crate) fn has_doi_scheme(s: &str) -> bool {
289        s.len() >= 4 && s.is_char_boundary(4) && s[..4].eq_ignore_ascii_case("doi:")
290    }
291
292    /// Case-insensitive `arxiv:` prefix detector. Accepts `arxiv:`,
293    /// `arXiv:` (the form used in `docs/MCP_TOOLS.md`), and any other case
294    /// mix.
295    pub(crate) fn has_arxiv_scheme(s: &str) -> bool {
296        s.len() >= 6 && s.is_char_boundary(6) && s[..6].eq_ignore_ascii_case("arxiv:")
297    }
298
299    pub(crate) fn strip_doi_scheme(s: &str) -> &str {
300        if has_doi_scheme(s) {
301            &s[4..]
302        } else {
303            s
304        }
305    }
306
307    pub(crate) fn strip_arxiv_scheme(s: &str) -> &str {
308        if has_arxiv_scheme(s) {
309            &s[6..]
310        } else {
311            s
312        }
313    }
314
315    /// DOI suffix charset per `docs/SECURITY.md` §1.1:
316    /// `[A-Za-z0-9._/():-]`. The forward slash is permitted inside the
317    /// suffix (e.g. `10.1016/...`); the registrant separator is the
318    /// *first* `/` and the suffix is everything after it.
319    ///
320    /// `:` is permitted because two large real publisher DOI families use
321    /// it in the suffix — legacy Kluwer/Springer (`10.1023/A:NNNNNNNNNN`)
322    /// and EDP Sciences / Journal de Physique
323    /// (`10.1051/jphys:NNNNNNNNNNNNNNNNN`). It adds no path-traversal
324    /// capability: traversal requires composing `/` and `.` into `../`,
325    /// and both characters are already in the suffix charset. In addition,
326    /// `safekey` independently escapes every char outside `[A-Za-z0-9._-]`
327    /// before any filesystem use, so `:` never reaches a path literally.
328    /// See ADR-0026 and `docs/SECURITY.md` §1.1.
329    fn is_doi_suffix_char(c: char) -> bool {
330        matches!(c,
331            'A'..='Z' | 'a'..='z' | '0'..='9'
332            | '.' | '_' | '/' | '(' | ')' | '-' | ':'
333        )
334    }
335
336    pub(crate) fn validate_doi(s: &str) -> Result<(), RefParseError> {
337        if s.is_empty() {
338            return Err(RefParseError::Empty);
339        }
340
341        // Must begin with literal "10."; the registrant is 4–9 digits up
342        // to the first '/'. After that, everything is suffix.
343        let rest = s
344            .strip_prefix("10.")
345            .ok_or(RefParseError::MissingDoiPrefix)?;
346        let slash_idx = rest
347            .find('/')
348            .ok_or(RefParseError::MissingDoiSuffixSeparator)?;
349        let registrant = &rest[..slash_idx];
350        let suffix = &rest[slash_idx + 1..];
351
352        // Registrant: 4–9 ASCII digits.
353        if registrant.len() < 4
354            || registrant.len() > 9
355            || !registrant.chars().all(|c| c.is_ascii_digit())
356        {
357            return Err(RefParseError::InvalidDoiRegistrant);
358        }
359
360        // Suffix: non-empty, charset-restricted, length-bounded.
361        if suffix.is_empty() {
362            return Err(RefParseError::EmptyDoiSuffix);
363        }
364        if suffix.len() > DOI_SUFFIX_MAX_LEN {
365            return Err(RefParseError::DoiSuffixTooLong {
366                len: suffix.len(),
367                max: DOI_SUFFIX_MAX_LEN,
368            });
369        }
370        if let Some(bad) = suffix.chars().find(|c| !is_doi_suffix_char(*c)) {
371            return Err(RefParseError::InvalidDoiSuffixChar { ch: bad });
372        }
373        Ok(())
374    }
375
376    /// Validates an arXiv id (with the `arxiv:` / `arXiv:` scheme already
377    /// stripped). Tries the new-style shape first, then the old-style.
378    pub(crate) fn validate_arxiv(s: &str) -> Result<(), RefParseError> {
379        if s.is_empty() {
380            return Err(RefParseError::Empty);
381        }
382        if validate_arxiv_new(s).is_ok() || validate_arxiv_old(s).is_ok() {
383            return Ok(());
384        }
385        Err(RefParseError::InvalidArxivShape)
386    }
387
388    /// New-style arXiv id: `YYMM.NNNNN[vN]`.
389    fn validate_arxiv_new(s: &str) -> Result<(), ()> {
390        let dot_idx = s.find('.').ok_or(())?;
391        let head = &s[..dot_idx];
392        let tail = &s[dot_idx + 1..];
393
394        // Head: exactly 4 ASCII digits.
395        if head.len() != 4 || !head.chars().all(|c| c.is_ascii_digit()) {
396            return Err(());
397        }
398
399        // Tail: 4–5 digits, then optional `v` followed by ≥1 digits.
400        let bytes = tail.as_bytes();
401        let mut i = 0;
402        while i < bytes.len() && bytes[i].is_ascii_digit() {
403            i += 1;
404        }
405        let digits_len = i;
406        if !(4..=5).contains(&digits_len) {
407            return Err(());
408        }
409        if i == bytes.len() {
410            return Ok(());
411        }
412        // Optional version suffix.
413        if bytes[i] != b'v' {
414            return Err(());
415        }
416        i += 1;
417        let v_start = i;
418        while i < bytes.len() && bytes[i].is_ascii_digit() {
419            i += 1;
420        }
421        if i == v_start || i != bytes.len() {
422            return Err(());
423        }
424        Ok(())
425    }
426
427    /// Old-style arXiv id: `subject-class/YYMMNNN[vN]`.
428    /// Subject class: `[a-z]([a-z-]*[a-z])?(\.[A-Z]{2})?`.
429    fn validate_arxiv_old(s: &str) -> Result<(), ()> {
430        let slash_idx = s.find('/').ok_or(())?;
431        let class = &s[..slash_idx];
432        let id = &s[slash_idx + 1..];
433
434        // Class: starts with [a-z], body is [a-z-], optional `.XX` (two
435        // ASCII upper).
436        let (core_class, dot_part) = match class.find('.') {
437            Some(d) => (&class[..d], Some(&class[d + 1..])),
438            None => (class, None),
439        };
440        if core_class.is_empty()
441            || !core_class
442                .chars()
443                .all(|c| c.is_ascii_lowercase() || c == '-')
444            || core_class.starts_with('-')
445            || core_class.ends_with('-')
446        {
447            return Err(());
448        }
449        if let Some(dp) = dot_part {
450            if dp.len() != 2 || !dp.chars().all(|c| c.is_ascii_uppercase()) {
451                return Err(());
452            }
453        }
454
455        // Id: 7 digits, optional `vN`.
456        let bytes = id.as_bytes();
457        let mut i = 0;
458        while i < bytes.len() && bytes[i].is_ascii_digit() {
459            i += 1;
460        }
461        if i != 7 {
462            return Err(());
463        }
464        if i == bytes.len() {
465            return Ok(());
466        }
467        if bytes[i] != b'v' {
468            return Err(());
469        }
470        i += 1;
471        let v_start = i;
472        while i < bytes.len() && bytes[i].is_ascii_digit() {
473            i += 1;
474        }
475        if i == v_start || i != bytes.len() {
476            return Err(());
477        }
478        Ok(())
479    }
480}
481
482// ---------------------------------------------------------------------------
483// RefParseError
484// ---------------------------------------------------------------------------
485
486/// Reasons a `Doi::parse` / `ArxivId::parse` / `Ref::parse` call can fail.
487///
488/// Each variant maps to one rejection category in `docs/SECURITY.md` §1.1.
489/// All variants funnel to [`ErrorCode::InvalidRef`] when surfacing to MCP /
490/// CLI; the granular shape is preserved for tests and for future log
491/// breadcrumbs. The `From<RefParseError> for ErrorCode` impl below makes
492/// `?` propagation collapse to `INVALID_REF` automatically, satisfying
493/// `docs/PUBLIC_API.md` §4.
494///
495/// Marked `#[non_exhaustive]` so adding new categories is a non-breaking
496/// change. Pattern-match with a wildcard arm.
497#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
498#[non_exhaustive]
499pub enum RefParseError {
500    /// Input was empty.
501    #[error("empty input")]
502    Empty,
503    /// Input did not begin with the required `10.` literal (after any
504    /// scheme strip).
505    #[error("DOI must begin with '10.'")]
506    MissingDoiPrefix,
507    /// Input started with `10.` but had no `/` separator between
508    /// registrant and suffix.
509    #[error("DOI must contain '/' between registrant and suffix")]
510    MissingDoiSuffixSeparator,
511    /// Registrant was not 4–9 ASCII digits.
512    #[error("DOI registrant must be 4–9 ASCII digits")]
513    InvalidDoiRegistrant,
514    /// DOI suffix was empty.
515    #[error("DOI suffix is empty")]
516    EmptyDoiSuffix,
517    /// DOI suffix exceeded `DOI_SUFFIX_MAX_LEN` bytes.
518    #[error("DOI suffix is {len} bytes; maximum is {max}")]
519    DoiSuffixTooLong {
520        /// Observed suffix length, in bytes.
521        len: usize,
522        /// Hard upper bound (always [`DOI_SUFFIX_MAX_LEN`]).
523        max: usize,
524    },
525    /// DOI suffix contained a character outside `[A-Za-z0-9._/():-]`.
526    #[error("DOI suffix contains invalid character {ch:?}")]
527    InvalidDoiSuffixChar {
528        /// The first offending character.
529        ch: char,
530    },
531    /// Input matched neither the new-style nor old-style arXiv shape.
532    #[error("input does not match any known arXiv id shape")]
533    InvalidArxivShape,
534    /// Input carried no scheme and no `10.` prefix, so it could have been
535    /// either kind of ref, and it was neither.
536    ///
537    /// #477: the fall-through used to report [`Self::InvalidArxivShape`],
538    /// so someone who mistyped a DOI was told about arXiv. The input names
539    /// no shape, so neither should the error.
540    #[error("input is neither a DOI (expected '10.<registrant>/<suffix>') nor an arXiv id")]
541    UnrecognisedShape,
542}
543
544impl From<RefParseError> for ErrorCode {
545    fn from(_: RefParseError) -> Self {
546        // All parse failures collapse to INVALID_REF at the public boundary,
547        // matching `docs/PUBLIC_API.md` §4 and `docs/SECURITY.md` §1.1.
548        ErrorCode::InvalidRef
549    }
550}
551
552// ---------------------------------------------------------------------------
553// Safekey
554// ---------------------------------------------------------------------------
555
556/// A filesystem-safe key derived deterministically from a `Ref`.
557///
558/// See `docs/SAFEKEY.md` for the full algorithm and reference test vectors.
559/// Construct via `Ref::safekey()` (Phase 1+); inner field is `pub(crate)`.
560///
561/// Wire format: bare string (`#[serde(transparent)]`), e.g. `"doi_10.1234_example"`.
562#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
563#[serde(transparent)]
564pub struct Safekey(pub(crate) String);
565
566impl Safekey {
567    /// Returns the safekey as a string slice.
568    pub fn as_str(&self) -> &str {
569        &self.0
570    }
571}
572
573impl Ref {
574    /// Returns the bare identifier string usable as a provenance `ref` field.
575    ///
576    /// Equivalent to `Doi::as_str` / `ArxivId::as_str` dispatched on the
577    /// variant — the URI scheme (`doi:` / `arxiv:`) is never present in the
578    /// inner identifiers (it is stripped at parse time), so the result is
579    /// always the bare DOI or arXiv id. Used by the CLI / MCP orchestrators
580    /// to populate the `ref` column of provenance log rows
581    /// (`docs/PROVENANCE_LOG.md` §3) without re-matching the variant.
582    pub fn as_input_str(&self) -> &str {
583        match self {
584            Ref::Doi(d) => d.as_str(),
585            Ref::Arxiv(a) => a.as_str(),
586        }
587    }
588
589    /// Derives a deterministic, filesystem-safe key from this reference.
590    ///
591    /// The algorithm is the NORMATIVE binding spec in `docs/SAFEKEY.md` §3.
592    /// Both Rust and Julia implementations MUST produce bit-identical output
593    /// for every entry in `tests/fixtures/safekey/vectors.json`.
594    ///
595    /// # Algorithm summary
596    ///
597    /// 1. Prefix with `doi_` or `arxiv_` (per variant).
598    /// 2. Replace any character outside `[A-Za-z0-9._-]` with `_`.
599    /// 3. Collapse consecutive `_` runs to a single `_`.
600    /// 4. Trim leading/trailing `_`.
601    /// 5. If the result exceeds 192 bytes, take the first 192 bytes plus
602    ///    `_` plus the first 8 hex chars of `SHA-256(raw)` (where `raw` is
603    ///    the step-1 output, before escaping).
604    ///
605    /// The bound on `as_str()` after step 4 is pure ASCII (steps 1-3 produce
606    /// only ASCII bytes), so the byte-slice in step 5 cannot split a
607    /// multibyte char.
608    pub fn safekey(&self) -> Safekey {
609        // Step 0: prefix per variant. Doi/ArxivId hold the bare identifier
610        // (no `doi:` / `arxiv:` URI scheme — that is stripped by Ref::parse,
611        // not relevant here).
612        let raw = match self {
613            Ref::Doi(d) => format!("doi_{}", d.as_str()),
614            Ref::Arxiv(a) => format!("arxiv_{}", a.as_str()),
615        };
616
617        // Step 1: replace unsafe chars with '_'. Non-ASCII chars (emitted by
618        // String::chars() as full Unicode code points) all hit the wildcard
619        // arm and become a single '_'.
620        let escaped: String = raw
621            .chars()
622            .map(|c| match c {
623                'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => c,
624                _ => '_',
625            })
626            .collect();
627
628        // Step 2: collapse consecutive '_' runs to a single '_'.
629        let mut collapsed = String::with_capacity(escaped.len());
630        let mut last_was_underscore = false;
631        for c in escaped.chars() {
632            if c == '_' {
633                if !last_was_underscore {
634                    collapsed.push('_');
635                }
636                last_was_underscore = true;
637            } else {
638                collapsed.push(c);
639                last_was_underscore = false;
640            }
641        }
642
643        // Step 3: trim leading/trailing '_'.
644        let trimmed = collapsed.trim_matches('_');
645
646        // Step 4: length-bound. After steps 1-3 `trimmed` is pure ASCII, so
647        // `len()` (bytes) == char count and `&trimmed[..192]` is char-safe.
648        let key = if trimmed.len() > 192 {
649            let digest = sha2::Sha256::digest(raw.as_bytes());
650            let hash = hex::encode(&digest[..4]);
651            format!("{}_{}", &trimmed[..192], hash)
652        } else {
653            trimmed.to_string()
654        };
655
656        Safekey(key)
657    }
658}
659
660// ---------------------------------------------------------------------------
661// ErrorCode
662// ---------------------------------------------------------------------------
663
664/// The closed set of error codes doiget surfaces.
665///
666/// See `docs/ERRORS.md` for the persona × code matrix.
667///
668/// Marked `#[non_exhaustive]` so adding new variants is a minor (not major)
669/// version bump.
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
671#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
672#[non_exhaustive]
673pub enum ErrorCode {
674    /// DOI / arXiv id failed validation.
675    InvalidRef,
676    /// Tier 1 sources reported no OA URL.
677    NoOaAvailable,
678    /// Internal rate cap or upstream 429.
679    RateLimited,
680    /// Transport / DNS / TLS failure.
681    NetworkError,
682    /// A metadata source authoritatively reported that the identifier
683    /// does not exist. Network-independent and reproducible, so `doiget
684    /// verify` treats it as a definite dead reference (fails the run even
685    /// without `--strict`) rather than a tolerable blip — distinct from
686    /// the transient [`Self::NetworkError`], [`Self::RateLimited`], and
687    /// [`Self::FetchTimeout`].
688    ///
689    /// Sources: an HTTP `404` / `410` / `451` from a metadata API, or a
690    /// source-specific absence signal (e.g. arXiv returns HTTP 200 with an
691    /// empty `<feed>` for an unknown id, surfaced via `FetchError::NotFound`).
692    ///
693    /// Caveat (DOI fan-out): for a DOI this is emitted only when the
694    /// configured metadata sources (Crossref, then Unpaywall) all fail to
695    /// resolve it and at least one authoritatively 404s. A DOI registered
696    /// only outside that set (e.g. a DataCite-only dataset DOI) can
697    /// therefore be reported `NotFound` even though it exists in a
698    /// registry doiget does not query.
699    NotFound,
700    /// A name filter (author / venue / publisher) matched MORE than one
701    /// entity with no clear winner, so it could not be resolved to a single
702    /// id. Distinct from [`Self::NotFound`] ("matched nothing"): an agent
703    /// should *narrow* the name (add a first name / fuller title) rather
704    /// than conclude the entity does not exist. The accompanying error
705    /// message lists the candidate matches. Wire form: `"AMBIGUOUS"`.
706    /// Raised by `doiget search`'s name-filter resolution (ADR-0031 D5).
707    Ambiguous,
708    /// The local store could not serve the request: a filesystem write
709    /// failed, or a mutating tool was asked to change an entry that has not
710    /// been fetched. Deliberately not [`Self::NotFound`] in the second case --
711    /// that code says a metadata source reported the id does not exist, and a
712    /// caller acting on it would treat a perfectly good reference as dead.
713    StoreError,
714    /// Provenance log write failed; the fetch was aborted.
715    LogError,
716    /// Source not granted by the runtime `CapabilityProfile`.
717    CapabilityDenied,
718    /// Per-request timeout exceeded.
719    FetchTimeout,
720    /// Store entry's `schema_version` is ahead of this build.
721    SchemaTooNew,
722    /// Could not acquire `flock` within 5 s.
723    LockTimeout,
724    /// Bug — please open an issue.
725    InternalError,
726    /// Feature is spec'd but not yet wired in this Phase. Distinct from
727    /// [`Self::InternalError`] (which signals a bug) and
728    /// [`Self::CapabilityDenied`] (which signals a runtime config gate).
729    /// Returned by stubs that exist to pin the public surface ahead of
730    /// orchestrator implementation, so an agent can react with "wait for
731    /// next minor release" rather than "report a bug" or "tweak my
732    /// capability profile". Wire form: `"NOT_IMPLEMENTED"`.
733    NotImplemented,
734    /// The identifier is valid and resolvable, but the **requested
735    /// representation** is not available from its source — currently the
736    /// ar5iv HTML render consulted by `doiget text` (a 200 with no
737    /// extractable prose: the paper was never converted to HTML).
738    ///
739    /// Deliberately distinct from the neighbouring codes so an agent does
740    /// not misdiagnose a missing render as a bad reference (issue #302):
741    /// it is NOT [`Self::NotFound`] (the id *does* exist), NOT
742    /// [`Self::NoOaAvailable`] (the paper may well be OA — only this one
743    /// representation is missing), and NOT [`Self::NetworkError`] (the
744    /// fetch succeeded). The actionable branch is "fetch the PDF instead",
745    /// not "fix the identifier". Wire form: `"TEXT_UNAVAILABLE"`.
746    TextUnavailable,
747}
748
749/// What a caller should DO about a failure, as opposed to what happened.
750///
751/// `docs/ERRORS.md` §2 has carried per-code retry guidance since Phase 0 and
752/// it is good guidance — but it is a markdown table, and the agent making the
753/// retry decision never reads it. Its only signal was the NAME of the code,
754/// and several names point the wrong way: `NO_OA_AVAILABLE` is the most common
755/// failure there is, and "no OA available" invites an unbounded retry loop for
756/// something that will not change until the configuration does (#506).
757///
758/// Three states, not two. "Retryable / not retryable" cannot express the case
759/// that matters most here — the answer will not change *by itself*, but a
760/// named one-line change makes it change. Facing that, an agent should neither
761/// loop nor give up silently; it should surface the specific change to a human.
762#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
763#[serde(rename_all = "snake_case")]
764#[non_exhaustive]
765pub enum Disposition {
766    /// The answer will not change. Do not retry, and do not wait for it.
767    ///
768    /// Includes failures a caller can act on by issuing a DIFFERENT request
769    /// (`INVALID_REF`, `AMBIGUOUS`, `TEXT_UNAVAILABLE`): this call is settled,
770    /// which is what the disposition is about.
771    Terminal,
772    /// The answer may change on its own. Retry, with backoff.
773    RetryAfter,
774    /// The answer will not change by itself, but a named change makes it.
775    /// Surface it; do not loop.
776    NeedsConfig,
777}
778
779impl Disposition {
780    /// The wire token, allocation-free.
781    #[must_use]
782    pub const fn as_wire(self) -> &'static str {
783        match self {
784            Self::Terminal => "terminal",
785            Self::RetryAfter => "retry_after",
786            Self::NeedsConfig => "needs_config",
787        }
788    }
789}
790
791impl ErrorCode {
792    /// What a caller should do about this code — see [`Disposition`].
793    ///
794    /// This is the single source of truth. `docs/ERRORS.md` §2 carries a
795    /// Disposition column, and `errors_md_disposition_column_matches_the_code`
796    /// parses that table and asserts it against this function for every
797    /// variant, so the document and the wire cannot drift (#506; the drift
798    /// pattern is #493).
799    ///
800    /// An exhaustive `match` with no wildcard: a new code must decide.
801    #[must_use]
802    pub const fn disposition(self) -> Disposition {
803        match self {
804            // Settled. The same call will return the same thing.
805            Self::InvalidRef
806            | Self::NotFound
807            | Self::InternalError
808            // ERRORS.md is explicit: "do not retry".
809            | Self::NotImplemented
810            // A different request may work (narrow the name / fetch the PDF
811            // instead), but THIS one is answered.
812            | Self::Ambiguous
813            | Self::TextUnavailable => Disposition::Terminal,
814
815            // May change on its own.
816            Self::RateLimited
817            | Self::NetworkError
818            | Self::FetchTimeout
819            | Self::LockTimeout => Disposition::RetryAfter,
820
821            // Will not change by itself; a named change makes it.
822            //
823            // `NoOaAvailable` sits here rather than in `RetryAfter` on
824            // purpose: it is the most common failure, and ERRORS.md's "Try
825            // later, or enable opt-in source" reads to a machine as the
826            // former when it is nearly always the latter.
827            //
828            // `StoreError` / `LogError` are disk and permission problems. A
829            // machine cannot name the fix, but it must not loop on it either,
830            // and "surface this to a human" is exactly what this disposition
831            // means.
832            Self::NoOaAvailable
833            | Self::CapabilityDenied
834            | Self::SchemaTooNew
835            | Self::StoreError
836            | Self::LogError => Disposition::NeedsConfig,
837        }
838    }
839}
840
841impl ErrorCode {
842    /// The `SCREAMING_SNAKE_CASE` wire token for this code, as a
843    /// `&'static str`. Identical to the serde representation but
844    /// allocation-free and usable where a borrowed string with a
845    /// `'static` lifetime is required — notably the provenance log
846    /// `error_code` column (`docs/PROVENANCE_LOG.md` §3), so a failure
847    /// row records the *actual* mapped code instead of a hand-written
848    /// literal that can drift from this enum (issue #118).
849    #[must_use]
850    pub fn as_wire(&self) -> &'static str {
851        match self {
852            ErrorCode::InvalidRef => "INVALID_REF",
853            ErrorCode::NoOaAvailable => "NO_OA_AVAILABLE",
854            ErrorCode::RateLimited => "RATE_LIMITED",
855            ErrorCode::NetworkError => "NETWORK_ERROR",
856            ErrorCode::NotFound => "NOT_FOUND",
857            ErrorCode::Ambiguous => "AMBIGUOUS",
858            ErrorCode::StoreError => "STORE_ERROR",
859            ErrorCode::LogError => "LOG_ERROR",
860            ErrorCode::CapabilityDenied => "CAPABILITY_DENIED",
861            ErrorCode::FetchTimeout => "FETCH_TIMEOUT",
862            ErrorCode::SchemaTooNew => "SCHEMA_TOO_NEW",
863            ErrorCode::LockTimeout => "LOCK_TIMEOUT",
864            ErrorCode::InternalError => "INTERNAL_ERROR",
865            ErrorCode::NotImplemented => "NOT_IMPLEMENTED",
866            ErrorCode::TextUnavailable => "TEXT_UNAVAILABLE",
867        }
868    }
869}
870
871// ---------------------------------------------------------------------------
872// DenialReason / DenialContext (ADR-0023)
873// ---------------------------------------------------------------------------
874
875/// Closed-set reasons a denial-class error envelope can carry on its
876/// optional `denial_context.reason` field.
877///
878/// Wire form (JSON / MCP) is `snake_case` — e.g. `"redirect_not_in_allowlist"`.
879/// The set is **closed** per ADR-0023 §2: adding a new variant is a minor
880/// semver bump; renaming or repurposing one is a breaking change. Mirrors
881/// the stability rule that already governs [`ErrorCode`].
882///
883/// See [`DenialContext`] for the surrounding struct, `docs/ERRORS.md` §3.1
884/// for the wire surface, and `docs/PUBLIC_API.md` §8 for the
885/// semver-locked surface contract.
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
887#[serde(rename_all = "snake_case")]
888pub enum DenialReason {
889    /// Redirect target host did not match the source's allowlist
890    /// (`HttpError::RedirectDenied`).
891    RedirectNotInAllowlist,
892    /// Redirect target had a non-HTTPS scheme (`HttpError::InsecureRedirect`).
893    InsecureScheme,
894    /// Source produced a URL whose host is on a future blocklist.
895    ///
896    /// Reserved — no producer wired yet. Will be emitted by the future
897    /// per-source URL host-blocklist guard once that component lands
898    /// (post-Phase-1 supply-chain hardening; see
899    /// `docs/REDIRECT_ALLOWLIST.md` §4 for the staging plan).
900    HostInBlockList,
901    /// Body exceeded [`PDF_MAX_BYTES`] (`HttpError::OversizedBody`).
902    SizeCapExceeded,
903    /// Store entry's `schema_version` is ahead of this binary.
904    ///
905    /// Reserved — no producer wired yet. Will be emitted by the
906    /// `FsStore` schema-rejection path once the read-side bump check
907    /// lands (it currently only writes the current `SCHEMA_VERSION`).
908    SchemaDrift,
909    /// Source not in the runtime [`CapabilityProfile`]
910    /// (`FetchError::NotEligible`).
911    CapabilityNotGranted,
912    /// Rate limiter rejected the call inside the current window.
913    ///
914    /// Reserved — no producer wired yet. Will be emitted by
915    /// [`RateLimiter`](crate::rate_limiter::RateLimiter) once the
916    /// limiter surfaces structured denials (Phase 2+; today the
917    /// limiter only sleeps to enforce the window).
918    RateLimitWindow,
919    /// SSRF guard rejected a private / link-local / cloud-metadata address.
920    ///
921    /// Reserved — no producer wired yet. Will be emitted by the
922    /// future SSRF pre-flight check (post-Phase-1 supply-chain
923    /// hardening; the workspace currently relies on rustls + the
924    /// HTTPS-only redirect policy to keep the attack surface small).
925    SsrfPrivateAddress,
926    /// Response Content-Type / magic-byte mismatch (`HttpError::NotAPdf`).
927    ContentTypeMismatch,
928}
929
930/// Structured machine-parseable companion to `error.message` for
931/// recoverable denials.
932///
933/// The field is **optional and additive** on the public error envelope —
934/// every previously-shipped `{code, message}` envelope remains valid, and
935/// agents that ignore this struct continue to work. When present, it
936/// carries the concrete parameters an LLM agent can use to plan a recovery
937/// (e.g. "the redirect to `evil.example.com` was denied because it is not
938/// in the crossref allowlist") without text-mining `error.message`.
939///
940/// ## Wire shape
941///
942/// `#[serde(deny_unknown_fields)]`: forward-compatible field additions on
943/// the wire are forbidden by design — adding a field to this struct is a
944/// **breaking** change. This is why the type is **not** `#[non_exhaustive]`
945/// (per `docs/PUBLIC_API.md` §8): both production rules — Rust struct
946/// construction outside the crate AND wire-level extension — must agree.
947///
948/// All fields except `reason` are optional. Producers populate the fields
949/// relevant to the reason and leave the rest at `None`; consumers MUST
950/// tolerate any subset of fields being present. Optional fields are
951/// skipped on serialize but accepted as missing on deserialize via
952/// `#[serde(default, skip_serializing_if = "Option::is_none")]`.
953///
954/// [`Self::expected`] is `Option<Vec<String>>` rather than `Vec<String>`
955/// so the producer can distinguish "this reason has no allowlist channel"
956/// (`None` → field absent on the wire) from "this is the explicit list of
957/// acceptable values, possibly empty" (`Some(vec![])` → `"expected":[]` on
958/// the wire). The previous `Vec<String>` shape collapsed both states
959/// into "field omitted", which an LLM agent could not safely disambiguate.
960///
961/// Mapping table: see ADR-0023 §4, plus the
962/// `From<&HttpError> for Option<DenialContext>` and
963/// `From<&FetchError> for Option<DenialContext>` impls in
964/// [`crate::http`] / [`crate::source`].
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
966#[serde(deny_unknown_fields)]
967pub struct DenialContext {
968    /// Closed-enum reason code; the only required field.
969    pub reason: DenialReason,
970    /// Resolver source key (e.g. `"crossref"`) when one is in scope.
971    #[serde(default, skip_serializing_if = "Option::is_none")]
972    pub source: Option<String>,
973    /// Concrete value the producer attempted (host, path, hex magic bytes,
974    /// scheme prefix). Shape is reason-specific; consumers MUST treat it
975    /// as opaque text.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub attempted: Option<String>,
978    /// Allowlist entries / acceptable values. `Option<Vec<String>>` so the
979    /// producer can distinguish "this reason has no allowlist channel"
980    /// (`None`, field absent on the wire) from "this is the explicit list
981    /// of acceptable values, possibly empty" (`Some(vec![])`, `"expected":[]`
982    /// on the wire). The inner `Vec<String>` is used even when only one
983    /// value is meaningful (e.g. `Some(vec!["%PDF-".into()])`) so the
984    /// format does not have to flip when multiple values are acceptable.
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub expected: Option<Vec<String>>,
987    /// Redirect-chain hop position, 0-indexed. `u8` because the chain is
988    /// hard-capped at [`crate::http`]'s `MAX_REDIRECTS` (= 10) and any
989    /// larger value indicates a bug.
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub hop_index: Option<u8>,
992    /// Size or rate cap value (e.g. [`PDF_MAX_BYTES`]).
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub cap: Option<u64>,
995    /// Observed value (e.g. response bytes when [`Self::cap`] is the byte
996    /// cap, or row schema_version when [`Self::cap`] is the binary's).
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub actual: Option<u64>,
999}
1000
1001// ---------------------------------------------------------------------------
1002// ResolvedCandidate / ResolveResult (Issue #242)
1003// ---------------------------------------------------------------------------
1004
1005/// How much of the query a candidate actually matched, as something an
1006/// agent can branch on (#536).
1007///
1008/// `score` alone is not judgement material. For it to work as a gate, the
1009/// consumer has to already know that the scorer is token overlap rather than
1010/// semantic similarity, that 0.5 is the FLOOR so the worst candidate the tool
1011/// will ever emit still looks like a positive number, and that for a citation
1012/// string carrying author + title + journal + volume + year, 0.5 means most of
1013/// it did not match. None of that is in the envelope, and an agent consuming a
1014/// ranked list takes the head of it.
1015///
1016/// The reported case: a citation for a paper in *Psychiatria Danubina* came
1017/// back as a different 2010 paper in a different journal by a different author
1018/// at `score: 0.5` — `quality`, `life`, `bipolar` and `2010` were enough to
1019/// clear the floor — in the same shape as a `score: 1.0` identity.
1020///
1021/// # These are bands over token overlap, not a semantic verdict
1022///
1023/// [`Self::Exact`] means every token in the query was found somewhere in the
1024/// candidate record. That is a strong signal and it is still not proof: a
1025/// short query can match the wrong paper completely. The bands make the
1026/// difference between "identity" and "coincidence" legible; they do not
1027/// remove the need to verify before citing.
1028#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1029#[serde(rename_all = "snake_case")]
1030#[non_exhaustive]
1031pub enum Confidence {
1032    /// Every query token matched. Verify before citing, but this is an
1033    /// identity rather than an overlap.
1034    Exact,
1035    /// At least four query tokens in five matched.
1036    Probable,
1037    /// Cleared the 0.5 floor and no more. For a known-item lookup this is a
1038    /// NEGATIVE result wearing a positive number.
1039    Weak,
1040}
1041
1042impl Confidence {
1043    /// Band a token-overlap score.
1044    ///
1045    /// The floor is 0.5 (`MIN_CITATION_SCORE`), so the range actually in play
1046    /// is 0.5..=1.0 and the split at 0.8 asks for four tokens in five. `Exact`
1047    /// compares against 0.999 rather than 1.0 because the score is a division:
1048    /// asking for bit-exact equality would band an all-tokens match as
1049    /// `Probable` on a rounding accident.
1050    /// A score outside `0.0..=1.0`, or `NaN`, is not a token-overlap ratio
1051    /// and gets the lowest band rather than a confident-looking answer. The
1052    /// only caller today guards with `MIN_CITATION_SCORE`, but that constant
1053    /// is private to `crossref.rs` and invisible from this signature -- and
1054    /// this is a public function on a semver-strict crate.
1055    #[must_use]
1056    pub fn from_score(score: f64) -> Self {
1057        if !score.is_finite() || !(0.0..=1.0).contains(&score) {
1058            return Self::Weak;
1059        }
1060        if score >= 0.999 {
1061            Self::Exact
1062        } else if score >= 0.8 {
1063            Self::Probable
1064        } else {
1065            Self::Weak
1066        }
1067    }
1068
1069    /// The wire token, allocation-free.
1070    #[must_use]
1071    pub const fn as_wire(self) -> &'static str {
1072        match self {
1073            Self::Exact => "exact",
1074            Self::Probable => "probable",
1075            Self::Weak => "weak",
1076        }
1077    }
1078}
1079
1080/// A candidate paper resolved from a bibliographic citation string.
1081#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1082#[non_exhaustive]
1083pub struct ResolvedCandidate {
1084    /// Resolved DOI.
1085    pub doi: String,
1086    /// Title of the resolved candidate.
1087    pub title: String,
1088    /// First author or primary author representation.
1089    pub author: String,
1090    /// Publication year, if resolved.
1091    pub year: Option<i32>,
1092    /// Token similarity overlap score in `0.0..=1.0`.
1093    ///
1094    /// Thresholded at `0.5`, so this is never below the floor — which is
1095    /// exactly why it reads as a positive number even at its worst. Branch on
1096    /// [`Self::confidence`] instead (#536).
1097    pub score: f64,
1098    /// [`Self::score`] banded into something an agent can branch on without
1099    /// knowing anything about the scorer (#536).
1100    pub confidence: Confidence,
1101    /// The query tokens that were found in this candidate's record.
1102    ///
1103    /// The evidence behind the score, so a reader can see *what* matched: in
1104    /// the #536 case it was `quality`, `life`, `bipolar`, `2010` — none of
1105    /// them the author or the journal, which is the whole story.
1106    pub matched: Vec<String>,
1107    /// Resolving metadata source (e.g. `"crossref"`).
1108    pub source: String,
1109}
1110
1111/// The result structure returned by bibliographic citation resolution.
1112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1113pub struct ResolveResult {
1114    /// The original query bibliographic citation string.
1115    pub query: String,
1116    /// Ranked candidate list (highest score first, thresholded to >= 0.5).
1117    pub candidates: Vec<ResolvedCandidate>,
1118}
1119
1120// ---------------------------------------------------------------------------
1121// CapabilityProfile (placeholder; full impl in Phase 1)
1122// ---------------------------------------------------------------------------
1123
1124/// Marker for the always-on Open Access tier. See `docs/CAPABILITY.md`.
1125#[derive(Debug, Clone, Copy)]
1126pub struct AlwaysOn;
1127
1128/// Which Tier 2 metadata sources are enabled this session. See `docs/CAPABILITY.md`.
1129#[derive(Debug, Clone, Default)]
1130#[non_exhaustive]
1131pub struct MetadataAccess {
1132    /// Phase 4+; enabled by `DOIGET_ENABLE_OPENALEX`.
1133    pub openalex: bool,
1134    /// Phase 4+; enabled by `DOIGET_ENABLE_S2`.
1135    pub semantic_scholar: bool,
1136    /// Phase 4+; enabled by `DOIGET_ENABLE_DOAJ`.
1137    pub doaj: bool,
1138    /// DOI **resolution** for DataCite-registered DOIs (Zenodo / figshare /
1139    /// Dryad / OSF / most institutional repositories); enabled by
1140    /// `DOIGET_ENABLE_DATACITE`.
1141    ///
1142    /// Unlike its siblings this is not enrichment — Crossref and Unpaywall
1143    /// simply do not index these DOIs, so without it a live, open record is
1144    /// reported [`ErrorCode::NotFound`] (ADR-0040, #414).
1145    pub datacite: bool,
1146    /// HAL — the French national OA repository, holding maths / physics /
1147    /// CS deposits that Crossref-centric indexes miss; enabled by
1148    /// `DOIGET_ENABLE_HAL` (ADR-0040, #418).
1149    pub hal: bool,
1150    /// OpenAIRE — European institutional / funder repository aggregation;
1151    /// enabled by `DOIGET_ENABLE_OPENAIRE` (ADR-0040, #416).
1152    pub openaire: bool,
1153    /// CORE — cross-repository OA aggregation, the last fallback in the
1154    /// optional chain; enabled by `DOIGET_ENABLE_CORE`. An optional free
1155    /// key in `DOIGET_CORE_API_KEY` raises the rate limit but is not
1156    /// required (ADR-0040, #417).
1157    pub core: bool,
1158    /// Europe PMC — biomedical OA full text that Unpaywall does not index;
1159    /// enabled by `DOIGET_ENABLE_EUROPE_PMC` (ADR-0040, #415).
1160    pub europe_pmc: bool,
1161}
1162
1163/// Process-wide rate limits. Hard-coded; not configurable.
1164///
1165/// Construct only via [`RateLimits::HARD_CODED`]. The struct fields are
1166/// `pub(crate)` so downstream code cannot synthesize a `RateLimits` with
1167/// different values, which would weaken `docs/LEGAL.md` §6 safeguard 8.
1168#[derive(Debug, Clone, Copy)]
1169#[non_exhaustive]
1170pub struct RateLimits {
1171    pub(crate) max_concurrent_fetches: u32,
1172    pub(crate) max_fetches_per_second: f32,
1173    pub(crate) per_source_backoff_ms: u64,
1174}
1175
1176impl RateLimits {
1177    /// The single, hard-coded set of rate limits. There is no other public
1178    /// constructor — see the type-level docs.
1179    pub const HARD_CODED: Self = Self {
1180        max_concurrent_fetches: MAX_CONCURRENT_FETCHES,
1181        max_fetches_per_second: MAX_FETCHES_PER_SECOND,
1182        per_source_backoff_ms: 200,
1183    };
1184
1185    /// Maximum number of concurrent fetches in flight.
1186    pub const fn max_concurrent_fetches(&self) -> u32 {
1187        self.max_concurrent_fetches
1188    }
1189
1190    /// Maximum fetch attempts per second across all sources.
1191    pub const fn max_fetches_per_second(&self) -> f32 {
1192        self.max_fetches_per_second
1193    }
1194
1195    /// Per-source backoff in milliseconds between consecutive requests.
1196    ///
1197    /// The floor that applies to every source. A source whose vendor
1198    /// publishes something stricter gets that instead -- see
1199    /// [`Self::backoff_ms_for`].
1200    pub const fn per_source_backoff_ms(&self) -> u64 {
1201        self.per_source_backoff_ms
1202    }
1203
1204    /// The minimum gap between two requests to `source`, in milliseconds.
1205    ///
1206    /// [`Self::per_source_backoff_ms`] unless [`SOURCE_RATE_OVERRIDES`]
1207    /// names a stricter value, in which case the stricter one wins. Never
1208    /// looser: `docs/SOURCES.md` promises doiget adopts a stricter vendor
1209    /// guideline at the per-source level rather than relaxing the global
1210    /// cap, and `max` here is what makes that promise structural instead of
1211    /// a matter of getting every table entry right.
1212    #[must_use]
1213    pub fn backoff_ms_for(&self, source: &str) -> u64 {
1214        match source_rate(source) {
1215            Some(r) => r.min_interval_ms.max(self.per_source_backoff_ms),
1216            None => self.per_source_backoff_ms,
1217        }
1218    }
1219
1220    /// The concurrency ceiling for `source`.
1221    ///
1222    /// Clamped to [`Self::max_concurrent_fetches`], so a table entry can
1223    /// only ever tighten the global cap.
1224    #[must_use]
1225    pub fn max_concurrent_for(&self, source: &str) -> u32 {
1226        match source_rate(source) {
1227            Some(r) => r.max_concurrent.min(self.max_concurrent_fetches),
1228            None => self.max_concurrent_fetches,
1229        }
1230    }
1231}
1232
1233/// A vendor-published rate guideline stricter than the global cap.
1234///
1235/// Library constants selected by source key, never caller-supplied:
1236/// `docs/LEGAL.md` §6a safeguard 5 makes [`RateLimits`] unsynthesizable by
1237/// downstream code on purpose, and a per-source table that took values from
1238/// a caller would hand back exactly what that safeguard withholds.
1239#[derive(Debug, Clone, Copy)]
1240#[non_exhaustive]
1241pub struct SourceRate {
1242    /// Minimum milliseconds between two requests to this source.
1243    pub min_interval_ms: u64,
1244    /// Maximum simultaneous requests to this source.
1245    pub max_concurrent: u32,
1246}
1247
1248/// Sources whose published terms are stricter than the global cap.
1249///
1250/// #493. The global cap is 5 requests/second and 5 concurrent, against
1251/// arXiv's published *"make no more than one request every three seconds,
1252/// and limit requests to a single connection at a time"* -- 15x the rate
1253/// and 5x the concurrency. Three places in the tree asserted the global cap
1254/// "comfortably respects" it.
1255///
1256/// A table rather than a config knob, and consulted through
1257/// [`RateLimits::backoff_ms_for`] rather than read directly, so an entry can
1258/// only ever tighten.
1259///
1260/// Keys are [`crate::source::Source::name`] values.
1261pub const SOURCE_RATE_OVERRIDES: &[(&str, SourceRate)] = &[(
1262    // <https://info.arxiv.org/help/api/tou.html>, read 2026-08-25. The
1263    // limit is collective across every machine under the caller's control,
1264    // and circumventing it may have access blocked.
1265    "arxiv",
1266    SourceRate {
1267        min_interval_ms: 3_000,
1268        max_concurrent: 1,
1269    },
1270)];
1271
1272/// The override for `source`, if any.
1273#[must_use]
1274pub fn source_rate(source: &str) -> Option<SourceRate> {
1275    SOURCE_RATE_OVERRIDES
1276        .iter()
1277        .find(|(k, _)| *k == source)
1278        .map(|(_, r)| *r)
1279}
1280
1281/// A successful TDM grant.
1282///
1283/// Carries the validated API key (`docs/CAPABILITY.md` §1) so that the key
1284/// flows from the startup capability gate into the source, rather than each
1285/// TDM source re-reading the env var at fetch time (issue #153 — an env
1286/// mutation between startup and fetch is otherwise undetectable).
1287///
1288/// The `api_key` field exists only when at least one `tdm-*` Cargo feature
1289/// is compiled in (the `secrecy` dependency is `optional = true` and gated
1290/// on those features per ADR-0002, so default release binaries contain no
1291/// TDM code path at all). The struct is `#[non_exhaustive]`; the
1292/// `tdm-*`-gated `api_key` field is therefore additive, not breaking, for
1293/// builds that toggle the feature set.
1294///
1295/// `docs/CAPABILITY.md` §1 specifies the type as `Secret<String>`; that is
1296/// the `secrecy` 0.9 spelling. The workspace pins `secrecy` 0.10, whose
1297/// equivalent owned-string secret type is `secrecy::SecretString`
1298/// (`= SecretBox<str>`). CAPABILITY.md §1 has been updated to match the
1299/// 0.10 API. `Debug` redacts the value.
1300///
1301/// Implements `Default` so in-crate test fixtures using
1302/// `TdmGrant { agree_env_var: ..., ..Default::default() }` keep compiling;
1303/// the default `api_key` is an empty secret.
1304#[derive(Debug, Clone)]
1305#[non_exhaustive]
1306pub struct TdmGrant {
1307    /// The publisher API key, validated present at startup by
1308    /// [`CapabilityProfile::from_env`]. Wrapped in
1309    /// `secrecy::SecretString` so `Debug` never prints it; use
1310    /// `secrecy::ExposeSecret::expose_secret` at the point of use.
1311    ///
1312    /// Only present when a `tdm-*` feature is compiled in (see the
1313    /// type-level docs and ADR-0002).
1314    #[cfg(any(
1315        feature = "tdm-elsevier",
1316        feature = "tdm-aps",
1317        feature = "tdm-springer",
1318        feature = "tdm-ieee"
1319    ))]
1320    pub api_key: secrecy::SecretString,
1321    /// Which env var the user used to acknowledge the publisher's ToS.
1322    pub agree_env_var: String,
1323    /// When the agreement env var was first observed at startup.
1324    pub agreed_at: chrono::DateTime<chrono::Utc>,
1325}
1326
1327impl Default for TdmGrant {
1328    fn default() -> Self {
1329        Self {
1330            #[cfg(any(
1331                feature = "tdm-elsevier",
1332                feature = "tdm-aps",
1333                feature = "tdm-springer",
1334                feature = "tdm-ieee"
1335            ))]
1336            api_key: secrecy::SecretString::from(String::new()),
1337            agree_env_var: String::new(),
1338            agreed_at: chrono::Utc::now(),
1339        }
1340    }
1341}
1342
1343/// Runtime gate for which sources may be invoked. See `docs/CAPABILITY.md`.
1344///
1345/// Marked `#[non_exhaustive]` so adding new capability classes is non-breaking.
1346/// Pattern-match only against the documented variants and use a wildcard arm.
1347///
1348/// **Construction**: external callers use [`CapabilityProfile::from_env()`].
1349/// Struct-literal construction is blocked outside this crate by
1350/// `#[non_exhaustive]`; this is intentional — the type's safety guarantees
1351/// rely on the resolution rules in `from_env`. `Default` is **not yet**
1352/// implemented; Phase 1 will add it once the field set stabilizes.
1353#[derive(Debug, Clone)]
1354///
1355/// **Correction (#468 review).** An earlier version of the note above said
1356/// the type's safety guarantees "rely on the resolution rules in
1357/// `from_env`", protected by `#[non_exhaustive]`. That overstates what the
1358/// attribute does: it blocks struct-literal construction across a crate
1359/// boundary, but every field here is `pub`, and `from_env` hands back an
1360/// owned value — so a downstream caller has always been able to obtain a
1361/// profile and then assign to `tdm_aps` directly. `#[non_exhaustive]` buys
1362/// forward-compatibility for adding fields, not an authorization boundary.
1363/// Whether one is wanted is tracked separately; it is not a property this
1364/// type has today, and claiming it did was the problem.
1365#[non_exhaustive]
1366pub struct CapabilityProfile {
1367    /// Tier 1 OA sources are always permitted.
1368    pub oa: AlwaysOn,
1369    /// Tier 2 metadata access (Phase 4+).
1370    pub metadata: MetadataAccess,
1371    /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1372    pub tdm_elsevier: Option<TdmGrant>,
1373    /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1374    pub tdm_aps: Option<TdmGrant>,
1375    /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1376    pub tdm_springer: Option<TdmGrant>,
1377    /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1378    pub tdm_ieee: Option<TdmGrant>,
1379    /// Hard-coded rate limits for this process.
1380    pub rate_limits: RateLimits,
1381}
1382
1383/// Errors that can arise during `CapabilityProfile::from_env`.
1384#[derive(Debug, thiserror::Error)]
1385pub enum CapabilityError {
1386    /// User set the agree env var but provided no key. See `docs/CAPABILITY.md` §2.
1387    #[error("env {agree_var} is set but {key_var} is missing")]
1388    AgreedButNoKey {
1389        /// The agreement env var the user set.
1390        agree_var: String,
1391        /// The key env var that should accompany it.
1392        key_var: String,
1393    },
1394    /// Key env var is set but user has not agreed. See `docs/CAPABILITY.md` §2.
1395    #[error("key for {agree_var} is present but {agree_var} is not set to '1'")]
1396    KeyButNotAgreed {
1397        /// The agreement env var the user must set to `1` before the key takes effect.
1398        agree_var: String,
1399    },
1400}
1401
1402impl CapabilityProfile {
1403    /// The profile a clean environment produces, built WITHOUT reading the
1404    /// environment (#456).
1405    ///
1406    /// Most tests want "a default profile", not "whatever the environment
1407    /// says". Calling [`Self::from_env`] for that couples them to a
1408    /// process-global they do not control, and the coupling is not
1409    /// hypothetical: `from_env` returns `Err(KeyButNotAgreed)` while any
1410    /// other test holds `DOIGET_KEY_*` set without its agreement var, so a
1411    /// reader that lands inside that window panics on `.expect("profile")`.
1412    /// `#[serial]` on the writer cannot help — it serialises marked tests
1413    /// against each other, and the readers were unmarked.
1414    ///
1415    /// It also makes the tests deterministic on a developer machine that
1416    /// happens to export `DOIGET_KEY_ELSEVIER`, which `#[serial]` cannot fix
1417    /// at all.
1418    ///
1419    /// Tests that genuinely exercise env resolution must keep
1420    /// [`Self::from_env`] **and** carry `#[serial_test::serial]`.
1421    ///
1422    /// Deliberately not `Default`: the type-level docs defer that to Phase 1
1423    /// "once the field set stabilizes", and a public `Default` would invite
1424    /// production code to skip the resolution rules.
1425    ///
1426    /// `#[cfg(test)]`, not merely `#[doc(hidden)]`. The #468 review pointed
1427    /// out that `#[doc(hidden)] pub` hides a function from rendered docs and
1428    /// from nothing else — it would still be compiled into every published
1429    /// build of this crate and callable by any downstream consumer, which is
1430    /// exactly what the paragraph above says a public constructor must not
1431    /// be. All 47 call sites are unit tests inside this crate (no
1432    /// integration test, no fuzz target, no other crate), so the gate costs
1433    /// nothing and the constructor does not exist in a release build.
1434    #[cfg(test)]
1435    #[must_use]
1436    pub(crate) fn for_tests() -> Self {
1437        Self {
1438            oa: AlwaysOn,
1439            // Every `DOIGET_ENABLE_*` unset — the same all-false shape
1440            // `from_env` produces with a clean environment.
1441            metadata: MetadataAccess::default(),
1442            tdm_elsevier: None,
1443            tdm_aps: None,
1444            tdm_springer: None,
1445            tdm_ieee: None,
1446            rate_limits: RateLimits::HARD_CODED,
1447        }
1448    }
1449
1450    /// Read the runtime profile from environment variables.
1451    ///
1452    /// Implements the resolution algorithm specified in
1453    /// [`docs/CAPABILITY.md`](../../../docs/CAPABILITY.md) §2.
1454    ///
1455    /// # Tier 1 (Open Access)
1456    ///
1457    /// Always permitted; not gated on any env var or feature.
1458    ///
1459    /// # Tier 2 (metadata)
1460    ///
1461    /// Each metadata source becomes available when its env var is set
1462    /// (presence-checked, value ignored) **and** the `metadata` Cargo feature
1463    /// was compiled in. If the env var is set but the feature is not compiled
1464    /// in, a `tracing::warn!` is emitted and the source is left disabled —
1465    /// this is not an error so that users can move binaries between machines
1466    /// (or switch feature sets between cargo invocations) without breaking
1467    /// startup. See `docs/CAPABILITY.md` §3 for the env var list.
1468    ///
1469    /// # Tier 3 (TDM)
1470    ///
1471    /// For each publisher in `{ELSEVIER, APS, SPRINGER}`, the
1472    /// `DOIGET_AGREE_TDM_<X>` agreement env var is paired with
1473    /// `DOIGET_KEY_<X>`. Resolution rules (per `docs/CAPABILITY.md` §2):
1474    ///
1475    /// - both unset → `tdm_<x> = None` (no error);
1476    /// - `agree == "1"` and key set → `Some(TdmGrant { .. })` (subject to the
1477    ///   feature gate below);
1478    /// - `agree == "1"` and key unset → [`CapabilityError::AgreedButNoKey`];
1479    /// - key set but `agree` unset (or `agree != "1"`) →
1480    ///   [`CapabilityError::KeyButNotAgreed`].
1481    ///
1482    /// When both env vars are set correctly **but** the corresponding
1483    /// `tdm-<x>` Cargo feature is not compiled in, this function emits a
1484    /// `tracing::warn!` and sets the grant to `None` rather than returning an
1485    /// error — same rationale as for the Tier 2 warn-and-skip behavior.
1486    ///
1487    /// # Precondition: tracing subscriber must be installed first
1488    ///
1489    /// Warn breadcrumbs are delivered via `tracing::warn!`. Callers MUST
1490    /// install a `tracing-subscriber` (or equivalent) **before** invoking
1491    /// this function, otherwise warnings are silently dropped. The
1492    /// `doiget-cli` binary does this in `main.rs`.
1493    ///
1494    /// # Errors
1495    ///
1496    /// Returns [`CapabilityError::AgreedButNoKey`] or
1497    /// [`CapabilityError::KeyButNotAgreed`] when the TDM env-var pair for any
1498    /// publisher is misconfigured. See the variant docs for the precise
1499    /// trigger conditions.
1500    ///
1501    /// # Note on `api_key` storage
1502    ///
1503    /// When a `tdm-*` feature is compiled in, [`TdmGrant`] carries the
1504    /// validated key as `secrecy::SecretString` (issue #153). The key is
1505    /// read exactly once here, at startup; TDM sources consume it from the
1506    /// grant and never re-read the env var at fetch time. This makes the
1507    /// grant a true startup attestation — an env mutation between startup
1508    /// and fetch can no longer silently change the credential in flight.
1509    /// See the [`TdmGrant`] doc-comment and `docs/CAPABILITY.md` §1/§2.
1510    pub fn from_env() -> Result<Self, CapabilityError> {
1511        // Issue #153: the validated API key is now threaded through
1512        // `TdmGrant` (as `secrecy::SecretString`, behind the `tdm-*`
1513        // features) by `resolve_tdm_grant` below — sources no longer
1514        // re-read the key env var at fetch time. See the `TdmGrant`
1515        // doc-comment and `docs/CAPABILITY.md` §1/§2.
1516
1517        // -- Tier 2 metadata -------------------------------------------------
1518        let metadata = MetadataAccess {
1519            openalex: resolve_metadata_flag(
1520                "DOIGET_ENABLE_OPENALEX",
1521                "metadata",
1522                cfg!(feature = "metadata"),
1523            ),
1524            semantic_scholar: resolve_metadata_flag(
1525                "DOIGET_ENABLE_S2",
1526                "metadata",
1527                cfg!(feature = "metadata"),
1528            ),
1529            doaj: resolve_metadata_flag(
1530                "DOIGET_ENABLE_DOAJ",
1531                "metadata",
1532                cfg!(feature = "metadata"),
1533            ),
1534            datacite: resolve_metadata_flag(
1535                "DOIGET_ENABLE_DATACITE",
1536                "metadata",
1537                cfg!(feature = "metadata"),
1538            ),
1539            hal: resolve_metadata_flag("DOIGET_ENABLE_HAL", "metadata", cfg!(feature = "metadata")),
1540            openaire: resolve_metadata_flag(
1541                "DOIGET_ENABLE_OPENAIRE",
1542                "metadata",
1543                cfg!(feature = "metadata"),
1544            ),
1545            core: resolve_metadata_flag(
1546                "DOIGET_ENABLE_CORE",
1547                "metadata",
1548                cfg!(feature = "metadata"),
1549            ),
1550            europe_pmc: resolve_metadata_flag(
1551                "DOIGET_ENABLE_EUROPE_PMC",
1552                "metadata",
1553                cfg!(feature = "metadata"),
1554            ),
1555        };
1556
1557        // -- Tier 3 TDM grants ----------------------------------------------
1558        // #509: the key may also come from `credentials.toml`, which
1559        // `docs/CONFIG.md` §6 has specified in full since 0.7 and which
1560        // nothing read. Loaded once — one file, one reader, so `config
1561        // doctor` and a fetch can never describe different files (#441's
1562        // lesson). The **agreement** stays environment-only; see the
1563        // `credentials` module docs and `docs/LEGAL.md` §6a.2.
1564        let creds = crate::credentials::load_or_default();
1565        let tdm_elsevier = resolve_tdm_grant(
1566            AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
1567            KeyVar::new("DOIGET_KEY_ELSEVIER"),
1568            "tdm-elsevier",
1569            cfg!(feature = "tdm-elsevier"),
1570            creds.api_key("elsevier"),
1571        )?;
1572        let tdm_aps = resolve_tdm_grant(
1573            AgreeVar::new("DOIGET_AGREE_TDM_APS"),
1574            KeyVar::new("DOIGET_KEY_APS"),
1575            "tdm-aps",
1576            cfg!(feature = "tdm-aps"),
1577            creds.api_key("aps"),
1578        )?;
1579        let tdm_springer = resolve_tdm_grant(
1580            AgreeVar::new("DOIGET_AGREE_TDM_SPRINGER"),
1581            KeyVar::new("DOIGET_KEY_SPRINGER"),
1582            "tdm-springer",
1583            cfg!(feature = "tdm-springer"),
1584            creds.api_key("springer"),
1585        )?;
1586        let tdm_ieee = resolve_tdm_grant(
1587            AgreeVar::new("DOIGET_AGREE_TDM_IEEE"),
1588            KeyVar::new("DOIGET_KEY_IEEE"),
1589            "tdm-ieee",
1590            cfg!(feature = "tdm-ieee"),
1591            creds.api_key("ieee"),
1592        )?;
1593
1594        Ok(Self {
1595            oa: AlwaysOn,
1596            metadata,
1597            tdm_elsevier,
1598            tdm_aps,
1599            tdm_springer,
1600            tdm_ieee,
1601            rate_limits: RateLimits::HARD_CODED,
1602        })
1603    }
1604}
1605
1606/// Resolve a Tier 2 metadata flag from its env var and compile-in feature.
1607///
1608/// Returns `true` only when both the env var is present and the feature is
1609/// compiled in. When the env var is set without the feature, emits a
1610/// `tracing::warn!` and returns `false` — see [`CapabilityProfile::from_env`]
1611/// for the rationale (binaries may move between hosts / feature sets).
1612fn resolve_metadata_flag(env_var: &str, feature: &str, feature_enabled: bool) -> bool {
1613    let env_set = std::env::var_os(env_var).is_some();
1614    match (env_set, feature_enabled) {
1615        (true, true) => true,
1616        (true, false) => {
1617            tracing::warn!(
1618                env_var,
1619                feature,
1620                "{} is set but feature {} was not compiled in; the source will be unavailable",
1621                env_var,
1622                feature
1623            );
1624            false
1625        }
1626        (false, _) => false,
1627    }
1628}
1629
1630/// The env var carrying the per-publisher agreement.
1631///
1632/// A newtype because `agree_var` and `key_var` were adjacent `&str`
1633/// parameters: transposing them at a call site type-checked, and the
1634/// resulting build would treat the KEY as the agreement signal and the
1635/// AGREEMENT as the key. `docs/LEGAL.md` §6a.2 makes that agreement an
1636/// enforced control, so "nothing stops a fifth publisher's call site from
1637/// being copy-pasted wrong" is not a risk worth carrying for two saved
1638/// characters.
1639///
1640/// `pub(crate)` with a private field: the only consumer is a private `fn`
1641/// in this module, so a public tuple struct added semver surface nothing
1642/// outside the crate can reach. The private field also closes the variant
1643/// the newtype alone did not — `AgreeVar("DOIGET_KEY_ELSEVIER")` is the
1644/// same transposition expressed as content rather than position, and it
1645/// compiled. [`AgreeVar::new`] refuses it.
1646#[derive(Debug, Clone, Copy)]
1647pub(crate) struct AgreeVar(&'static str);
1648
1649/// The env var carrying the per-publisher API key. See [`AgreeVar`].
1650#[derive(Debug, Clone, Copy)]
1651pub(crate) struct KeyVar(&'static str);
1652
1653impl AgreeVar {
1654    /// # Panics
1655    ///
1656    /// If `var` is not a `DOIGET_AGREE_TDM_*` name. Every argument is a
1657    /// literal in this file, so this is a typo caught at the first test
1658    /// run, not a runtime failure mode.
1659    pub(crate) fn new(var: &'static str) -> Self {
1660        assert!(
1661            var.starts_with("DOIGET_AGREE_TDM_"),
1662            "{var} is not an agreement variable"
1663        );
1664        Self(var)
1665    }
1666}
1667
1668impl KeyVar {
1669    /// # Panics
1670    ///
1671    /// If `var` is not a `DOIGET_KEY_*` name. See [`AgreeVar::new`].
1672    pub(crate) fn new(var: &'static str) -> Self {
1673        assert!(
1674            var.starts_with("DOIGET_KEY_"),
1675            "{var} is not a key variable"
1676        );
1677        Self(var)
1678    }
1679}
1680
1681/// Resolve a Tier 3 TDM grant from the agreement env var, the key (env var
1682/// or `credentials.toml`), and the per-publisher Cargo feature.
1683///
1684/// Implements the rules in `docs/CAPABILITY.md` §2:
1685///
1686/// - both unset → `Ok(None)`.
1687/// - `agree == "1"` and a key → `Ok(Some(TdmGrant { .. }))` (when the
1688///   feature is enabled), or warn-and-`Ok(None)` (when the feature is not
1689///   compiled in).
1690/// - `agree == "1"` and no key → [`CapabilityError::AgreedButNoKey`].
1691/// - a key, and `agree` unset OR set to anything other than `"1"` →
1692///   [`CapabilityError::KeyButNotAgreed`].
1693///
1694/// `file_key` is `[tdm.<publisher>] api_key` from `credentials.toml`, one
1695/// rung **below** `DOIGET_KEY_<PUBLISHER>` (#509). The two rules above are
1696/// unchanged by its existence: a key from the file still needs the
1697/// agreement, and the agreement still comes only from the environment, so
1698/// `KeyButNotAgreed` now also fires for a file-supplied key with no
1699/// `DOIGET_AGREE_TDM_<PUBLISHER>=1`. That is the point — `docs/LEGAL.md`
1700/// §6a.2 is an enforced control, and a convenience must not dilute it.
1701fn resolve_tdm_grant(
1702    agree: AgreeVar,
1703    key: KeyVar,
1704    feature: &str,
1705    feature_enabled: bool,
1706    file_key: Option<&str>,
1707) -> Result<Option<TdmGrant>, CapabilityError> {
1708    let (agree_var, key_var) = (agree.0, key.0);
1709    // `agree` is "agreed" iff the value is exactly the literal "1"; any other
1710    // value (including "true", "yes", empty) is treated as not-agreed per
1711    // `docs/CAPABILITY.md` §2.
1712    let agree_raw = std::env::var(agree_var).ok();
1713    let agreed = matches!(agree_raw.as_deref(), Some("1"));
1714    let agree_present = agree_raw.is_some();
1715    // Read the key value once, at startup, so the validated key flows
1716    // through `TdmGrant` and sources never re-read the env (issue #153).
1717    // An empty value is treated as "not set" — an empty API key cannot
1718    // authenticate, and silently constructing a grant around it would
1719    // mask the misconfiguration the AgreedButNoKey rule exists to surface.
1720    //
1721    // Env above file (#509), matching `docs/CONFIG.md` §1 and the
1722    // `store_root` / `contact_email` rungs. `credentials.toml` has already
1723    // applied the same blank-is-unset rule.
1724    let key_value = std::env::var(key_var)
1725        .ok()
1726        .filter(|v| !v.trim().is_empty())
1727        .or_else(|| file_key.map(str::to_string));
1728
1729    match (agreed, agree_present, key_value) {
1730        (true, _, Some(key)) => {
1731            if feature_enabled {
1732                Ok(Some(build_tdm_grant(agree_var, key)))
1733            } else {
1734                // `key` is dropped here; under no-tdm builds it is the only
1735                // consumer of the owned `String`, which is intended.
1736                let _ = key;
1737                tracing::warn!(
1738                    env_var = agree_var,
1739                    feature,
1740                    "{} is set but feature {} was not compiled in; the source will be unavailable",
1741                    agree_var,
1742                    feature
1743                );
1744                Ok(None)
1745            }
1746        }
1747        (true, _, None) => Err(CapabilityError::AgreedButNoKey {
1748            agree_var: agree_var.to_string(),
1749            key_var: key_var.to_string(),
1750        }),
1751        // agree set to non-"1", key also set: KeyButNotAgreed (the key would
1752        // otherwise authorize the source without an explicit agreement).
1753        (false, true, Some(_)) => Err(CapabilityError::KeyButNotAgreed {
1754            agree_var: agree_var.to_string(),
1755        }),
1756        // agree unset, key set: KeyButNotAgreed (same rule).
1757        (false, false, Some(_)) => Err(CapabilityError::KeyButNotAgreed {
1758            agree_var: agree_var.to_string(),
1759        }),
1760        // agree set to non-"1" and no key: treat as no-grant. The user
1761        // expressed something but did not opt in and provided no credential,
1762        // so silent skip is the safe default (no source enabled).
1763        (false, true, None) => Ok(None),
1764        // Neither env var set: no grant, no error.
1765        (false, false, None) => Ok(None),
1766    }
1767}
1768
1769/// Construct a [`TdmGrant`] from the validated agreement var and key value.
1770///
1771/// Split out so the `tdm-*`-gated `api_key` field is populated in exactly
1772/// one place. When no `tdm-*` feature is compiled in the `key` is consumed
1773/// (dropped) here — the grant is still produced so that startup attestation
1774/// behavior (the warn-and-skip path) does not change shape between feature
1775/// sets.
1776fn build_tdm_grant(agree_var: &str, key: String) -> TdmGrant {
1777    #[cfg(any(
1778        feature = "tdm-elsevier",
1779        feature = "tdm-aps",
1780        feature = "tdm-springer",
1781        feature = "tdm-ieee"
1782    ))]
1783    {
1784        TdmGrant {
1785            api_key: secrecy::SecretString::from(key),
1786            agree_env_var: agree_var.to_string(),
1787            agreed_at: chrono::Utc::now(),
1788        }
1789    }
1790    #[cfg(not(any(
1791        feature = "tdm-elsevier",
1792        feature = "tdm-aps",
1793        feature = "tdm-springer",
1794        feature = "tdm-ieee"
1795    )))]
1796    {
1797        let _ = key;
1798        TdmGrant {
1799            agree_env_var: agree_var.to_string(),
1800            agreed_at: chrono::Utc::now(),
1801        }
1802    }
1803}
1804
1805// ---------------------------------------------------------------------------
1806// Tests — one smoke test per legally-load-bearing constant. See
1807// `docs/LEGAL.md` §6 safeguard 8 and `docs/PHASES.md` §4. These also keep the
1808// `cargo test --workspace` job from being a false-green during Phase 0.
1809// ---------------------------------------------------------------------------
1810
1811// `expect`/`unwrap` are idiomatic in tests where panics double as assertions.
1812// The workspace lints deny them in production code; relax for the test module
1813// only.
1814#[cfg(test)]
1815#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1816mod tests {
1817    use super::*;
1818
1819    #[test]
1820    fn rate_limits_hard_coded_match_legal_safeguards() {
1821        // docs/LEGAL.md §6 safeguard 8 names these exact values.
1822        assert_eq!(RateLimits::HARD_CODED.max_concurrent_fetches(), 5);
1823        assert!((RateLimits::HARD_CODED.max_fetches_per_second() - 5.0).abs() < f32::EPSILON);
1824        assert_eq!(RateLimits::HARD_CODED.per_source_backoff_ms(), 200);
1825    }
1826
1827    #[test]
1828    fn batch_size_caps_match_security_doc() {
1829        // docs/SECURITY.md §1.4 + docs/MCP_TOOLS.md.
1830        assert_eq!(MCP_BATCH_MAX_SIZE, 100);
1831        assert_eq!(MCP_QUEUE_DEPTH_MAX, 100);
1832        assert_eq!(DOI_SUFFIX_MAX_LEN, 256);
1833        assert_eq!(MCP_STDIN_EOF_SHUTDOWN_SEC, 5);
1834        // Slice 2: spec-language alias for MCP_BATCH_MAX_SIZE must
1835        // numerically agree with the original constant.
1836        assert_eq!(MAX_BATCH_REFS, MCP_BATCH_MAX_SIZE);
1837    }
1838
1839    #[test]
1840    fn schema_version_is_pinned_to_1_0() {
1841        // docs/STORE.md §3 — Phase 0/1 writes 1.0 exactly.
1842        // A bump to 1.1 (minor, backward-compat additions) requires updating
1843        // both this test and the cross-tool compat fixtures simultaneously.
1844        assert_eq!(SCHEMA_VERSION, "1.0");
1845    }
1846
1847    // -----------------------------------------------------------------
1848    // CapabilityProfile::from_env — Phase 1 resolution algorithm tests.
1849    //
1850    // These tests mutate process-global env state via std::env::set_var /
1851    // remove_var, so each test holds an `EnvGuard` RAII drop guard that
1852    // captures the pre-test value of every env var it touches and restores
1853    // it on drop (even on panic). They also use `#[serial_test::serial]` so
1854    // that no two tests in this module touch env state concurrently — the
1855    // workspace's test runner defaults to multi-threaded.
1856    //
1857    // Spec: docs/CAPABILITY.md §2 (resolution algorithm) and §3 (env var
1858    // reference table).
1859    // -----------------------------------------------------------------
1860
1861    /// RAII guard that captures the prior value of an env var on construction
1862    /// and restores it on drop. Use one guard per touched var per test.
1863    struct EnvGuard {
1864        var: &'static str,
1865        prior: Option<std::ffi::OsString>,
1866    }
1867
1868    impl EnvGuard {
1869        /// Capture and clear `var`. Use `set` afterwards to install a value.
1870        fn unset(var: &'static str) -> Self {
1871            let prior = std::env::var_os(var);
1872            // SAFETY (env mutation): tests are serialized via
1873            // `#[serial_test::serial]`. `remove_var` is sound when no other
1874            // thread reads or writes the environment concurrently.
1875            std::env::remove_var(var);
1876            EnvGuard { var, prior }
1877        }
1878
1879        /// Capture, then set `var` to `value`.
1880        fn set(var: &'static str, value: &str) -> Self {
1881            let prior = std::env::var_os(var);
1882            std::env::set_var(var, value);
1883            EnvGuard { var, prior }
1884        }
1885    }
1886
1887    impl Drop for EnvGuard {
1888        fn drop(&mut self) {
1889            match &self.prior {
1890                Some(v) => std::env::set_var(self.var, v),
1891                None => std::env::remove_var(self.var),
1892            }
1893        }
1894    }
1895
1896    /// Point every config-dir rung at `dir`, so `credentials.toml` and
1897    /// `config.toml` resolve there. Returns guards restoring prior values.
1898    fn scoped_config_home(dir: &str) -> Vec<EnvGuard> {
1899        ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
1900            .iter()
1901            .map(|v| EnvGuard::set(v, dir))
1902            .collect()
1903    }
1904
1905    /// Convenience: unset every Tier 2 / Tier 3 env var the resolution
1906    /// algorithm reads, returning a vector of guards that restore them on
1907    /// drop. Callers can then `EnvGuard::set` individual vars on top.
1908    ///
1909    /// The caller MUST also scope the config directory — see
1910    /// [`isolated_capability_env`]. Since #509 the TDM key has a
1911    /// `credentials.toml` rung, so a test that only clears the environment
1912    /// reads the developer's real credentials file: green in CI and red on
1913    /// the one machine that has TDM configured, which is the least useful
1914    /// place for a test to fail.
1915    fn unset_all_capability_env_vars() -> Vec<EnvGuard> {
1916        [
1917            "DOIGET_ENABLE_OPENALEX",
1918            "DOIGET_ENABLE_S2",
1919            "DOIGET_ENABLE_DOAJ",
1920            "DOIGET_AGREE_TDM_ELSEVIER",
1921            "DOIGET_KEY_ELSEVIER",
1922            "DOIGET_AGREE_TDM_APS",
1923            "DOIGET_KEY_APS",
1924            "DOIGET_AGREE_TDM_SPRINGER",
1925            "DOIGET_KEY_SPRINGER",
1926            "DOIGET_AGREE_TDM_IEEE",
1927            "DOIGET_KEY_IEEE",
1928        ]
1929        .iter()
1930        .map(|v| EnvGuard::unset(v))
1931        .collect()
1932    }
1933
1934    /// Clean environment AND an empty config directory, so
1935    /// `CapabilityProfile::from_env` sees neither an env var nor a
1936    /// credentials file. Hold the returned tuple for the test's lifetime.
1937    fn isolated_capability_env() -> (tempfile::TempDir, Vec<EnvGuard>, Vec<EnvGuard>) {
1938        isolated_env_with(None)
1939    }
1940
1941    /// As [`isolated_capability_env`], optionally writing `credentials.toml`.
1942    fn isolated_env_with(
1943        credentials: Option<&str>,
1944    ) -> (tempfile::TempDir, Vec<EnvGuard>, Vec<EnvGuard>) {
1945        let td = tempfile::TempDir::new().expect("tempdir");
1946        let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
1947            .expect("temp path is UTF-8");
1948        if let Some(body) = credentials {
1949            std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
1950            std::fs::write(
1951                dir.join("doiget").join("credentials.toml").as_std_path(),
1952                body,
1953            )
1954            .expect("write credentials.toml");
1955        }
1956        let env = unset_all_capability_env_vars();
1957        let home = scoped_config_home(dir.as_str());
1958        (td, env, home)
1959    }
1960
1961    /// #509: `credentials.toml` supplies the KEY, and the agreement still
1962    /// comes only from the environment.
1963    ///
1964    /// Asserts the production path (`CapabilityProfile::from_env`), not the
1965    /// parser — the parser was never the missing part. #442, #454 and #458
1966    /// were each a correct component nothing reached, and a file reader
1967    /// with no caller would be that defect again.
1968    ///
1969    /// Holds in the shipped `oa-only` build: `KeyButNotAgreed` fires before
1970    /// any feature gate, so this proves the file is read without needing a
1971    /// `tdm-*` feature compiled.
1972    #[test]
1973    #[serial_test::serial]
1974    fn a_key_from_credentials_toml_is_read_and_still_needs_the_agreement() {
1975        let (_td, _env, _home) = isolated_env_with(Some(
1976            "[tdm.elsevier]
1977api_key = \"file-key\"
1978",
1979        ));
1980
1981        match CapabilityProfile::from_env() {
1982            Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
1983                assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1984            }
1985            other => panic!(
1986                "a key in credentials.toml must be READ, so the missing agreement is reported. Before #509 this was Ok(no grant) because the file was never opened. Got {other:?}"
1987            ),
1988        }
1989    }
1990
1991    /// The half that must NOT work: `agreed = true` in the file is not an
1992    /// agreement (`docs/LEGAL.md` §6a.2). Key from the file, no
1993    /// `DOIGET_AGREE_TDM_ELSEVIER` — still `KeyButNotAgreed`.
1994    #[test]
1995    #[serial_test::serial]
1996    fn agreed_in_credentials_toml_does_not_grant_anything() {
1997        let (_td, _env, _home) = isolated_env_with(Some(
1998            "[tdm.elsevier]
1999api_key = \"file-key\"
2000agreed = true
2001",
2002        ));
2003
2004        match CapabilityProfile::from_env() {
2005            Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
2006                assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
2007            }
2008            other => panic!(
2009                "`agreed` in the file must not substitute for the environment agreement; got {other:?}"
2010            ),
2011        }
2012    }
2013
2014    /// Env above file, per `docs/CONFIG.md` §1: the agreement plus either
2015    /// key resolves, and a blank env key falls through to the file rather
2016    /// than counting as a key (the blank-is-unset rule every rung uses).
2017    #[test]
2018    #[serial_test::serial]
2019    fn the_env_key_outranks_the_file_and_a_blank_one_falls_through() {
2020        let _g = unset_all_capability_env_vars();
2021
2022        let granted = resolve_tdm_grant(
2023            AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
2024            KeyVar::new("DOIGET_KEY_ELSEVIER"),
2025            "tdm-elsevier",
2026            false,
2027            Some("file-key"),
2028        );
2029        match granted {
2030            Err(CapabilityError::KeyButNotAgreed { .. }) => {}
2031            other => panic!("a file key with no agreement is KeyButNotAgreed; got {other:?}"),
2032        }
2033
2034        let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "   ");
2035        match resolve_tdm_grant(
2036            AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
2037            KeyVar::new("DOIGET_KEY_ELSEVIER"),
2038            "tdm-elsevier",
2039            false,
2040            Some("file-key"),
2041        ) {
2042            Err(CapabilityError::KeyButNotAgreed { .. }) => {}
2043            other => panic!("a blank env key must fall through to the file; got {other:?}"),
2044        }
2045
2046        let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
2047        assert!(
2048            resolve_tdm_grant(
2049                AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
2050                KeyVar::new("DOIGET_KEY_ELSEVIER"),
2051                "tdm-elsevier",
2052                false,
2053                Some("file-key"),
2054            )
2055            .is_ok(),
2056            "agreement + a file key is a valid configuration"
2057        );
2058    }
2059
2060    #[test]
2061    #[serial_test::serial]
2062    fn from_env_no_env_vars_set_returns_tier_1_only() {
2063        // Rule: with every relevant env var unset, the resolved profile has
2064        // all TDM grants `None` and all metadata flags `false`. Hard-coded
2065        // rate limits still apply. (Replaces the old Phase 0 stub test.)
2066        let (_td, _g, _home) = isolated_capability_env();
2067
2068        let p = CapabilityProfile::from_env().expect("clean env never errors");
2069        assert!(p.tdm_elsevier.is_none());
2070        assert!(p.tdm_aps.is_none());
2071        assert!(p.tdm_springer.is_none());
2072        assert!(!p.metadata.openalex);
2073        assert!(!p.metadata.semantic_scholar);
2074        assert!(!p.metadata.doaj);
2075        assert_eq!(p.rate_limits.max_concurrent_fetches(), 5);
2076    }
2077
2078    #[test]
2079    #[serial_test::serial]
2080    fn from_env_no_tdm_returns_tier_1_profile() {
2081        // Rule (CAPABILITY.md §2): with every TDM env var unset, all
2082        // `tdm_*` fields are `None` and no error is produced.
2083        let (_td, _g, _home) = isolated_capability_env();
2084
2085        let p = CapabilityProfile::from_env().expect("no TDM env -> Ok");
2086        assert!(p.tdm_elsevier.is_none());
2087        assert!(p.tdm_aps.is_none());
2088        assert!(p.tdm_springer.is_none());
2089    }
2090
2091    #[test]
2092    #[serial_test::serial]
2093    fn from_env_agreed_but_no_key_errs() {
2094        // Rule (CAPABILITY.md §2): agree=1 + key unset -> AgreedButNoKey.
2095        let (_td, _g, _home) = isolated_capability_env();
2096        let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
2097
2098        let result = CapabilityProfile::from_env();
2099        match result {
2100            Err(CapabilityError::AgreedButNoKey { agree_var, key_var }) => {
2101                assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
2102                assert_eq!(key_var, "DOIGET_KEY_ELSEVIER");
2103            }
2104            other => panic!("expected AgreedButNoKey, got {:?}", other),
2105        }
2106    }
2107
2108    #[test]
2109    #[serial_test::serial]
2110    fn from_env_agreed_but_empty_key_errs() {
2111        // Security-adjacent (PR #161 review): an *empty* key string is
2112        // treated as "not set" by `resolve_tdm_grant`. With agree=1 and
2113        // DOIGET_KEY_ELSEVIER="" the misconfiguration must surface as
2114        // AgreedButNoKey, not silently build a grant around an empty
2115        // secret that could never authenticate.
2116        let (_td, _g, _home) = isolated_capability_env();
2117        let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
2118        let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "");
2119
2120        let result = CapabilityProfile::from_env();
2121        match result {
2122            Err(CapabilityError::AgreedButNoKey { agree_var, key_var }) => {
2123                assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
2124                assert_eq!(key_var, "DOIGET_KEY_ELSEVIER");
2125            }
2126            other => panic!("expected AgreedButNoKey for empty key, got {:?}", other),
2127        }
2128    }
2129
2130    #[test]
2131    #[serial_test::serial]
2132    fn from_env_empty_key_without_agree_is_no_grant() {
2133        // Security-adjacent (PR #161 review): an empty key with the
2134        // agree var unset is indistinguishable from "no key at all".
2135        // It must resolve to Ok(None) (no grant, no error) — an empty
2136        // string must NOT trip the KeyButNotAgreed leaked-credential
2137        // rule, since there is no credential.
2138        let (_td, _g, _home) = isolated_capability_env();
2139        let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "");
2140
2141        let p = CapabilityProfile::from_env()
2142            .expect("empty key + agree unset must be Ok(None), not an error");
2143        assert!(
2144            p.tdm_elsevier.is_none(),
2145            "empty DOIGET_KEY_ELSEVIER with no agree var must yield no grant"
2146        );
2147        assert!(p.tdm_aps.is_none());
2148        assert!(p.tdm_springer.is_none());
2149    }
2150
2151    #[test]
2152    #[serial_test::serial]
2153    fn from_env_key_but_not_agreed_errs() {
2154        // Rule (CAPABILITY.md §2): key set + agree unset -> KeyButNotAgreed.
2155        // A leaked DOIGET_KEY_ELSEVIER must not silently enable a source.
2156        let (_td, _g, _home) = isolated_capability_env();
2157        let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "sk-test");
2158
2159        let result = CapabilityProfile::from_env();
2160        match result {
2161            Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
2162                assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
2163            }
2164            other => panic!("expected KeyButNotAgreed, got {:?}", other),
2165        }
2166    }
2167
2168    #[test]
2169    #[serial_test::serial]
2170    fn from_env_agree_not_one_errs() {
2171        // Rule (CAPABILITY.md §2): the agree var must be exactly "1". Any
2172        // other value (here: "true") is treated as not-agreed; combined
2173        // with a key set, that triggers KeyButNotAgreed.
2174        let (_td, _g, _home) = isolated_capability_env();
2175        let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "true");
2176        let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "sk-test");
2177
2178        let result = CapabilityProfile::from_env();
2179        match result {
2180            Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
2181                assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
2182            }
2183            other => panic!("expected KeyButNotAgreed, got {:?}", other),
2184        }
2185    }
2186
2187    #[test]
2188    #[serial_test::serial]
2189    fn from_env_both_set_correctly_returns_grant() {
2190        // Rule (CAPABILITY.md §2): agree=1 + key set -> Some(TdmGrant) when
2191        // the corresponding feature is compiled in; else None (warn-and-skip).
2192        // The feature gate for elsevier is `tdm-elsevier`; this test asserts
2193        // both branches via `cfg!`.
2194        let _g = unset_all_capability_env_vars();
2195        let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
2196        let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "sk-test");
2197
2198        let p = CapabilityProfile::from_env().expect("agree=1 + key -> Ok");
2199
2200        if cfg!(feature = "tdm-elsevier") {
2201            let grant = p
2202                .tdm_elsevier
2203                .as_ref()
2204                .expect("feature tdm-elsevier compiled in -> Some(TdmGrant)");
2205            assert_eq!(grant.agree_env_var, "DOIGET_AGREE_TDM_ELSEVIER");
2206            // Issue #153 / PR #161 review: prove the key was actually
2207            // threaded into TdmGrant::api_key at startup (not just that
2208            // the agree var was recorded). The field is cfg-gated to
2209            // the same `tdm-*` set as the assertion below, so gate the
2210            // check identically.
2211            #[cfg(any(
2212                feature = "tdm-elsevier",
2213                feature = "tdm-aps",
2214                feature = "tdm-springer",
2215                feature = "tdm-ieee"
2216            ))]
2217            {
2218                use secrecy::ExposeSecret as _;
2219                assert_eq!(
2220                    grant.api_key.expose_secret(),
2221                    "sk-test",
2222                    "the DOIGET_KEY_ELSEVIER value must be threaded into \
2223                     TdmGrant::api_key (issue #153)"
2224                );
2225            }
2226        } else {
2227            assert!(
2228                p.tdm_elsevier.is_none(),
2229                "feature tdm-elsevier NOT compiled in -> None (warn-and-skip)"
2230            );
2231        }
2232    }
2233
2234    #[test]
2235    #[serial_test::serial]
2236    fn from_env_metadata_env_warns_without_feature() {
2237        // Rule (CAPABILITY.md §2): metadata env var without the `metadata`
2238        // feature -> source disabled (warn-and-skip, not an error).
2239        // We don't capture the tracing warn here; we just assert the field
2240        // is `false` when the feature is absent and `true` when present.
2241        let _g = unset_all_capability_env_vars();
2242        let _enable = EnvGuard::set("DOIGET_ENABLE_OPENALEX", "1");
2243
2244        let p = CapabilityProfile::from_env().expect("metadata env never errors");
2245
2246        if cfg!(feature = "metadata") {
2247            assert!(p.metadata.openalex);
2248        } else {
2249            assert!(!p.metadata.openalex);
2250        }
2251    }
2252
2253    // -----------------------------------------------------------------
2254    // Safekey reference vectors (docs/SAFEKEY.md §3, NORMATIVE).
2255    //
2256    // The vectors.json file is the binding cross-tool contract with
2257    // BiblioFetch.jl: every entry MUST round-trip identically through
2258    // both implementations. Phase 0 ships 13 entries; the full 100-entry
2259    // set is gated on the BiblioFetch.jl pre-flight (ADR-0007 Status:
2260    // Proposed at the time of this Phase 1 implementation).
2261    //
2262    // `Ref::parse` is concurrent W3-A work and is not on `main` yet, so
2263    // this test branches on the input prefix (`doi:` / `arxiv:`) and
2264    // constructs the variant directly via the in-crate `pub(crate)`
2265    // tuple constructor.
2266    // -----------------------------------------------------------------
2267
2268    #[derive(Deserialize)]
2269    struct SafekeyVector {
2270        input: String,
2271        expected: String,
2272    }
2273
2274    #[derive(Deserialize)]
2275    struct SafekeyVectorFile {
2276        vectors: Vec<SafekeyVector>,
2277    }
2278
2279    /// In-crate test helper: build a `Ref` from the user-facing form used
2280    /// in the vectors file, by stripping the `doi:` / `arxiv:` URI scheme
2281    /// and wrapping the remainder. This bypasses validation; it is fine
2282    /// here because the vectors are hand-curated and the test asserts the
2283    /// derivation algorithm, not parser semantics.
2284    fn ref_from_vector_input(input: &str) -> Ref {
2285        if let Some(rest) = input.strip_prefix("doi:") {
2286            Ref::Doi(Doi(rest.to_string()))
2287        } else if let Some(rest) = input.strip_prefix("arxiv:") {
2288            Ref::Arxiv(ArxivId(rest.to_string()))
2289        } else {
2290            panic!(
2291                "vectors.json entry has unknown ref scheme (expected doi: or arxiv: prefix): {}",
2292                input
2293            );
2294        }
2295    }
2296
2297    /// #506: `docs/ERRORS.md` §2 and [`ErrorCode::disposition`] are the same
2298    /// claim written twice, so this asserts they say the same thing.
2299    ///
2300    /// The issue asked for exactly this ("the ERRORS.md §2 table either
2301    /// generated from it or asserted against it in a test — otherwise the doc
2302    /// and the wire drift, which is the #493 pattern"). Generating the table
2303    /// would have cost the per-code prose, which is the useful part; asserting
2304    /// it keeps both.
2305    ///
2306    /// Reads the shipped document rather than a fixture copy, so a doc edit
2307    /// that contradicts the code fails here and not in someone's agent.
2308    #[test]
2309    fn errors_md_disposition_column_matches_the_code() {
2310        // Resolves relative to this file; three levels up is the workspace
2311        // root (same reasoning as `safekey_matches_reference_vectors`).
2312        let doc = include_str!("../../../docs/ERRORS.md");
2313
2314        let mut checked = 0usize;
2315        for line in doc.lines() {
2316            // `| \`CODE\` | meaning | \`disposition\` | recoverable |`
2317            let Some(rest) = line.strip_prefix("| `") else {
2318                continue;
2319            };
2320            let Some((code_str, tail)) = rest.split_once("` | ") else {
2321                continue;
2322            };
2323            let cols: Vec<&str> = tail.split(" | ").collect();
2324            if cols.len() < 3 {
2325                continue;
2326            }
2327            let documented = cols[1].trim().trim_matches('`');
2328
2329            let code = match code_str {
2330                "INVALID_REF" => ErrorCode::InvalidRef,
2331                "NO_OA_AVAILABLE" => ErrorCode::NoOaAvailable,
2332                "RATE_LIMITED" => ErrorCode::RateLimited,
2333                "NETWORK_ERROR" => ErrorCode::NetworkError,
2334                "NOT_FOUND" => ErrorCode::NotFound,
2335                "AMBIGUOUS" => ErrorCode::Ambiguous,
2336                "STORE_ERROR" => ErrorCode::StoreError,
2337                "LOG_ERROR" => ErrorCode::LogError,
2338                "CAPABILITY_DENIED" => ErrorCode::CapabilityDenied,
2339                "FETCH_TIMEOUT" => ErrorCode::FetchTimeout,
2340                "SCHEMA_TOO_NEW" => ErrorCode::SchemaTooNew,
2341                "LOCK_TIMEOUT" => ErrorCode::LockTimeout,
2342                "INTERNAL_ERROR" => ErrorCode::InternalError,
2343                "NOT_IMPLEMENTED" => ErrorCode::NotImplemented,
2344                "TEXT_UNAVAILABLE" => ErrorCode::TextUnavailable,
2345                // Not a §2 row (e.g. the §6 mapping tables).
2346                _ => continue,
2347            };
2348            assert_eq!(
2349                documented,
2350                code.disposition().as_wire(),
2351                "docs/ERRORS.md §2 says {code_str} is `{documented}`, the code says                  `{}` — one of the two is wrong and an agent reads the second",
2352                code.disposition().as_wire()
2353            );
2354            checked += 1;
2355        }
2356
2357        // The guard the assertion above cannot be: a parser that silently
2358        // matches nothing would pass every time. §2 has one row per code.
2359        assert_eq!(
2360            checked, 15,
2361            "expected every ErrorCode to have a §2 row with a Disposition              column; parsed {checked}. Either a code was added without              documenting it, or the table's shape changed and this parser              stopped seeing it."
2362        );
2363    }
2364
2365    #[test]
2366    fn safekey_matches_reference_vectors() {
2367        // include_str! resolves relative to the file containing this macro
2368        // call (crates/doiget-core/src/lib.rs), so we go up three levels
2369        // to reach the workspace root, then down to tests/fixtures.
2370        let raw = include_str!("../../../tests/fixtures/safekey/vectors.json");
2371        let parsed: SafekeyVectorFile =
2372            serde_json::from_str(raw).expect("vectors.json is valid JSON matching schema");
2373
2374        // Phase 0 final ships the full NORMATIVE 100-entry set
2375        // (docs/SAFEKEY.md §5). The fixture is the binding cross-tool
2376        // contract with BiblioFetch.jl; tightening the count guard to
2377        // `== 100` ensures the set cannot silently grow or shrink without
2378        // a coordinated ADR bump (per docs/SAFEKEY.md status block).
2379        assert_eq!(
2380            parsed.vectors.len(),
2381            100,
2382            "vectors.json MUST be exactly 100 entries (NORMATIVE per docs/SAFEKEY.md §5); got {}",
2383            parsed.vectors.len()
2384        );
2385
2386        let mut failures: Vec<String> = Vec::new();
2387        for v in &parsed.vectors {
2388            let r = ref_from_vector_input(&v.input);
2389            let got = r.safekey().as_str().to_string();
2390            if got != v.expected {
2391                failures.push(format!(
2392                    "input={:?}\n  expected={:?}\n  got     ={:?}",
2393                    v.input, v.expected, got
2394                ));
2395            }
2396        }
2397
2398        assert!(
2399            failures.is_empty(),
2400            "{}/{} safekey reference vectors failed:\n{}",
2401            failures.len(),
2402            parsed.vectors.len(),
2403            failures.join("\n")
2404        );
2405    }
2406
2407    #[test]
2408    fn safekey_truncates_long_inputs_with_sha256_suffix() {
2409        // Construct a synthetic DOI whose suffix produces a `trimmed` longer than
2410        // 192 chars after step 3. 220 ASCII-safe chars + the `doi_10.1234/`
2411        // prefix easily exceeds 192. The resulting key must be exactly 201 chars:
2412        // 192 (trimmed prefix) + 1 (`_` separator) + 8 (hex of first 4 bytes of
2413        // SHA-256(raw)). Per docs/SAFEKEY.md §3 step 5.
2414        let suffix = "a".repeat(220);
2415        let doi = Doi(format!("10.1234/{}", suffix));
2416        let key = Ref::Doi(doi).safekey();
2417        let s = key.as_str();
2418
2419        // Shape: <192 ASCII chars from {A-Za-z0-9._-}> + "_" + <8 hex chars>
2420        assert_eq!(
2421            s.len(),
2422            201,
2423            "expected 201-char truncated key, got {}: {}",
2424            s.len(),
2425            s
2426        );
2427        assert_eq!(&s[192..193], "_", "expected '_' separator at byte 192");
2428        let hash_part = &s[193..];
2429        assert_eq!(hash_part.len(), 8, "hash suffix must be 8 hex chars");
2430        assert!(
2431            hash_part
2432                .chars()
2433                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
2434            "hash suffix must be lowercase hex: {}",
2435            hash_part
2436        );
2437
2438        // Determinism: same input twice must produce the same key.
2439        let key2 = Ref::Doi(Doi(format!("10.1234/{}", "a".repeat(220)))).safekey();
2440        assert_eq!(s, key2.as_str(), "safekey must be deterministic");
2441
2442        // Hash content: must equal hex(sha256(raw)[..4]) where raw is the
2443        // pre-escape prefixed form per docs/SAFEKEY.md §3 step 5.
2444        use sha2::Digest;
2445        let raw = format!("doi_10.1234/{}", "a".repeat(220));
2446        let expected_hash = {
2447            let digest = sha2::Sha256::digest(raw.as_bytes());
2448            format!(
2449                "{:02x}{:02x}{:02x}{:02x}",
2450                digest[0], digest[1], digest[2], digest[3]
2451            )
2452        };
2453        assert_eq!(
2454            hash_part, expected_hash,
2455            "hash must match SHA-256 of raw form"
2456        );
2457    }
2458
2459    // -----------------------------------------------------------------
2460    // Doi::parse / ArxivId::parse / Ref::parse — Phase 1 W3-A.
2461    // Spec: docs/SECURITY.md §1.1 (input validation). The rejection
2462    // category set is the binding contract; each test case below names
2463    // which rule it exercises in a comment.
2464    // -----------------------------------------------------------------
2465
2466    // ---- Doi::parse happy paths (≥6) --------------------------------
2467
2468    #[test]
2469    fn doi_parse_accepts_bare_canonical_form() {
2470        // Rule: "10.<registrant>/<suffix>" is the canonical bare form.
2471        let d = Doi::parse("10.1234/example").expect("canonical bare DOI");
2472        assert_eq!(d.as_str(), "10.1234/example");
2473    }
2474
2475    #[test]
2476    fn doi_parse_accepts_doi_uri_scheme() {
2477        // Rule: the `doi:` scheme is stripped at construction; as_str
2478        // never carries it (matches docs/SAFEKEY.md §3 step 0).
2479        let d = Doi::parse("doi:10.1234/example").expect("doi: scheme accepted");
2480        assert_eq!(d.as_str(), "10.1234/example");
2481    }
2482
2483    #[test]
2484    fn doi_parse_accepts_complex_real_world_suffix() {
2485        // Rule: suffix charset includes `.`, `(`, `)`, `-`. From a real
2486        // PhysRevLett DOI used elsewhere in the test fixture set.
2487        let d = Doi::parse("10.1103/PhysRevLett.130.200601").expect("real-world PhysRev DOI");
2488        assert_eq!(d.as_str(), "10.1103/PhysRevLett.130.200601");
2489    }
2490
2491    #[test]
2492    fn doi_parse_accepts_parens_in_suffix() {
2493        // Rule: `(` and `)` are explicitly listed in the spec charset.
2494        let d = Doi::parse("10.1016/S0370-1573(98)00122-3").expect("parens in suffix");
2495        assert_eq!(d.as_str(), "10.1016/S0370-1573(98)00122-3");
2496    }
2497
2498    #[test]
2499    fn doi_parse_accepts_nested_slashes_in_suffix() {
2500        // Rule: `/` is a suffix character; only the first `/` is the
2501        // registrant/suffix separator.
2502        let d = Doi::parse("10.1234/foo/bar/baz").expect("nested slashes");
2503        assert_eq!(d.as_str(), "10.1234/foo/bar/baz");
2504    }
2505
2506    #[test]
2507    fn doi_parse_accepts_colon_in_legacy_kluwer_suffix() {
2508        // #194: legacy Kluwer/Springer DOIs (`10.1023/A:NNNNNNNNNN`)
2509        // carry a `:` in the suffix. Real DOI: "Entanglement, Quantum
2510        // Phase Transitions, and DMRG" (Kluwer, 2002).
2511        let d = Doi::parse("10.1023/A:1019601218492").expect("legacy Kluwer colon DOI");
2512        assert_eq!(d.as_str(), "10.1023/A:1019601218492");
2513    }
2514
2515    #[test]
2516    fn doi_parse_accepts_colon_in_edp_jphys_suffix() {
2517        // #194: EDP Sciences / Journal de Physique legacy corpus uses
2518        // `10.1051/jphys:NNNNNNNNNNNNNNNNN`. Real DOIs from the dogfood
2519        // Ising-RG run; both resolve at doi.org and via Crossref.
2520        let d = Doi::parse("10.1051/jphys:0198900500120136500").expect("EDP jphys colon DOI");
2521        assert_eq!(d.as_str(), "10.1051/jphys:0198900500120136500");
2522        let d2 = Doi::parse("doi:10.1051/jphys:0198500460100164500").expect("scheme + colon");
2523        assert_eq!(d2.as_str(), "10.1051/jphys:0198500460100164500");
2524    }
2525
2526    #[test]
2527    fn doi_parse_rejects_semicolon_in_suffix() {
2528        // #194 / ADR-0026: `;` is the natural ASCII neighbor of `:` and
2529        // is explicitly EXCLUDED from the suffix charset extension
2530        // (ADR-0026 §"Out of scope"). This test guards against an
2531        // over-broad `matches!` arm (e.g. an accidental `':'..=';'` range
2532        // typo) re-admitting `;` along with `:`.
2533        let result = Doi::parse("10.1234/foo;bar");
2534        assert!(
2535            matches!(result, Err(RefParseError::InvalidDoiSuffixChar { ch: ';' })),
2536            "expected InvalidDoiSuffixChar with ch=';', got {:?}",
2537            result
2538        );
2539    }
2540
2541    #[test]
2542    fn doi_parse_accepts_suffix_at_max_len_boundary() {
2543        // Rule: a suffix of exactly DOI_SUFFIX_MAX_LEN bytes is accepted;
2544        // 1 byte more is rejected (covered separately below).
2545        let suffix = "a".repeat(DOI_SUFFIX_MAX_LEN);
2546        let input = format!("10.1234/{}", suffix);
2547        let d = Doi::parse(&input).expect("suffix at max len");
2548        assert_eq!(d.as_str().len(), "10.1234/".len() + DOI_SUFFIX_MAX_LEN);
2549    }
2550
2551    #[test]
2552    fn doi_parse_uri_scheme_is_case_insensitive() {
2553        // Rule: be lenient on scheme casing; the scheme is stripped
2554        // either way so the stored form is identical.
2555        let d = Doi::parse("DOI:10.1234/example").expect("uppercase scheme");
2556        assert_eq!(d.as_str(), "10.1234/example");
2557    }
2558
2559    // ---- Doi::parse rejection paths (≥6) ----------------------------
2560
2561    #[test]
2562    fn doi_parse_rejects_missing_10_prefix() {
2563        // Rule: must start with "10." literal.
2564        assert_eq!(
2565            Doi::parse("11.1234/example"),
2566            Err(RefParseError::MissingDoiPrefix)
2567        );
2568    }
2569
2570    #[test]
2571    fn doi_parse_rejects_empty_input() {
2572        // Rule: empty inputs are not valid DOIs.
2573        assert_eq!(Doi::parse(""), Err(RefParseError::Empty));
2574    }
2575
2576    #[test]
2577    fn doi_parse_rejects_missing_suffix_separator() {
2578        // Rule: must contain a `/` between registrant and suffix.
2579        assert_eq!(
2580            Doi::parse("10.1234"),
2581            Err(RefParseError::MissingDoiSuffixSeparator)
2582        );
2583    }
2584
2585    #[test]
2586    fn doi_parse_rejects_empty_suffix() {
2587        // Rule: suffix must be non-empty.
2588        assert_eq!(Doi::parse("10.1234/"), Err(RefParseError::EmptyDoiSuffix));
2589    }
2590
2591    #[test]
2592    fn doi_parse_rejects_invalid_registrant_too_short() {
2593        // Rule: registrant must be 4–9 digits.
2594        assert_eq!(
2595            Doi::parse("10.12/example"),
2596            Err(RefParseError::InvalidDoiRegistrant)
2597        );
2598    }
2599
2600    #[test]
2601    fn doi_parse_rejects_non_digit_registrant() {
2602        // Rule: registrant chars must all be ASCII digits.
2603        assert_eq!(
2604            Doi::parse("10.12ab/example"),
2605            Err(RefParseError::InvalidDoiRegistrant)
2606        );
2607    }
2608
2609    #[test]
2610    fn doi_parse_rejects_control_char_in_suffix() {
2611        // Rule (from docs/SECURITY.md §1.1, log-injection mitigation):
2612        // control chars are not in the suffix charset; reject before they
2613        // can reach the provenance log.
2614        let result = Doi::parse("10.1234/foo\nbar");
2615        assert!(
2616            matches!(
2617                result,
2618                Err(RefParseError::InvalidDoiSuffixChar { ch: '\n' })
2619            ),
2620            "got {:?}",
2621            result
2622        );
2623    }
2624
2625    #[test]
2626    fn doi_parse_rejects_suffix_over_max_len() {
2627        // Rule: DOI_SUFFIX_MAX_LEN + 1 bytes is rejected.
2628        let suffix = "a".repeat(DOI_SUFFIX_MAX_LEN + 1);
2629        let input = format!("10.1234/{}", suffix);
2630        let result = Doi::parse(&input);
2631        match result {
2632            Err(RefParseError::DoiSuffixTooLong { len, max }) => {
2633                assert_eq!(len, DOI_SUFFIX_MAX_LEN + 1);
2634                assert_eq!(max, DOI_SUFFIX_MAX_LEN);
2635            }
2636            other => panic!("expected DoiSuffixTooLong, got {:?}", other),
2637        }
2638    }
2639
2640    #[test]
2641    fn doi_parse_rejects_non_ascii_in_suffix() {
2642        // Rule: spec charset is ASCII-only; non-ASCII becomes an
2643        // InvalidDoiSuffixChar (consistent with safekey behavior of
2644        // collapsing such chars to '_', which is a downstream concern).
2645        let result = Doi::parse("10.1234/物理学");
2646        assert!(
2647            matches!(result, Err(RefParseError::InvalidDoiSuffixChar { .. })),
2648            "got {:?}",
2649            result
2650        );
2651    }
2652
2653    // ---- ArxivId::parse happy paths (≥6) ----------------------------
2654
2655    #[test]
2656    fn arxiv_parse_accepts_new_style_4_digit_seq() {
2657        // Rule: new-style YYMM.NNNN (4-digit sequence number).
2658        let a = ArxivId::parse("0704.0001").expect("new-style 4-digit seq");
2659        assert_eq!(a.as_str(), "0704.0001");
2660    }
2661
2662    #[test]
2663    fn arxiv_parse_accepts_new_style_5_digit_seq() {
2664        // Rule: new-style YYMM.NNNNN (5-digit sequence number, post-2015).
2665        let a = ArxivId::parse("2401.12345").expect("new-style 5-digit seq");
2666        assert_eq!(a.as_str(), "2401.12345");
2667    }
2668
2669    #[test]
2670    fn arxiv_parse_accepts_new_style_with_version() {
2671        // Rule: optional `vN` version suffix.
2672        let a = ArxivId::parse("2401.12345v2").expect("with version");
2673        assert_eq!(a.as_str(), "2401.12345v2");
2674    }
2675
2676    #[test]
2677    fn arxiv_parse_accepts_old_style() {
2678        // Rule: old-style subject-class/YYMMNNN.
2679        let a = ArxivId::parse("cond-mat/9501001").expect("old-style cond-mat");
2680        assert_eq!(a.as_str(), "cond-mat/9501001");
2681    }
2682
2683    #[test]
2684    fn arxiv_parse_accepts_old_style_with_subclass_and_version() {
2685        // Rule: old-style subject-class may have a `.XX` two-upper subclass
2686        // and an optional `vN` suffix.
2687        let a = ArxivId::parse("astro-ph.CO/0703123v2").expect("old-style with subclass + version");
2688        assert_eq!(a.as_str(), "astro-ph.CO/0703123v2");
2689    }
2690
2691    #[test]
2692    fn arxiv_parse_accepts_arxiv_uri_scheme() {
2693        // Rule: `arxiv:` / `arXiv:` scheme is stripped at construction.
2694        let a = ArxivId::parse("arxiv:2401.12345").expect("arxiv: scheme");
2695        assert_eq!(a.as_str(), "2401.12345");
2696    }
2697
2698    #[test]
2699    fn arxiv_parse_accepts_arxiv_uri_scheme_mixed_case() {
2700        // Rule: scheme case-insensitive; matches the `arXiv:` form named
2701        // in docs/MCP_TOOLS.md.
2702        let a = ArxivId::parse("arXiv:2401.12345v2").expect("arXiv: scheme");
2703        assert_eq!(a.as_str(), "2401.12345v2");
2704    }
2705
2706    // ---- ArxivId::parse rejection paths (≥6) ------------------------
2707
2708    #[test]
2709    fn arxiv_parse_rejects_empty_input() {
2710        // Rule: empty rejected up-front.
2711        assert_eq!(ArxivId::parse(""), Err(RefParseError::Empty));
2712    }
2713
2714    #[test]
2715    fn arxiv_parse_rejects_no_dot_or_slash() {
2716        // Rule: must contain `.` (new-style) or `/` (old-style).
2717        assert_eq!(
2718            ArxivId::parse("notanarxivid"),
2719            Err(RefParseError::InvalidArxivShape)
2720        );
2721    }
2722
2723    #[test]
2724    fn arxiv_parse_rejects_new_style_wrong_head_length() {
2725        // Rule: head must be exactly 4 digits.
2726        assert_eq!(
2727            ArxivId::parse("240.12345"),
2728            Err(RefParseError::InvalidArxivShape)
2729        );
2730    }
2731
2732    #[test]
2733    fn arxiv_parse_rejects_new_style_seq_too_short() {
2734        // Rule: seq must be 4–5 digits.
2735        assert_eq!(
2736            ArxivId::parse("2401.123"),
2737            Err(RefParseError::InvalidArxivShape)
2738        );
2739    }
2740
2741    #[test]
2742    fn arxiv_parse_rejects_old_style_wrong_id_length() {
2743        // Rule: old-style id is exactly 7 digits.
2744        assert_eq!(
2745            ArxivId::parse("cond-mat/95001"),
2746            Err(RefParseError::InvalidArxivShape)
2747        );
2748    }
2749
2750    #[test]
2751    fn arxiv_parse_rejects_invalid_version_suffix() {
2752        // Rule: version suffix is `v` followed by ≥1 digits, nothing else.
2753        assert_eq!(
2754            ArxivId::parse("2401.12345v"),
2755            Err(RefParseError::InvalidArxivShape)
2756        );
2757    }
2758
2759    #[test]
2760    fn arxiv_parse_rejects_control_char() {
2761        // Rule (docs/SECURITY.md §1.1 log-injection): no control chars.
2762        assert_eq!(
2763            ArxivId::parse("2401.12345\n"),
2764            Err(RefParseError::InvalidArxivShape)
2765        );
2766    }
2767
2768    #[test]
2769    fn arxiv_parse_rejects_non_ascii() {
2770        // Rule: ASCII-only.
2771        assert_eq!(
2772            ArxivId::parse("2401.物理"),
2773            Err(RefParseError::InvalidArxivShape)
2774        );
2775    }
2776
2777    // ---- Ref::parse happy paths (≥6) --------------------------------
2778
2779    #[test]
2780    fn ref_parse_dispatches_doi_scheme_to_doi() {
2781        // Detection rule 1: explicit `doi:` scheme.
2782        match Ref::parse("doi:10.1234/example").expect("doi: dispatched to Doi") {
2783            Ref::Doi(d) => assert_eq!(d.as_str(), "10.1234/example"),
2784            other => panic!("expected Ref::Doi, got {:?}", other),
2785        }
2786    }
2787
2788    #[test]
2789    fn ref_parse_dispatches_arxiv_scheme_to_arxiv() {
2790        // Detection rule 2: explicit `arxiv:` scheme.
2791        match Ref::parse("arxiv:2401.12345").expect("arxiv: dispatched to Arxiv") {
2792            Ref::Arxiv(a) => assert_eq!(a.as_str(), "2401.12345"),
2793            other => panic!("expected Ref::Arxiv, got {:?}", other),
2794        }
2795    }
2796
2797    #[test]
2798    fn ref_parse_dispatches_arxiv_mixed_case_scheme() {
2799        // Detection rule 2 (case-insensitive): `arXiv:` form.
2800        match Ref::parse("arXiv:cond-mat/9501001").expect("arXiv: dispatched") {
2801            Ref::Arxiv(a) => assert_eq!(a.as_str(), "cond-mat/9501001"),
2802            other => panic!("expected Ref::Arxiv, got {:?}", other),
2803        }
2804    }
2805
2806    #[test]
2807    fn ref_parse_bare_doi_resolves_to_doi() {
2808        // Detection rule 3: bare input starting with `10.` is a DOI.
2809        match Ref::parse("10.1234/foo").expect("bare DOI") {
2810            Ref::Doi(d) => assert_eq!(d.as_str(), "10.1234/foo"),
2811            other => panic!("expected Ref::Doi, got {:?}", other),
2812        }
2813    }
2814
2815    #[test]
2816    fn ref_parse_bare_arxiv_new_resolves_to_arxiv() {
2817        // Detection rule 4: bare input not starting with `10.` falls
2818        // through to arXiv. Tests the ambiguous-input branch named in the
2819        // PR brief: `2401.12345` should resolve to ArxivId.
2820        match Ref::parse("2401.12345").expect("bare new-style arXiv") {
2821            Ref::Arxiv(a) => assert_eq!(a.as_str(), "2401.12345"),
2822            other => panic!("expected Ref::Arxiv, got {:?}", other),
2823        }
2824    }
2825
2826    #[test]
2827    fn ref_parse_bare_arxiv_old_resolves_to_arxiv() {
2828        // Detection rule 4: bare old-style arXiv id.
2829        match Ref::parse("cond-mat/9501001").expect("bare old-style arXiv") {
2830            Ref::Arxiv(a) => assert_eq!(a.as_str(), "cond-mat/9501001"),
2831            other => panic!("expected Ref::Arxiv, got {:?}", other),
2832        }
2833    }
2834
2835    // ---- Ref::parse rejection paths (≥6) ----------------------------
2836
2837    #[test]
2838    fn ref_parse_rejects_empty() {
2839        // Rule: empty up-front.
2840        assert_eq!(Ref::parse(""), Err(RefParseError::Empty));
2841    }
2842
2843    #[test]
2844    fn ref_parse_doi_scheme_with_invalid_doi_propagates_doi_error() {
2845        // When the scheme is explicit, we surface the parser's error
2846        // verbatim — not a generic "shape mismatch".
2847        assert_eq!(
2848            Ref::parse("doi:10.1234"),
2849            Err(RefParseError::MissingDoiSuffixSeparator)
2850        );
2851    }
2852
2853    #[test]
2854    fn ref_parse_arxiv_scheme_with_invalid_arxiv_propagates_arxiv_error() {
2855        assert_eq!(
2856            Ref::parse("arxiv:notanid"),
2857            Err(RefParseError::InvalidArxivShape)
2858        );
2859    }
2860
2861    #[test]
2862    fn ref_parse_bare_with_10_prefix_uses_doi_errors() {
2863        // Bare `10.…` heuristic: DOI parser is dispatched and its error
2864        // surfaces (here: bad registrant).
2865        assert_eq!(
2866            Ref::parse("10.12/x"),
2867            Err(RefParseError::InvalidDoiRegistrant)
2868        );
2869    }
2870
2871    #[test]
2872    fn ref_parse_bare_without_10_prefix_reports_neither_shape() {
2873        // The comment on this test always said the right thing -- "`1.2.3`
2874        // is neither a DOI nor an arXiv shape" -- while the assertion said
2875        // `InvalidArxivShape`, which is the fallback parser's verdict
2876        // rather than the truth about the input (#477). Someone who
2877        // mistyped a DOI was told about arXiv id shapes.
2878        assert_eq!(Ref::parse("1.2.3"), Err(RefParseError::UnrecognisedShape));
2879    }
2880
2881    #[test]
2882    fn an_explicit_arxiv_scheme_still_reports_the_arxiv_shape_error() {
2883        // The narrowing in #477 applies ONLY to the ambiguous fall-through.
2884        // When the caller declared `arxiv:`, the arXiv parser's verdict IS
2885        // the truth about the input, and generalising it there would lose
2886        // information rather than gain it.
2887        assert_eq!(
2888            Ref::parse("arxiv:1.2.3"),
2889            Err(RefParseError::InvalidArxivShape)
2890        );
2891    }
2892
2893    #[test]
2894    fn ref_parse_rejects_doi_scheme_with_oversized_suffix() {
2895        // Length-bound: DOI suffix > DOI_SUFFIX_MAX_LEN through Ref::parse
2896        // surfaces DoiSuffixTooLong, not a generic InvalidArxivShape.
2897        let suffix = "a".repeat(DOI_SUFFIX_MAX_LEN + 5);
2898        let input = format!("doi:10.1234/{}", suffix);
2899        match Ref::parse(&input) {
2900            Err(RefParseError::DoiSuffixTooLong { .. }) => {}
2901            other => panic!("expected DoiSuffixTooLong, got {:?}", other),
2902        }
2903    }
2904
2905    #[test]
2906    fn ref_parse_round_trip_via_serde_preserves_inner_string() {
2907        // Wire-format check: Doi/ArxivId are #[serde(transparent)], and a
2908        // round-trip through Ref::parse → serde_json → Ref must preserve
2909        // the inner identifier. Guards against accidental scheme leakage
2910        // into the stored form.
2911        let r = Ref::parse("doi:10.1234/example").expect("parse ok");
2912        let json = serde_json::to_string(&r).expect("serialize");
2913        // The transparent inner value is the bare identifier (no `doi:`).
2914        assert!(
2915            json.contains("10.1234/example") && !json.contains("doi:"),
2916            "scheme leaked into wire form: {}",
2917            json
2918        );
2919    }
2920
2921    #[test]
2922    fn ref_parse_error_maps_to_invalid_ref_error_code() {
2923        // Public-API contract (docs/PUBLIC_API.md §4): all parse failures
2924        // collapse to ErrorCode::InvalidRef at the public boundary.
2925        let err: ErrorCode = RefParseError::Empty.into();
2926        assert_eq!(err, ErrorCode::InvalidRef);
2927        let err2: ErrorCode = RefParseError::MissingDoiPrefix.into();
2928        assert_eq!(err2, ErrorCode::InvalidRef);
2929    }
2930
2931    // -----------------------------------------------------------------
2932    // DenialReason / DenialContext (ADR-0023) — wire-shape tests.
2933    // -----------------------------------------------------------------
2934
2935    #[test]
2936    fn denial_reason_serializes_snake_case() {
2937        // ADR-0023 §2 / docs/PUBLIC_API.md §8: wire form is snake_case.
2938        let s = serde_json::to_string(&DenialReason::RedirectNotInAllowlist).expect("ser");
2939        assert_eq!(s, "\"redirect_not_in_allowlist\"");
2940        let s = serde_json::to_string(&DenialReason::SizeCapExceeded).expect("ser");
2941        assert_eq!(s, "\"size_cap_exceeded\"");
2942        let s = serde_json::to_string(&DenialReason::ContentTypeMismatch).expect("ser");
2943        assert_eq!(s, "\"content_type_mismatch\"");
2944    }
2945
2946    #[test]
2947    fn denial_reason_round_trip_via_serde() {
2948        // Round-trip every closed-set variant so adding a new variant
2949        // forces this test to be updated (the closed-set contract).
2950        for r in [
2951            DenialReason::RedirectNotInAllowlist,
2952            DenialReason::InsecureScheme,
2953            DenialReason::HostInBlockList,
2954            DenialReason::SizeCapExceeded,
2955            DenialReason::SchemaDrift,
2956            DenialReason::CapabilityNotGranted,
2957            DenialReason::RateLimitWindow,
2958            DenialReason::SsrfPrivateAddress,
2959            DenialReason::ContentTypeMismatch,
2960        ] {
2961            let s = serde_json::to_string(&r).expect("ser");
2962            let back: DenialReason = serde_json::from_str(&s).expect("de");
2963            assert_eq!(back, r, "round-trip mismatch for {:?} -> {}", r, s);
2964        }
2965    }
2966
2967    #[test]
2968    fn denial_context_round_trips_full_shape() {
2969        // A populated context (the redirect-denied case from ADR-0023 §1
2970        // example) survives a JSON round-trip. Whole-struct equality
2971        // exercises the `PartialEq` derive added per ADR-0023 §3 (added
2972        // in the multi-agent review feedback PR — see ADR-0023 history).
2973        let dc = DenialContext {
2974            reason: DenialReason::RedirectNotInAllowlist,
2975            source: Some("crossref".to_string()),
2976            attempted: Some("evil.example.com".to_string()),
2977            expected: Some(vec![
2978                "api.crossref.org".to_string(),
2979                "*.crossref.org".to_string(),
2980            ]),
2981            hop_index: Some(1),
2982            cap: None,
2983            actual: None,
2984        };
2985        let s = serde_json::to_string(&dc).expect("ser");
2986        let back: DenialContext = serde_json::from_str(&s).expect("de");
2987        assert_eq!(back, dc);
2988    }
2989
2990    #[test]
2991    fn denial_context_serialize_elides_empty_fields() {
2992        // `skip_serializing_if = "Option::is_none"` must keep the wire form
2993        // lean: every `None` field MUST NOT appear on the wire. Reason is
2994        // always present.
2995        let dc = DenialContext {
2996            reason: DenialReason::CapabilityNotGranted,
2997            source: None,
2998            attempted: None,
2999            expected: None,
3000            hop_index: None,
3001            cap: None,
3002            actual: None,
3003        };
3004        let s = serde_json::to_string(&dc).expect("ser");
3005        assert_eq!(s, "{\"reason\":\"capability_not_granted\"}");
3006    }
3007
3008    #[test]
3009    fn denial_context_expected_some_empty_vec_preserves_explicit_empty_allowlist() {
3010        // Post-refinement disambiguation: `expected: Some(vec![])` is the
3011        // "explicit empty allowlist" signal and MUST survive the wire as
3012        // `"expected":[]`. Only `expected: None` is skipped on serialize.
3013        // This is the bug the previous `Vec<String>` shape masked.
3014        let dc = DenialContext {
3015            reason: DenialReason::RedirectNotInAllowlist,
3016            source: Some("crossref".to_string()),
3017            attempted: Some("evil.example.com".to_string()),
3018            expected: Some(Vec::new()),
3019            hop_index: None,
3020            cap: None,
3021            actual: None,
3022        };
3023        let s = serde_json::to_string(&dc).expect("ser");
3024        assert!(
3025            s.contains("\"expected\":[]"),
3026            "expected:[] must survive on the wire (got: {s})"
3027        );
3028        let back: DenialContext = serde_json::from_str(&s).expect("de");
3029        assert_eq!(back.expected, Some(Vec::new()));
3030    }
3031
3032    #[test]
3033    fn denial_context_deserialize_tolerates_missing_optional_fields() {
3034        // Consumer-side contract (ADR-0023 §3): consumers MUST tolerate
3035        // any subset of fields being present. Missing optional fields
3036        // deserialize to their defaults via `#[serde(default)]`.
3037        let wire = r#"{"reason":"size_cap_exceeded","cap":104857600,"actual":209715200}"#;
3038        let dc: DenialContext = serde_json::from_str(wire).expect("de");
3039        assert_eq!(dc.reason, DenialReason::SizeCapExceeded);
3040        assert_eq!(dc.cap, Some(104857600));
3041        assert_eq!(dc.actual, Some(209715200));
3042        assert!(dc.source.is_none());
3043        assert!(dc.attempted.is_none());
3044        assert!(dc.expected.is_none());
3045        assert!(dc.hop_index.is_none());
3046    }
3047
3048    #[test]
3049    fn full_error_envelope_with_denial_context_serializes_to_pinned_json() {
3050        // Pins the byte-exact wire shape of the full failure envelope
3051        // documented in docs/ERRORS.md §3 + §3.1 and ADR-0023 §1. A
3052        // future regression that flips key order or skip-rules anywhere
3053        // in the chain breaks this test loudly.
3054        //
3055        // Note: serde_json's `Map` (used by `json!`) sorts keys
3056        // alphabetically when the `preserve_order` feature is NOT
3057        // enabled (we do not enable it). Embedding a `DenialContext`
3058        // via `json!` first re-serialises it through the same alphabet-
3059        // sorted Map path, so the inner field order is also alphabetical
3060        // here — NOT the struct field-order produced by direct
3061        // `to_string(&DenialContext)`. This is by design: the public
3062        // wire shape is canonicalised by serde_json's Map ordering, so
3063        // the byte-exact pin below documents that exact canonicalisation.
3064        let denial = DenialContext {
3065            reason: DenialReason::RedirectNotInAllowlist,
3066            source: Some("crossref".into()),
3067            attempted: Some("evil.example.com".into()),
3068            expected: Some(vec!["api.crossref.org".into(), "*.crossref.org".into()]),
3069            hop_index: Some(1),
3070            cap: None,
3071            actual: None,
3072        };
3073        let envelope = serde_json::json!({
3074            "ok": false,
3075            "error": {
3076                "code": ErrorCode::NetworkError,
3077                "message": "redirect target evil.example.com not in allowlist for source crossref",
3078                "denial_context": denial,
3079            }
3080        });
3081        let actual = serde_json::to_string(&envelope).expect("serialize envelope");
3082        let expected = r#"{"error":{"code":"NETWORK_ERROR","denial_context":{"attempted":"evil.example.com","expected":["api.crossref.org","*.crossref.org"],"hop_index":1,"reason":"redirect_not_in_allowlist","source":"crossref"},"message":"redirect target evil.example.com not in allowlist for source crossref"},"ok":false}"#;
3083        assert_eq!(actual, expected);
3084    }
3085
3086    #[test]
3087    fn denial_context_rejects_unknown_fields() {
3088        // `#[serde(deny_unknown_fields)]` (ADR-0023 §3, PUBLIC_API.md §8):
3089        // an unknown field on the wire MUST be a deserialize error so
3090        // forward-compat field additions stay a breaking change.
3091        let wire = r#"{"reason":"capability_not_granted","banana":1}"#;
3092        let result: Result<DenialContext, _> = serde_json::from_str(wire);
3093        assert!(
3094            result.is_err(),
3095            "deny_unknown_fields must reject 'banana': {:?}",
3096            result.map(|d| d.reason),
3097        );
3098    }
3099}