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