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 /// Source-side schema mismatch (unexpected JSON shape, missing
140 /// required field). Surfaces to [`crate::ErrorCode::InternalError`]
141 /// at the public boundary.
142 #[error("source-side schema error: {hint}")]
143 SourceSchema {
144 /// Human-readable hint at the offending field/path; not parsed.
145 hint: String,
146 },
147 /// Batch orchestrator received more refs than
148 /// [`crate::MAX_BATCH_REFS`]. Surfaced to the MCP `doiget_batch_fetch`
149 /// tool as `ErrorCode::InvalidRef` (closest closed-set fit — the
150 /// request shape itself is invalid; no `denial_context` channel
151 /// applies). Slice 2 / `docs/MCP_TOOLS.md` §1.
152 #[error("too many refs: got {got}, max {max}")]
153 TooManyRefs {
154 /// Number of refs the batch orchestrator was handed.
155 got: usize,
156 /// The hard cap ([`crate::MAX_BATCH_REFS`]).
157 max: usize,
158 },
159 /// A source returned a successful response that contained no usable
160 /// representation of the requested kind — currently `doiget text`'s
161 /// ar5iv leg returning a 200 with no extractable prose (the paper was
162 /// never converted to HTML). The identifier is valid; only this one
163 /// representation is missing. Surfaces as
164 /// [`crate::ErrorCode::TextUnavailable`] so an agent fetches the PDF
165 /// instead of concluding the reference is wrong (issue #302) — NOT
166 /// [`Self::NotFound`], which means the id itself does not exist.
167 #[error(
168 "no readable text for arXiv:{arxiv_id} (no ar5iv HTML render); \
169 the PDF may be fetchable instead"
170 )]
171 TextUnavailable {
172 /// The arXiv id whose ar5iv render was empty; echoed into the
173 /// human/MCP message so the actionable `doiget fetch <id>` hint is
174 /// self-contained. A validated [`crate::ArxivId`] (review #318) —
175 /// the id was already parsed, so the error cannot carry a malformed
176 /// string into the actionable `doiget fetch <id>` hint.
177 arxiv_id: crate::ArxivId,
178 },
179 /// A source returned a successful response that contained no file of the
180 /// requested kind for `doiget source` — a PDF-only / single-file
181 /// submission (no multi-file bundle), or `--figures-only` on a submission
182 /// with no image files. The identifier is valid; only the bundle / figure
183 /// representation is absent. Surfaces as
184 /// [`crate::ErrorCode::TextUnavailable`] (same "this representation is
185 /// missing; the PDF may be fetchable" class as [`Self::TextUnavailable`]),
186 /// but as a DISTINCT variant so the message is not ar5iv-specific
187 /// (issue #343 / ADR-0034; PR review).
188 #[error("no source files for arXiv:{arxiv_id} ({kind}); the PDF may be fetchable instead")]
189 SourceUnavailable {
190 /// The arXiv id whose source bundle / figures were absent.
191 arxiv_id: crate::ArxivId,
192 /// Which representation was requested: `"source bundle"` or `"figures"`.
193 kind: &'static str,
194 },
195}
196
197/// Map [`FetchError`] to the closed [`crate::ErrorCode`] set surfaced at
198/// the public CLI / MCP boundary. Mirrors the
199/// `From<RefParseError> for ErrorCode` collapse from PR #55.
200impl From<FetchError> for crate::ErrorCode {
201 fn from(e: FetchError) -> crate::ErrorCode {
202 crate::ErrorCode::from(&e)
203 }
204}
205
206/// Borrow-form of the collapse above, so a caller that still needs the
207/// error for its `Display` message / `denial_context` side-channel
208/// (notably the CLI human-persona renderer, issue #119) can obtain the
209/// closed code without consuming it. The owned impl delegates here so
210/// the mapping table lives in exactly one place.
211impl From<&FetchError> for crate::ErrorCode {
212 fn from(e: &FetchError) -> crate::ErrorCode {
213 match e {
214 FetchError::NotEligible { .. } => crate::ErrorCode::CapabilityDenied,
215 FetchError::NoOaAvailable => crate::ErrorCode::NoOaAvailable,
216 FetchError::NotFound { .. } => crate::ErrorCode::NotFound,
217 // A name filter that matched several entities is its own wire
218 // code so agents can distinguish "narrow the name" from
219 // "does not exist" (ADR-0031 D5).
220 FetchError::Ambiguous { .. } => crate::ErrorCode::Ambiguous,
221 // 404 / 410 / 451 are authoritative "this id does not exist"
222 // signals → `NotFound` (not retriable). 401 / 403 mean the
223 // server understood the request but denied access (IP block, auth
224 // required) — `CapabilityDenied` lets agents distinguish access
225 // denial from a transient connectivity failure. Everything else
226 // is treated as transient.
227 FetchError::Http(HttpError::HttpStatus {
228 status: 404 | 410 | 451,
229 ..
230 }) => crate::ErrorCode::NotFound,
231 FetchError::Http(HttpError::HttpStatus {
232 status: 401 | 403, ..
233 }) => crate::ErrorCode::CapabilityDenied,
234 FetchError::Http(_) => crate::ErrorCode::NetworkError,
235 FetchError::Log(_) => crate::ErrorCode::LogError,
236 FetchError::InvalidRef(_) => crate::ErrorCode::InvalidRef,
237 FetchError::SourceSchema { .. } => crate::ErrorCode::InternalError,
238 // Slice 2: a too-large batch is a request-shape failure, so
239 // collapse to `INVALID_REF` (closest closed-set fit). The
240 // `#[non_exhaustive]` wildcard below would otherwise route
241 // it to `INTERNAL_ERROR`, which would mislead agents.
242 FetchError::TooManyRefs { .. } => crate::ErrorCode::InvalidRef,
243 // The id resolved; only the ar5iv text representation is
244 // missing. Its own code so an agent fetches the PDF rather
245 // than conclude the reference is wrong (issue #302).
246 FetchError::TextUnavailable { .. } => crate::ErrorCode::TextUnavailable,
247 // The id resolved; only the source-bundle / figure representation
248 // is absent. Same wire code as TextUnavailable (representation
249 // missing → fetch the PDF), distinct variant for a correct message.
250 FetchError::SourceUnavailable { .. } => crate::ErrorCode::TextUnavailable,
251 }
252 }
253}
254
255/// Map a [`FetchError`] reference to the structured [`crate::DenialContext`]
256/// channel introduced by ADR-0023 §4.
257///
258/// `&FetchError` (rather than `FetchError`) so the orchestrator can
259/// produce the structured side-channel without consuming the error it
260/// still needs for `error.message` and the `From<FetchError> for
261/// ErrorCode` collapse above. The `Http` arm delegates to the
262/// `From<&HttpError> for Option<DenialContext>` impl in [`crate::http`].
263impl From<&FetchError> for Option<crate::DenialContext> {
264 fn from(e: &FetchError) -> Self {
265 use crate::{DenialContext, DenialReason};
266 match e {
267 FetchError::NotEligible { source_key } => Some(DenialContext {
268 reason: DenialReason::CapabilityNotGranted,
269 source: Some(source_key.clone()),
270 attempted: None,
271 // CapabilityNotGranted has no allowlist channel: the
272 // producer leaves `expected` at `None` (NOT `Some(vec![])`).
273 // See `DenialContext::expected` for the disambiguation.
274 expected: None,
275 hop_index: None,
276 cap: None,
277 actual: None,
278 }),
279 // Delegate to the HttpError mapping (ADR-0023 §4 mapping table).
280 FetchError::Http(http_err) => http_err.into(),
281 // Non-denial variants map to None per ADR-0023 §4. (Slice 2:
282 // `TooManyRefs` is a request-shape failure, not a denial —
283 // adding it to the None arm keeps the mapping table consistent.)
284 FetchError::NoOaAvailable
285 | FetchError::NotFound { .. }
286 | FetchError::Ambiguous { .. }
287 | FetchError::Log(_)
288 | FetchError::InvalidRef(_)
289 | FetchError::SourceSchema { .. }
290 | FetchError::TooManyRefs { .. }
291 | FetchError::TextUnavailable { .. }
292 | FetchError::SourceUnavailable { .. } => None,
293 }
294 }
295}
296
297/// The trait implemented by every Tier 1 / 2 / 3 fetcher.
298///
299/// Binding signature: `docs/PUBLIC_API.md` §2 (NORMATIVE — the wire shape
300/// of these three methods is semver-locked).
301#[async_trait]
302pub trait Source: Send + Sync {
303 /// Stable name used in metadata (`[doiget].source`) and provenance
304 /// rows. Conventional values: `"crossref"`, `"unpaywall"`, `"arxiv"`,
305 /// `"openalex"`, `"semantic-scholar"`, `"doaj"`, `"tdm-elsevier"`,
306 /// etc. (see `docs/SOURCES.md`).
307 fn name(&self) -> &str;
308
309 /// True if this source can plausibly serve the given ref under the
310 /// runtime capability profile. Implementations MUST be fast and
311 /// non-blocking; the orchestrator calls `can_serve` to decide whether
312 /// to invoke `fetch` at all.
313 fn can_serve(&self, profile: &CapabilityProfile, ref_: &Ref) -> bool;
314
315 /// Perform the source-specific fetch.
316 ///
317 /// Implementations:
318 /// 1. acquire `ctx.rate_limiter.acquire(self.name()).await`,
319 /// 2. fetch via `ctx.http.fetch_bytes` / `ctx.http.fetch_pdf`,
320 /// 3. emit one `LogEvent::Fetch` row via `ctx.log.append`,
321 /// 4. return a [`FetchResult`].
322 ///
323 /// The trait does NOT enforce these steps; it documents the protocol
324 /// so concrete impls produce uniform audit trails (per
325 /// `docs/ARCHITECTURE.md` §6 and `docs/PROVENANCE_LOG.md` §3).
326 async fn fetch(
327 &self,
328 ref_: &Ref,
329 profile: &CapabilityProfile,
330 ctx: &FetchContext,
331 ) -> Result<FetchResult, FetchError>;
332
333 /// Fetch the publisher's own copy of the document itself, when this
334 /// source holds one.
335 ///
336 /// Distinct from [`Self::fetch`], which resolves a *record*. A Tier-3
337 /// TDM source is consulted for two different reasons at two different
338 /// points in the fetch, and conflating them is what #458 was:
339 ///
340 /// - [`fetch`](Self::fetch) answers "who can tell me about this DOI?"
341 /// and runs when Crossref could not;
342 /// - `fetch_content` answers "who will give me the bytes?" and runs
343 /// when the content leg was blocked — which is usually *after*
344 /// Crossref answered perfectly well.
345 ///
346 /// The default is `Ok(None)`: "this source is metadata-only". Stating
347 /// it is the point. Before #458 the same fact was expressed by every
348 /// Tier-3 impl setting `FetchResult.pdf_bytes` to `None` and saying so
349 /// in a doc-comment, which the orchestrator could neither read nor act
350 /// on — so it could not tell a source that had nothing to offer from
351 /// one it had simply never asked.
352 ///
353 /// Implementations that override it MUST use a PDF-validating fetch
354 /// ([`HttpClient::fetch_pdf`] or
355 /// [`HttpClient::fetch_pdf_with_headers`]). A publisher error page or
356 /// a WAF holding response is a 200 with a body, and storing one under
357 /// `<safekey>.pdf` would be worse than returning nothing.
358 ///
359 /// # Errors
360 ///
361 /// Any [`FetchError`]. `Ok(None)` means "not me"; `Err` means "me, and
362 /// it went wrong". The orchestrator keeps the original content-leg
363 /// block either way, but records the two as different attempt
364 /// outcomes.
365 async fn fetch_content(
366 &self,
367 _ref_: &Ref,
368 _profile: &CapabilityProfile,
369 _ctx: &FetchContext,
370 ) -> Result<Option<Bytes>, FetchError> {
371 Ok(None)
372 }
373}
374
375// ---------------------------------------------------------------------------
376// Tests
377// ---------------------------------------------------------------------------
378
379#[cfg(test)]
380#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
381mod tests {
382 use super::*;
383
384 use camino::Utf8PathBuf;
385 use tempfile::TempDir;
386
387 use crate::http::{tier_1_allowlist, HttpClient};
388 use crate::provenance::ProvenanceLog;
389 use crate::rate_limiter::RateLimiter;
390 use crate::{CapabilityProfile, Doi, ErrorCode, RateLimits, Ref};
391
392 /// Minimal `Source` impl exercised purely to pin the trait shape and
393 /// verify dispatch through `Box<dyn Source>`. Concrete sources land in
394 /// follow-up PRs (Crossref / Unpaywall / arXiv).
395 struct MockSource;
396
397 #[async_trait]
398 impl Source for MockSource {
399 fn name(&self) -> &str {
400 "mock"
401 }
402 fn can_serve(&self, _: &CapabilityProfile, _: &Ref) -> bool {
403 true
404 }
405 async fn fetch(
406 &self,
407 _: &Ref,
408 _: &CapabilityProfile,
409 _: &FetchContext,
410 ) -> Result<FetchResult, FetchError> {
411 Ok(FetchResult {
412 source: "mock".into(),
413 license: "unknown".into(),
414 pdf_bytes: None,
415 final_url: None,
416 metadata_json: None,
417 })
418 }
419 }
420
421 /// Build a `FetchContext` backed by real (but inert) Round-A
422 /// foundation modules: a `HttpClient` over the Tier-1 allowlist, a
423 /// `RateLimiter` at hard-coded politeness, and a `ProvenanceLog` in
424 /// a tempdir. Returns the dir as well so the caller keeps it alive
425 /// for the duration of the test.
426 fn build_test_context() -> (TempDir, FetchContext) {
427 let td = TempDir::new().expect("tempdir");
428 // Workspace lints ban `std::path::PathBuf` for log paths; convert
429 // via camino's `Utf8PathBuf::try_from`.
430 let log_dir =
431 Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
432 let log_path = log_dir.join("test.jsonl");
433
434 let http = Arc::new(HttpClient::new(tier_1_allowlist()).expect("http client builds"));
435 let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
436 let session_id = "01J0000000000000000000TEST".to_string();
437 let log = Arc::new(
438 ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
439 );
440
441 (
442 td,
443 FetchContext {
444 http,
445 rate_limiter,
446 log,
447 session_id,
448 cache_root: None,
449 },
450 )
451 }
452
453 #[tokio::test]
454 async fn mock_source_compiles_as_trait_object() {
455 // Trait-shape pin: a `Source` impl is dyn-safe and can be boxed.
456 let s: Box<dyn Source> = Box::new(MockSource);
457 assert_eq!(s.name(), "mock");
458 let profile = CapabilityProfile::for_tests();
459 let r = Ref::Doi(Doi("10.1234/example".to_string()));
460 assert!(s.can_serve(&profile, &r));
461
462 let (_td, ctx) = build_test_context();
463 let res = s.fetch(&r, &profile, &ctx).await.expect("fetch ok");
464 assert_eq!(res.source, "mock");
465 }
466
467 #[tokio::test]
468 async fn mock_source_fetch_returns_result() {
469 // Direct dispatch (not through `dyn`) to exercise the async fn
470 // body and assert the populated FetchResult fields.
471 let s = MockSource;
472 let profile = CapabilityProfile::for_tests();
473 let r = Ref::Doi(Doi("10.1234/example".to_string()));
474 let (_td, ctx) = build_test_context();
475
476 let res = s.fetch(&r, &profile, &ctx).await.expect("fetch ok");
477 assert_eq!(res.source, "mock");
478 assert_eq!(res.license, "unknown");
479 assert!(res.pdf_bytes.is_none());
480 assert!(res.final_url.is_none());
481 assert!(res.metadata_json.is_none());
482 }
483
484 #[test]
485 fn fetch_error_collapses_to_error_code() {
486 // Mirrors `docs/PUBLIC_API.md` §4 / PR #55 boundary collapse.
487 // Each variant must map to its documented code.
488 let e: ErrorCode = FetchError::NotEligible {
489 source_key: "mock".into(),
490 }
491 .into();
492 assert_eq!(e, ErrorCode::CapabilityDenied);
493
494 let e: ErrorCode = FetchError::NoOaAvailable.into();
495 assert_eq!(e, ErrorCode::NoOaAvailable);
496
497 let e: ErrorCode = FetchError::Http(HttpError::UnknownSource {
498 source_key: "mock".into(),
499 })
500 .into();
501 assert_eq!(e, ErrorCode::NetworkError);
502
503 // 404 / 410 / 451 from a metadata source are authoritative "id does
504 // not exist" → NotFound (network-independent), NOT NetworkError.
505 for status in [404u16, 410, 451] {
506 let e: ErrorCode = FetchError::Http(HttpError::HttpStatus {
507 status,
508 url: "https://api.crossref.org/works/10.5555/absent".into(),
509 })
510 .into();
511 assert_eq!(
512 e,
513 ErrorCode::NotFound,
514 "status {status} should map to NotFound"
515 );
516 }
517 // A non-HTTP authoritative absence (e.g. arXiv's empty Atom feed)
518 // also maps to NotFound.
519 let e: ErrorCode = FetchError::NotFound {
520 hint: "arxiv empty feed".into(),
521 }
522 .into();
523 assert_eq!(e, ErrorCode::NotFound);
524 // A transient upstream status (e.g. 503) stays NetworkError so
525 // `doiget verify` tolerates it rather than failing a live id.
526 let e: ErrorCode = FetchError::Http(HttpError::HttpStatus {
527 status: 503,
528 url: "https://api.crossref.org/works/10.5555/down".into(),
529 })
530 .into();
531 assert_eq!(e, ErrorCode::NetworkError);
532
533 let e: ErrorCode = FetchError::Log(LogError::Io(std::io::Error::other("synthetic"))).into();
534 assert_eq!(e, ErrorCode::LogError);
535
536 let e: ErrorCode = FetchError::InvalidRef(RefParseError::Empty).into();
537 assert_eq!(e, ErrorCode::InvalidRef);
538
539 let e: ErrorCode = FetchError::SourceSchema {
540 hint: "missing field 'license'".into(),
541 }
542 .into();
543 assert_eq!(e, ErrorCode::InternalError);
544
545 // Slice 2 — TooManyRefs collapses to INVALID_REF, NOT
546 // InternalError (the `#[non_exhaustive]` wildcard would
547 // otherwise misroute this to InternalError).
548 let e: ErrorCode = FetchError::TooManyRefs { got: 101, max: 100 }.into();
549 assert_eq!(e, ErrorCode::InvalidRef);
550
551 // #343 / ADR-0034 — SourceUnavailable shares the TextUnavailable wire
552 // code (representation missing; the PDF may be fetchable), distinct
553 // variant for a non-ar5iv message.
554 let arxiv = match Ref::parse("arxiv:2401.12345").expect("parse arxiv id") {
555 Ref::Arxiv(a) => a,
556 Ref::Doi(_) => unreachable!("parsed an arxiv id"),
557 };
558 let e: ErrorCode = FetchError::SourceUnavailable {
559 arxiv_id: arxiv,
560 kind: "figures",
561 }
562 .into();
563 assert_eq!(e, ErrorCode::TextUnavailable);
564 }
565
566 #[test]
567 fn fetch_context_debug_redacts_internals() {
568 // Pin the Debug shape — only `session_id` is printed, the rest is
569 // elided. Prevents accidental log leakage when a context is
570 // included in a `tracing::debug!` event.
571 let (_td, ctx) = build_test_context();
572 let s = format!("{:?}", ctx);
573 assert!(
574 s.contains("session_id"),
575 "session_id must be in Debug: {}",
576 s
577 );
578 assert!(s.contains("01J0000000000000000000TEST"));
579 assert!(
580 !s.contains("HttpClient") && !s.contains("RateLimiter") && !s.contains("ProvenanceLog"),
581 "FetchContext Debug must not dump foundation internals: {}",
582 s,
583 );
584 }
585
586 // ---------------------------------------------------------------
587 // FetchError -> Option<DenialContext> (ADR-0023 §4)
588 // ---------------------------------------------------------------
589
590 #[test]
591 fn denial_from_not_eligible_carries_source_key() {
592 use crate::{DenialContext, DenialReason};
593 let e = FetchError::NotEligible {
594 source_key: "tdm-elsevier".to_string(),
595 };
596 let dc: Option<DenialContext> = (&e).into();
597 let dc = dc.expect("NotEligible -> Some(DenialContext)");
598 assert_eq!(dc.reason, DenialReason::CapabilityNotGranted);
599 assert_eq!(dc.source.as_deref(), Some("tdm-elsevier"));
600 assert!(dc.attempted.is_none());
601 // Post-refinement: `expected: None` ("producer did not populate")
602 // rather than `Some(vec![])` ("explicit empty allowlist"). See
603 // `DenialContext::expected` field doc for the disambiguation.
604 assert!(dc.expected.is_none());
605 }
606
607 #[test]
608 fn denial_from_http_delegates_to_http_mapping() {
609 use crate::http::HttpError;
610 use crate::{DenialContext, DenialReason, PDF_MAX_BYTES};
611 // The Http arm must delegate to the HttpError mapping rather than
612 // reinventing it, so an OversizedBody surfaces with cap/actual
613 // populated and the SizeCapExceeded reason — proving delegation
614 // works without per-variant duplication.
615 let e = FetchError::Http(HttpError::OversizedBody {
616 actual: 209_715_200,
617 cap: PDF_MAX_BYTES,
618 });
619 let dc: Option<DenialContext> = (&e).into();
620 let dc = dc.expect("Http(OversizedBody) -> Some(DenialContext)");
621 assert_eq!(dc.reason, DenialReason::SizeCapExceeded);
622 assert_eq!(dc.cap, Some(PDF_MAX_BYTES));
623 assert_eq!(dc.actual, Some(209_715_200));
624 }
625
626 #[test]
627 fn denial_from_non_denial_variants_returns_none() {
628 use crate::DenialContext;
629 // Each of the four non-denial FetchError arms maps to None per
630 // ADR-0023 §4.
631 let e = FetchError::NoOaAvailable;
632 let dc: Option<DenialContext> = (&e).into();
633 assert!(dc.is_none(), "NoOaAvailable must not produce DenialContext");
634
635 let e = FetchError::Log(LogError::Io(std::io::Error::other("synthetic")));
636 let dc: Option<DenialContext> = (&e).into();
637 assert!(dc.is_none(), "Log must not produce DenialContext");
638
639 let e = FetchError::InvalidRef(RefParseError::Empty);
640 let dc: Option<DenialContext> = (&e).into();
641 assert!(dc.is_none(), "InvalidRef must not produce DenialContext");
642
643 let e = FetchError::SourceSchema {
644 hint: "missing field 'license'".into(),
645 };
646 let dc: Option<DenialContext> = (&e).into();
647 assert!(dc.is_none(), "SourceSchema must not produce DenialContext");
648 }
649}