Skip to main content

doiget_core/
source.rs

1//! Source abstraction. Each Tier 1/2/3 fetcher implements this trait.
2//!
3//! Binding spec: `docs/PUBLIC_API.md` §2 (trait surface),
4//! `docs/ARCHITECTURE.md` §6 (per-fetch data flow), and
5//! `docs/PROVENANCE_LOG.md` §3 (the `Fetch` row source impls emit).
6//!
7//! Phase 1 ships the trait + supporting types; concrete impls (Crossref,
8//! Unpaywall, arXiv) land in follow-up PRs (see `docs/SOURCES.md` for the
9//! source matrix and tiering).
10
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use bytes::Bytes;
15use thiserror::Error;
16
17use crate::http::{HttpClient, HttpError};
18use crate::provenance::{LogError, ProvenanceLog};
19use crate::rate_limiter::RateLimiter;
20use crate::{CapabilityProfile, Ref, RefParseError};
21
22/// What a successful fetch returns to the caller.
23///
24/// Whether `pdf_bytes` is `None` depends on the source: metadata-only
25/// sources (Phase 4) leave it unset; OA sources (Phase 1) return PDF bytes
26/// when an OA URL was discovered.
27#[derive(Debug, Clone)]
28#[non_exhaustive]
29pub struct FetchResult {
30    /// Source's name (matches `Source::name()`); set for the audit trail.
31    pub source: String,
32    /// OA license string (`"CC-BY-4.0"`, `"unknown"`, etc.).
33    pub license: String,
34    /// PDF bytes; `None` for metadata-only sources.
35    pub pdf_bytes: Option<Bytes>,
36    /// Final URL after redirect resolution; useful for the metadata
37    /// `[doiget].url` field.
38    pub final_url: Option<url::Url>,
39    /// Source-side metadata payload as a serde_json value. The Source impl
40    /// is responsible for the shape; the caller (Phase 1+ orchestrator)
41    /// maps it into `Metadata` when one exists (Phase 1+).
42    pub metadata_json: Option<serde_json::Value>,
43}
44
45/// Per-fetch context shared by all `Source` impls.
46///
47/// Held by the orchestrator (CLI / MCP server) and passed by reference into
48/// each [`Source::fetch`]. Sources MUST NOT construct their own
49/// [`HttpClient`] / [`RateLimiter`] / [`ProvenanceLog`] — they go through
50/// this context for uniform politeness, redirect allowlisting, and audit
51/// logging.
52#[derive(Clone)]
53pub struct FetchContext {
54    /// Shared, allowlist-aware HTTP client. See [`HttpClient`].
55    pub http: Arc<HttpClient>,
56    /// Process-wide async rate limiter. See [`RateLimiter`].
57    pub rate_limiter: Arc<RateLimiter>,
58    /// Append-only, hash-chained provenance log. Source impls MUST emit
59    /// one `LogEvent::Fetch` row per attempt via `log.append`. See
60    /// [`ProvenanceLog`].
61    pub log: Arc<ProvenanceLog>,
62    /// 26-char ULID identifying this process invocation. Mirrors the
63    /// `session_id` stamped into every provenance row by the writer; held
64    /// here so source impls can include it in their own structured logs
65    /// without re-reading the env.
66    pub session_id: String,
67    /// Resolver cache root (`<cache_root>/resolver/<safekey>.toml`, see
68    /// `docs/CACHE.md` and [`crate::resolver_cache`]). `Some` enables the
69    /// metadata-only resolve cache (repeat resolves served from disk,
70    /// avoiding upstream rate limits); `None` disables it (tests, or a
71    /// caller that opts out). Only `metadata_only` consults it — per-PDF
72    /// fetches are never cached.
73    pub cache_root: Option<camino::Utf8PathBuf>,
74}
75
76impl std::fmt::Debug for FetchContext {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        // Avoid printing the full HTTP / rate-limiter / log internals; only
79        // the session_id is human-meaningful for log breadcrumbs.
80        f.debug_struct("FetchContext")
81            .field("session_id", &self.session_id)
82            .finish_non_exhaustive()
83    }
84}
85
86/// Errors returned by [`Source::fetch`].
87///
88/// At the public CLI / MCP boundary, every variant collapses to an
89/// [`crate::ErrorCode`] via the `From<FetchError>` impl below — mirroring
90/// the [`RefParseError`] → [`crate::ErrorCode::InvalidRef`] collapse from
91/// PR #55.
92#[derive(Debug, Error)]
93#[non_exhaustive]
94pub enum FetchError {
95    /// The source does not handle the given ref under the runtime
96    /// capability profile (covers both `can_serve = false` outcomes and
97    /// runtime denials raised inside `fetch`).
98    #[error("source {source_key} cannot serve this ref")]
99    NotEligible {
100        /// The source key that declined.
101        source_key: String,
102    },
103    /// Tier 1 sources reported no OA URL for this ref.
104    #[error("Tier 1 sources reported no OA URL for this ref")]
105    NoOaAvailable,
106    /// A metadata source authoritatively reported that the identifier does
107    /// not exist — distinct from a transport failure. Surfaces as
108    /// [`crate::ErrorCode::NotFound`]. Used for sources whose
109    /// "absent" signal is NOT an HTTP 404/410 (e.g. the arXiv Atom API
110    /// returns HTTP 200 with an empty `<feed>` for an unknown id).
111    #[error("identifier not found: {hint}")]
112    NotFound {
113        /// Human-readable detail (which source, and how it signalled
114        /// absence); not parsed.
115        hint: String,
116    },
117    /// A name filter (author / venue / publisher) matched MORE than one
118    /// OpenAlex entity with no clear winner. Carries a candidate listing
119    /// so the caller can narrow the name (or pass an explicit id).
120    /// Collapses to [`crate::ErrorCode::Ambiguous`] (wire `"AMBIGUOUS"`) —
121    /// distinct from `NotFound` so an agent narrows rather than gives up.
122    /// Used by [`crate::discovery`].
123    #[error("{hint}")]
124    Ambiguous {
125        /// Human-readable candidate listing; not parsed.
126        hint: String,
127    },
128    /// Underlying HTTP / network failure. See [`HttpError`].
129    #[error("network error: {0}")]
130    Http(#[from] HttpError),
131    /// Provenance log write failed. Per `docs/SECURITY.md` §1.8 this is a
132    /// fail-closed signal; the surrounding fetch MUST be aborted.
133    #[error("provenance log error: {0}")]
134    Log(#[from] LogError),
135    /// Ref re-parse / validation failed inside the source (e.g. when a
136    /// source receives a borrowed string from upstream and re-validates).
137    #[error("invalid ref: {0}")]
138    InvalidRef(#[from] RefParseError),
139    /// A source found the record and **cannot supply a copy** — an access
140    /// refusal, not a failure.
141    ///
142    /// The distinction is the whole point: "the source has it and cannot
143    /// give it to us" and "the source broke" lead an operator to different
144    /// conclusions, and only the second is a bug to chase.
145    ///
146    /// This exists as a variant because it used to be a *substring search*.
147    /// A refusal was [`Self::SourceSchema`] with an explanatory hint, and
148    /// `orchestrator::is_access_refusal` read the hint back looking for
149    /// "not open access" / "openAccess" / "no retrievable PDF". #503
150    /// reworded Europe PMC's refusal for good reasons, the hint fell out of
151    /// that list, and every Europe PMC refusal silently became
152    /// `AttemptOutcome::Failed`. Nothing in the source said the wording was
153    /// load-bearing, and `hal` matched on `openAccess` — a JSON *field
154    /// name*, not prose anyone chose (#538).
155    ///
156    /// Collapses to [`crate::ErrorCode::NoOaAvailable`], which is an
157    /// EXISTING wire code: "found it, no free copy" is exactly what that
158    /// means, so the closed set in `docs/ERRORS.md` §3 does not widen. See
159    /// ADR-0054.
160    #[error("{source_key} has the record but no retrievable copy: {detail}")]
161    NotRetrievable {
162        /// Which source refused.
163        source_key: String,
164        /// Why, in the source's own terms — the flags or codes a reader
165        /// checks next. Displayed, never parsed: that is the point.
166        detail: String,
167    },
168    /// Source-side schema mismatch (unexpected JSON shape, missing
169    /// required field). Surfaces to [`crate::ErrorCode::InternalError`]
170    /// at the public boundary.
171    #[error("source-side schema error: {hint}")]
172    SourceSchema {
173        /// Human-readable hint at the offending field/path; not parsed.
174        hint: String,
175    },
176    /// Batch orchestrator received more refs than
177    /// [`crate::MAX_BATCH_REFS`]. Surfaced to the MCP `doiget_batch_fetch`
178    /// tool as `ErrorCode::InvalidRef` (closest closed-set fit — the
179    /// request shape itself is invalid; no `denial_context` channel
180    /// applies). Slice 2 / `docs/MCP_TOOLS.md` §1.
181    #[error("too many refs: got {got}, max {max}")]
182    TooManyRefs {
183        /// Number of refs the batch orchestrator was handed.
184        got: usize,
185        /// The hard cap ([`crate::MAX_BATCH_REFS`]).
186        max: usize,
187    },
188    /// A source returned a successful response that contained no usable
189    /// representation of the requested kind — currently `doiget text`'s
190    /// ar5iv leg returning a 200 with no extractable prose (the paper was
191    /// never converted to HTML). The identifier is valid; only this one
192    /// representation is missing. Surfaces as
193    /// [`crate::ErrorCode::TextUnavailable`] so an agent fetches the PDF
194    /// instead of concluding the reference is wrong (issue #302) — NOT
195    /// [`Self::NotFound`], which means the id itself does not exist.
196    #[error(
197        "no readable text for arXiv:{arxiv_id} (no ar5iv HTML render); \
198         the PDF may be fetchable instead"
199    )]
200    TextUnavailable {
201        /// The arXiv id whose ar5iv render was empty; echoed into the
202        /// human/MCP message so the actionable `doiget fetch <id>` hint is
203        /// self-contained. A validated [`crate::ArxivId`] (review #318) —
204        /// the id was already parsed, so the error cannot carry a malformed
205        /// string into the actionable `doiget fetch <id>` hint.
206        arxiv_id: crate::ArxivId,
207    },
208    /// A source returned a successful response that contained no file of the
209    /// requested kind for `doiget source` — a PDF-only / single-file
210    /// submission (no multi-file bundle), or `--figures-only` on a submission
211    /// with no image files. The identifier is valid; only the bundle / figure
212    /// representation is absent. Surfaces as
213    /// [`crate::ErrorCode::TextUnavailable`] (same "this representation is
214    /// missing; the PDF may be fetchable" class as [`Self::TextUnavailable`]),
215    /// but as a DISTINCT variant so the message is not ar5iv-specific
216    /// (issue #343 / ADR-0034; PR review).
217    #[error("no source files for arXiv:{arxiv_id} ({kind}); the PDF may be fetchable instead")]
218    SourceUnavailable {
219        /// The arXiv id whose source bundle / figures were absent.
220        arxiv_id: crate::ArxivId,
221        /// Which representation was requested: `"source bundle"` or `"figures"`.
222        kind: &'static str,
223    },
224    /// This session already asked about the ref and got an answer no retry
225    /// can change yet (#507, ADR-0057), so it was not asked again. Carries
226    /// the code the caller was given then -- or `RATE_LIMITED` when the
227    /// earlier answer was `retry_after` and its wait has not elapsed.
228    #[error("{message}")]
229    Replayed {
230        /// The code to report: the earlier answer's, or `RATE_LIMITED`.
231        code: crate::ErrorCode,
232        /// What happened and how to ask anyway.
233        message: String,
234        /// For a wait still running: seconds until a retry is let through.
235        retry_after_secs: Option<u64>,
236    },
237}
238
239/// Map [`FetchError`] to the closed [`crate::ErrorCode`] set surfaced at
240/// the public CLI / MCP boundary. Mirrors the
241/// `From<RefParseError> for ErrorCode` collapse from PR #55.
242impl From<FetchError> for crate::ErrorCode {
243    fn from(e: FetchError) -> crate::ErrorCode {
244        crate::ErrorCode::from(&e)
245    }
246}
247
248/// Borrow-form of the collapse above, so a caller that still needs the
249/// error for its `Display` message / `denial_context` side-channel
250/// (notably the CLI human-persona renderer, issue #119) can obtain the
251/// closed code without consuming it. The owned impl delegates here so
252/// the mapping table lives in exactly one place.
253impl From<&FetchError> for crate::ErrorCode {
254    fn from(e: &FetchError) -> crate::ErrorCode {
255        match e {
256            FetchError::NotEligible { .. } => crate::ErrorCode::CapabilityDenied,
257            FetchError::NoOaAvailable => crate::ErrorCode::NoOaAvailable,
258            FetchError::NotFound { .. } => crate::ErrorCode::NotFound,
259            // A name filter that matched several entities is its own wire
260            // code so agents can distinguish "narrow the name" from
261            // "does not exist" (ADR-0031 D5).
262            FetchError::Ambiguous { .. } => crate::ErrorCode::Ambiguous,
263            // 404 / 410 / 451 are authoritative "this id does not exist"
264            // signals → `NotFound` (not retriable). 401 / 403 mean the
265            // server understood the request but denied access (IP block, auth
266            // required) — `CapabilityDenied` lets agents distinguish access
267            // denial from a transient connectivity failure. Everything else
268            // is treated as transient.
269            FetchError::Http(HttpError::HttpStatus {
270                status: 404 | 410 | 451,
271                ..
272            }) => crate::ErrorCode::NotFound,
273            FetchError::Http(HttpError::HttpStatus {
274                status: 401 | 403, ..
275            }) => crate::ErrorCode::CapabilityDenied,
276            // Exhaustive over `HttpError`, not `Http(_)`. The wildcard sent
277            // six deterministic outcomes to `NETWORK_ERROR`, whose disposition
278            // is `retry_after` -- so an agent was told to back off and retry an
279            // allowlist refusal, an http:// downgrade, a size cap, a
280            // wrong content type, an unregistered source key and a malformed
281            // header, none of which a retry can change. That is the defect
282            // ADR-0055 exists to remove, in the mapping every surface routes
283            // through. The `DenialContext` impl 100 lines down already matches
284            // all eight variants; this one opted out of the same protection.
285            FetchError::Http(e) => match e {
286                // Policy decisions. Settled until the configuration changes,
287                // which is what `needs_config` means -- and each of these
288                // carries a `DenialContext` naming the fix.
289                HttpError::RedirectDenied { .. } | HttpError::InsecureRedirect { .. } => {
290                    crate::ErrorCode::CapabilityDenied
291                }
292                // The response arrived and was not what was asked for.
293                // Re-requesting returns the same bytes.
294                HttpError::OversizedBody { .. } | HttpError::NotAPdf { .. } => {
295                    crate::ErrorCode::NoOaAvailable
296                }
297                // The caller asked for a source the client was never given.
298                // A build/wiring fault, not the network (#454, #462).
299                HttpError::UnknownSource { .. } | HttpError::InvalidHeader { .. } => {
300                    crate::ErrorCode::InternalError
301                }
302                // Genuinely transient: transport failures, and the statuses
303                // the arms above did not claim.
304                HttpError::Network(_) | HttpError::HttpStatus { .. } => {
305                    crate::ErrorCode::NetworkError
306                }
307            },
308            FetchError::Log(_) => crate::ErrorCode::LogError,
309            FetchError::InvalidRef(_) => crate::ErrorCode::InvalidRef,
310            // An access refusal is not an internal error. Before #538 it
311            // was reported as one, because it travelled as `SourceSchema`.
312            FetchError::NotRetrievable { .. } => crate::ErrorCode::NoOaAvailable,
313            FetchError::SourceSchema { .. } => crate::ErrorCode::InternalError,
314            // Slice 2: a too-large batch is a request-shape failure, so
315            // collapse to `INVALID_REF` (closest closed-set fit). The
316            // `#[non_exhaustive]` wildcard below would otherwise route
317            // it to `INTERNAL_ERROR`, which would mislead agents.
318            FetchError::TooManyRefs { .. } => crate::ErrorCode::InvalidRef,
319            // The id resolved; only the ar5iv text representation is
320            // missing. Its own code so an agent fetches the PDF rather
321            // than conclude the reference is wrong (issue #302).
322            FetchError::TextUnavailable { .. } => crate::ErrorCode::TextUnavailable,
323            // The id resolved; only the source-bundle / figure representation
324            // is absent. Same wire code as TextUnavailable (representation
325            // missing → fetch the PDF), distinct variant for a correct message.
326            FetchError::SourceUnavailable { .. } => crate::ErrorCode::TextUnavailable,
327            // A replay reports the answer it replays, so an agent reads the
328            // same disposition it was given the first time (#507).
329            FetchError::Replayed { code, .. } => *code,
330        }
331    }
332}
333
334/// Map a [`FetchError`] reference to the structured [`crate::DenialContext`]
335/// channel introduced by ADR-0023 §4.
336///
337/// `&FetchError` (rather than `FetchError`) so the orchestrator can
338/// produce the structured side-channel without consuming the error it
339/// still needs for `error.message` and the `From<FetchError> for
340/// ErrorCode` collapse above. The `Http` arm delegates to the
341/// `From<&HttpError> for Option<DenialContext>` impl in [`crate::http`].
342/// The server's own `Retry-After` for this failure, in milliseconds (#506).
343///
344/// `None` when the server sent no header, which is most failures. Deliberately
345/// NOT backfilled from doiget's internal backoff: that is a guess about the
346/// server, and a guess wearing the name of a server-supplied value is exactly
347/// the defect `error.disposition` and this field exist to remove.
348///
349/// Pairs with [`crate::Disposition::RetryAfter`] -- the disposition says
350/// "retry", and this says how long the server asked you to wait before you do.
351#[must_use]
352pub fn retry_after_ms(e: &FetchError) -> Option<u64> {
353    match e {
354        FetchError::Http(HttpError::HttpStatus { retry_after_ms, .. }) => *retry_after_ms,
355        // #507: not a guess either -- the wait repeat suppression enforces,
356        // measured from the answer it is enforcing.
357        FetchError::Replayed {
358            retry_after_secs, ..
359        } => retry_after_secs.map(|s| s * 1000),
360        _ => None,
361    }
362}
363
364/// Whether `e` is a replay of an answer this session already gave (#507),
365/// which the envelopes mark with `replayed: true`.
366#[must_use]
367pub fn is_replayed(e: &FetchError) -> bool {
368    matches!(e, FetchError::Replayed { .. })
369}
370
371impl From<&FetchError> for Option<crate::DenialContext> {
372    fn from(e: &FetchError) -> Self {
373        use crate::{DenialContext, DenialReason};
374        match e {
375            FetchError::NotEligible { source_key } => Some(DenialContext {
376                reason: DenialReason::CapabilityNotGranted,
377                source: Some(source_key.clone()),
378                attempted: None,
379                // CapabilityNotGranted has no allowlist channel: the
380                // producer leaves `expected` at `None` (NOT `Some(vec![])`).
381                // See `DenialContext::expected` for the disambiguation.
382                expected: None,
383                hop_index: None,
384                cap: None,
385                actual: None,
386            }),
387            // Delegate to the HttpError mapping (ADR-0023 §4 mapping table).
388            FetchError::Http(http_err) => http_err.into(),
389            // Non-denial variants map to None per ADR-0023 §4. (Slice 2:
390            // `TooManyRefs` is a request-shape failure, not a denial —
391            // adding it to the None arm keeps the mapping table consistent.)
392            FetchError::NoOaAvailable
393            // #538: a source refusing because the work is not open there is
394            // NOT a denial in the ADR-0023 sense. Nothing was withheld by
395            // policy, so there is no capability to grant and no allowlist to
396            // widen -- a `DenialContext` would send a reader after a
397            // configuration change that does not exist.
398            | FetchError::NotRetrievable { .. }
399            | FetchError::NotFound { .. }
400            | FetchError::Ambiguous { .. }
401            | FetchError::Log(_)
402            | FetchError::InvalidRef(_)
403            | FetchError::SourceSchema { .. }
404            | FetchError::TooManyRefs { .. }
405            | FetchError::TextUnavailable { .. }
406            | FetchError::SourceUnavailable { .. }
407            | FetchError::Replayed { .. } => None,
408        }
409    }
410}
411
412/// The trait implemented by every Tier 1 / 2 / 3 fetcher.
413///
414/// Binding signature: `docs/PUBLIC_API.md` §2 (NORMATIVE — the wire shape
415/// of these three methods is semver-locked).
416#[async_trait]
417pub trait Source: Send + Sync {
418    /// Stable name used in metadata (`[doiget].source`) and provenance
419    /// rows. Conventional values: `"crossref"`, `"unpaywall"`, `"arxiv"`,
420    /// `"openalex"`, `"semantic-scholar"`, `"doaj"`, `"tdm-elsevier"`,
421    /// etc. (see `docs/SOURCES.md`).
422    fn name(&self) -> &str;
423
424    /// True if this source can plausibly serve the given ref under the
425    /// runtime capability profile. Implementations MUST be fast and
426    /// non-blocking; the orchestrator calls `can_serve` to decide whether
427    /// to invoke `fetch` at all.
428    fn can_serve(&self, profile: &CapabilityProfile, ref_: &Ref) -> bool;
429
430    /// Perform the source-specific fetch.
431    ///
432    /// Implementations:
433    ///   1. acquire `ctx.rate_limiter.acquire(self.name()).await`,
434    ///   2. fetch via `ctx.http.fetch_bytes` / `ctx.http.fetch_pdf`,
435    ///   3. emit one `LogEvent::Fetch` row via `ctx.log.append`,
436    ///   4. return a [`FetchResult`].
437    ///
438    /// The trait does NOT enforce these steps; it documents the protocol
439    /// so concrete impls produce uniform audit trails (per
440    /// `docs/ARCHITECTURE.md` §6 and `docs/PROVENANCE_LOG.md` §3).
441    async fn fetch(
442        &self,
443        ref_: &Ref,
444        profile: &CapabilityProfile,
445        ctx: &FetchContext,
446    ) -> Result<FetchResult, FetchError>;
447
448    /// Fetch the publisher's own copy of the document itself, when this
449    /// source holds one.
450    ///
451    /// Distinct from [`Self::fetch`], which resolves a *record*. A Tier-3
452    /// TDM source is consulted for two different reasons at two different
453    /// points in the fetch, and conflating them is what #458 was:
454    ///
455    /// - [`fetch`](Self::fetch) answers "who can tell me about this DOI?"
456    ///   and runs when Crossref could not;
457    /// - `fetch_content` answers "who will give me the bytes?" and runs
458    ///   when the content leg was blocked — which is usually *after*
459    ///   Crossref answered perfectly well.
460    ///
461    /// The default is `Ok(None)`: "this source is metadata-only". Stating
462    /// it is the point. Before #458 the same fact was expressed by every
463    /// Tier-3 impl setting `FetchResult.pdf_bytes` to `None` and saying so
464    /// in a doc-comment, which the orchestrator could neither read nor act
465    /// on — so it could not tell a source that had nothing to offer from
466    /// one it had simply never asked.
467    ///
468    /// Implementations that override it MUST use a PDF-validating fetch
469    /// ([`HttpClient::fetch_pdf`] or
470    /// [`HttpClient::fetch_pdf_with_headers`]). A publisher error page or
471    /// a WAF holding response is a 200 with a body, and storing one under
472    /// `<safekey>.pdf` would be worse than returning nothing.
473    ///
474    /// # Errors
475    ///
476    /// Any [`FetchError`]. `Ok(None)` means "not me"; `Err` means "me, and
477    /// it went wrong". The orchestrator keeps the original content-leg
478    /// block either way, but records the two as different attempt
479    /// outcomes.
480    async fn fetch_content(
481        &self,
482        _ref_: &Ref,
483        _profile: &CapabilityProfile,
484        _ctx: &FetchContext,
485    ) -> Result<Option<Bytes>, FetchError> {
486        Ok(None)
487    }
488}
489
490// ---------------------------------------------------------------------------
491// Tests
492// ---------------------------------------------------------------------------
493
494#[cfg(test)]
495#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
496mod tests {
497    use super::*;
498
499    use camino::Utf8PathBuf;
500    use tempfile::TempDir;
501
502    use crate::http::{tier_1_allowlist, HttpClient};
503    use crate::provenance::ProvenanceLog;
504    use crate::rate_limiter::RateLimiter;
505    use crate::{CapabilityProfile, Doi, ErrorCode, RateLimits, Ref};
506
507    /// Minimal `Source` impl exercised purely to pin the trait shape and
508    /// verify dispatch through `Box<dyn Source>`. Concrete sources land in
509    /// follow-up PRs (Crossref / Unpaywall / arXiv).
510    struct MockSource;
511
512    #[async_trait]
513    impl Source for MockSource {
514        fn name(&self) -> &str {
515            "mock"
516        }
517        fn can_serve(&self, _: &CapabilityProfile, _: &Ref) -> bool {
518            true
519        }
520        async fn fetch(
521            &self,
522            _: &Ref,
523            _: &CapabilityProfile,
524            _: &FetchContext,
525        ) -> Result<FetchResult, FetchError> {
526            Ok(FetchResult {
527                source: "mock".into(),
528                license: "unknown".into(),
529                pdf_bytes: None,
530                final_url: None,
531                metadata_json: None,
532            })
533        }
534    }
535
536    /// Build a `FetchContext` backed by real (but inert) Round-A
537    /// foundation modules: a `HttpClient` over the Tier-1 allowlist, a
538    /// `RateLimiter` at hard-coded politeness, and a `ProvenanceLog` in
539    /// a tempdir. Returns the dir as well so the caller keeps it alive
540    /// for the duration of the test.
541    fn build_test_context() -> (TempDir, FetchContext) {
542        let td = TempDir::new().expect("tempdir");
543        // Workspace lints ban `std::path::PathBuf` for log paths; convert
544        // via camino's `Utf8PathBuf::try_from`.
545        let log_dir =
546            Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
547        let log_path = log_dir.join("test.jsonl");
548
549        let http = Arc::new(HttpClient::new(tier_1_allowlist()).expect("http client builds"));
550        let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
551        let session_id = "01J0000000000000000000TEST".to_string();
552        let log = Arc::new(
553            ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
554        );
555
556        (
557            td,
558            FetchContext {
559                http,
560                rate_limiter,
561                log,
562                session_id,
563                cache_root: None,
564            },
565        )
566    }
567
568    #[tokio::test]
569    async fn mock_source_compiles_as_trait_object() {
570        // Trait-shape pin: a `Source` impl is dyn-safe and can be boxed.
571        let s: Box<dyn Source> = Box::new(MockSource);
572        assert_eq!(s.name(), "mock");
573        let profile = CapabilityProfile::for_tests();
574        let r = Ref::Doi(Doi("10.1234/example".to_string()));
575        assert!(s.can_serve(&profile, &r));
576
577        let (_td, ctx) = build_test_context();
578        let res = s.fetch(&r, &profile, &ctx).await.expect("fetch ok");
579        assert_eq!(res.source, "mock");
580    }
581
582    #[tokio::test]
583    async fn mock_source_fetch_returns_result() {
584        // Direct dispatch (not through `dyn`) to exercise the async fn
585        // body and assert the populated FetchResult fields.
586        let s = MockSource;
587        let profile = CapabilityProfile::for_tests();
588        let r = Ref::Doi(Doi("10.1234/example".to_string()));
589        let (_td, ctx) = build_test_context();
590
591        let res = s.fetch(&r, &profile, &ctx).await.expect("fetch ok");
592        assert_eq!(res.source, "mock");
593        assert_eq!(res.license, "unknown");
594        assert!(res.pdf_bytes.is_none());
595        assert!(res.final_url.is_none());
596        assert!(res.metadata_json.is_none());
597    }
598
599    /// A deterministic HTTP outcome must not be advertised as retriable.
600    ///
601    /// `FetchError::Http(_) => NetworkError` was a wildcard over all eight
602    /// `HttpError` variants, and `NetworkError`'s disposition is
603    /// `retry_after`. Six of them cannot change on a retry, so the mapping
604    /// every surface routes through was telling agents to back off and try
605    /// again on an allowlist refusal, a size cap and an unregistered source
606    /// key -- the exact advice ADR-0055 exists to stop giving.
607    #[test]
608    fn a_deterministic_http_failure_is_not_advertised_as_retriable() {
609        let cases: Vec<(HttpError, crate::Disposition)> = vec![
610            (
611                HttpError::RedirectDenied {
612                    source_key: "oa-publisher".into(),
613                    host: "evil.example.com".into(),
614                    expected_hosts: vec!["*.wiley.com".to_string()],
615                },
616                crate::Disposition::NeedsConfig,
617            ),
618            (
619                HttpError::UnknownSource {
620                    source_key: "tdm-aps".into(),
621                },
622                crate::Disposition::Terminal,
623            ),
624        ];
625        for (he, want) in cases {
626            let code: ErrorCode = FetchError::Http(he).into();
627            assert_ne!(
628                code,
629                ErrorCode::NetworkError,
630                "a policy/wiring outcome is not a network error: {code:?}"
631            );
632            assert_eq!(
633                code.disposition(),
634                want,
635                "and its disposition must not say retry_after: {code:?}"
636            );
637        }
638    }
639
640    /// The transient ones keep saying retry, so the fix did not overshoot.
641    #[test]
642    fn a_transient_http_failure_still_says_retry() {
643        let code: ErrorCode = FetchError::Http(HttpError::HttpStatus {
644            status: 503,
645            retry_after_ms: None,
646            url: "https://api.crossref.org/works/10.5555/x".into(),
647        })
648        .into();
649        assert_eq!(code, ErrorCode::NetworkError);
650        assert_eq!(code.disposition(), crate::Disposition::RetryAfter);
651    }
652
653    #[test]
654    fn fetch_error_collapses_to_error_code() {
655        // Mirrors `docs/PUBLIC_API.md` §4 / PR #55 boundary collapse.
656        // Each variant must map to its documented code.
657        let e: ErrorCode = FetchError::NotEligible {
658            source_key: "mock".into(),
659        }
660        .into();
661        assert_eq!(e, ErrorCode::CapabilityDenied);
662
663        let e: ErrorCode = FetchError::NoOaAvailable.into();
664        assert_eq!(e, ErrorCode::NoOaAvailable);
665
666        // `UnknownSource` is "the caller asked HttpClient to fetch for a
667        // source it was never given" -- a wiring fault. This asserted
668        // `NetworkError` because the mapping used to be `Http(_) =>
669        // NetworkError`, i.e. it pinned the wildcard rather than a decision:
670        // retrying cannot register a missing source, and `NetworkError`'s
671        // `retry_after` disposition told an agent to try anyway. It is the
672        // error #462's TDM reproduction actually hit, and calling it a network
673        // problem is part of why it read as one.
674        let e: ErrorCode = FetchError::Http(HttpError::UnknownSource {
675            source_key: "mock".into(),
676        })
677        .into();
678        assert_eq!(e, ErrorCode::InternalError);
679        assert_eq!(e.disposition(), crate::Disposition::Terminal);
680
681        // 404 / 410 / 451 from a metadata source are authoritative "id does
682        // not exist" → NotFound (network-independent), NOT NetworkError.
683        for status in [404u16, 410, 451] {
684            let e: ErrorCode = FetchError::Http(HttpError::HttpStatus {
685                status,
686                retry_after_ms: None,
687                url: "https://api.crossref.org/works/10.5555/absent".into(),
688            })
689            .into();
690            assert_eq!(
691                e,
692                ErrorCode::NotFound,
693                "status {status} should map to NotFound"
694            );
695        }
696        // ...and a `Retry-After` on that response does not change it. #506
697        // added `retry_after_ms` to this variant, and the arm above briefly
698        // matched `retry_after_ms: None`, which silently sent a 404 carrying
699        // the header to `NETWORK_ERROR` -- disposition `retry_after` -- so an
700        // agent was told to retry a DOI that will never resolve. The header
701        // says how long to wait IF you retry; it does not make an
702        // authoritative absence provisional.
703        for status in [404u16, 410, 451] {
704            let e: ErrorCode = FetchError::Http(HttpError::HttpStatus {
705                status,
706                retry_after_ms: Some(30_000),
707                url: "https://api.crossref.org/works/10.5555/absent".into(),
708            })
709            .into();
710            assert_eq!(
711                e,
712                ErrorCode::NotFound,
713                "status {status} with Retry-After is still NotFound"
714            );
715            assert_eq!(
716                e.disposition(),
717                crate::Disposition::Terminal,
718                "and stays terminal, so nothing tells the agent to retry it"
719            );
720        }
721        // A non-HTTP authoritative absence (e.g. arXiv's empty Atom feed)
722        // also maps to NotFound.
723        let e: ErrorCode = FetchError::NotFound {
724            hint: "arxiv empty feed".into(),
725        }
726        .into();
727        assert_eq!(e, ErrorCode::NotFound);
728        // A transient upstream status (e.g. 503) stays NetworkError so
729        // `doiget verify` tolerates it rather than failing a live id.
730        let e: ErrorCode = FetchError::Http(HttpError::HttpStatus {
731            status: 503,
732            retry_after_ms: None,
733            url: "https://api.crossref.org/works/10.5555/down".into(),
734        })
735        .into();
736        assert_eq!(e, ErrorCode::NetworkError);
737
738        let e: ErrorCode = FetchError::Log(LogError::Io(std::io::Error::other("synthetic"))).into();
739        assert_eq!(e, ErrorCode::LogError);
740
741        let e: ErrorCode = FetchError::InvalidRef(RefParseError::Empty).into();
742        assert_eq!(e, ErrorCode::InvalidRef);
743
744        let e: ErrorCode = FetchError::SourceSchema {
745            hint: "missing field 'license'".into(),
746        }
747        .into();
748        assert_eq!(e, ErrorCode::InternalError);
749
750        // Slice 2 — TooManyRefs collapses to INVALID_REF, NOT
751        // InternalError (the `#[non_exhaustive]` wildcard would
752        // otherwise misroute this to InternalError).
753        let e: ErrorCode = FetchError::TooManyRefs { got: 101, max: 100 }.into();
754        assert_eq!(e, ErrorCode::InvalidRef);
755
756        // #343 / ADR-0034 — SourceUnavailable shares the TextUnavailable wire
757        // code (representation missing; the PDF may be fetchable), distinct
758        // variant for a non-ar5iv message.
759        let arxiv = match Ref::parse("arxiv:2401.12345").expect("parse arxiv id") {
760            Ref::Arxiv(a) => a,
761            Ref::Doi(_) => unreachable!("parsed an arxiv id"),
762        };
763        let e: ErrorCode = FetchError::SourceUnavailable {
764            arxiv_id: arxiv,
765            kind: "figures",
766        }
767        .into();
768        assert_eq!(e, ErrorCode::TextUnavailable);
769    }
770
771    #[test]
772    fn fetch_context_debug_redacts_internals() {
773        // Pin the Debug shape — only `session_id` is printed, the rest is
774        // elided. Prevents accidental log leakage when a context is
775        // included in a `tracing::debug!` event.
776        let (_td, ctx) = build_test_context();
777        let s = format!("{:?}", ctx);
778        assert!(
779            s.contains("session_id"),
780            "session_id must be in Debug: {}",
781            s
782        );
783        assert!(s.contains("01J0000000000000000000TEST"));
784        assert!(
785            !s.contains("HttpClient") && !s.contains("RateLimiter") && !s.contains("ProvenanceLog"),
786            "FetchContext Debug must not dump foundation internals: {}",
787            s,
788        );
789    }
790
791    // ---------------------------------------------------------------
792    // FetchError -> Option<DenialContext>  (ADR-0023 §4)
793    // ---------------------------------------------------------------
794
795    #[test]
796    fn denial_from_not_eligible_carries_source_key() {
797        use crate::{DenialContext, DenialReason};
798        let e = FetchError::NotEligible {
799            source_key: "tdm-elsevier".to_string(),
800        };
801        let dc: Option<DenialContext> = (&e).into();
802        let dc = dc.expect("NotEligible -> Some(DenialContext)");
803        assert_eq!(dc.reason, DenialReason::CapabilityNotGranted);
804        assert_eq!(dc.source.as_deref(), Some("tdm-elsevier"));
805        assert!(dc.attempted.is_none());
806        // Post-refinement: `expected: None` ("producer did not populate")
807        // rather than `Some(vec![])` ("explicit empty allowlist"). See
808        // `DenialContext::expected` field doc for the disambiguation.
809        assert!(dc.expected.is_none());
810    }
811
812    #[test]
813    fn denial_from_http_delegates_to_http_mapping() {
814        use crate::http::HttpError;
815        use crate::{DenialContext, DenialReason, PDF_MAX_BYTES};
816        // The Http arm must delegate to the HttpError mapping rather than
817        // reinventing it, so an OversizedBody surfaces with cap/actual
818        // populated and the SizeCapExceeded reason — proving delegation
819        // works without per-variant duplication.
820        let e = FetchError::Http(HttpError::OversizedBody {
821            actual: 209_715_200,
822            cap: PDF_MAX_BYTES,
823        });
824        let dc: Option<DenialContext> = (&e).into();
825        let dc = dc.expect("Http(OversizedBody) -> Some(DenialContext)");
826        assert_eq!(dc.reason, DenialReason::SizeCapExceeded);
827        assert_eq!(dc.cap, Some(PDF_MAX_BYTES));
828        assert_eq!(dc.actual, Some(209_715_200));
829    }
830
831    #[test]
832    fn denial_from_non_denial_variants_returns_none() {
833        use crate::DenialContext;
834        // Each of the four non-denial FetchError arms maps to None per
835        // ADR-0023 §4.
836        let e = FetchError::NoOaAvailable;
837        let dc: Option<DenialContext> = (&e).into();
838        assert!(dc.is_none(), "NoOaAvailable must not produce DenialContext");
839
840        let e = FetchError::Log(LogError::Io(std::io::Error::other("synthetic")));
841        let dc: Option<DenialContext> = (&e).into();
842        assert!(dc.is_none(), "Log must not produce DenialContext");
843
844        let e = FetchError::InvalidRef(RefParseError::Empty);
845        let dc: Option<DenialContext> = (&e).into();
846        assert!(dc.is_none(), "InvalidRef must not produce DenialContext");
847
848        let e = FetchError::SourceSchema {
849            hint: "missing field 'license'".into(),
850        };
851        let dc: Option<DenialContext> = (&e).into();
852        assert!(dc.is_none(), "SourceSchema must not produce DenialContext");
853    }
854}