doiget_cli/commands/fetch.rs
1//! `doiget fetch <ref>` subcommand.
2//!
3//! Phase 1 scope:
4//!
5//! - **arXiv refs** — full end-to-end: PDF bytes are fetched via the
6//! `doiget_core::sources::arxiv::ArxivSource`, the `[doiget]`
7//! extension table is populated with the resolved license, source,
8//! size, and `fetched_at`, and the result is written to the on-disk
9//! store with both the metadata TOML and the PDF.
10//! - **DOI refs** — Crossref metadata + Unpaywall license enrichment + an
11//! OA PDF fetch when Unpaywall's `best_oa_location.url_for_pdf` (or
12//! `best_oa_location.url`) resolves to a host on the synthetic
13//! `"oa-publisher"` allowlist (`docs/REDIRECT_ALLOWLIST.md` §3). The OA
14//! URL host check is informed-best-effort; if the host is not on the
15//! allowlist or the body fails the magic-byte check, the orchestrator
16//! logs a `Fetch err` row under `source = "oa-publisher"` and falls back
17//! to metadata-only success — the metadata is still useful.
18//!
19//! ## Provenance contract
20//!
21//! Per `docs/PROVENANCE_LOG.md` §3, every invocation emits at least one
22//! `SessionStart`, one or more `Fetch` rows (one per source consulted), one
23//! `StoreWrite` row on success, and one `SessionEnd`. Each `Fetch` row is
24//! appended by the underlying `Source` impl; the orchestrator owns the
25//! session-bookend rows and the `StoreWrite` row.
26//!
27//! ## Configuration surface
28//!
29//! Hard-coded paths with env-var overrides; full `config.toml` plumbing
30//! arrives in a follow-up. See `docs/CONFIG.md` for the eventual surface.
31//!
32//! | Env var | Default | Purpose |
33//! |---|---|---|
34//! | `DOIGET_STORE_ROOT` | `./papers` (under the current working dir) | Filesystem store root |
35//! | `DOIGET_LOG_PATH` | `<config>/doiget/access.jsonl` | Provenance log file |
36//! | `DOIGET_CONTACT_EMAIL` | `doiget@localhost` | Polite-pool contact email (User-Agent and Crossref) |
37//! | `DOIGET_UNPAYWALL_EMAIL` | (= contact email) | Unpaywall query-string email |
38//! | `DOIGET_ARXIV_BASE` | `https://arxiv.org` | arXiv source base (test override) |
39//! | `DOIGET_CROSSREF_BASE` | `https://api.crossref.org` | Crossref source base (test override) |
40//! | `DOIGET_UNPAYWALL_BASE` | `https://api.unpaywall.org/v2` | Unpaywall source base (test override) |
41//! | `DOIGET_OA_PUBLISHER_BASE` | (production allowlist) | OA publisher host allowlist override (test override) |
42
43use std::sync::Arc;
44
45use anyhow::{anyhow, Context, Result};
46use camino::{Utf8Path, Utf8PathBuf};
47
48use super::output::print_err;
49#[cfg(feature = "metadata")]
50use doiget_core::http::tier_2_allowlist;
51use doiget_core::http::{
52 discovery_allowlist, fulltext_allowlist, oa_publisher_allowlist, tier_1_allowlist,
53 tier_3_allowlists, HttpClient,
54};
55use doiget_core::orchestrator::{
56 fetch_paper as core_fetch_paper, FetchPaperOutcome, PdfLegStatus, SourceAttempt,
57};
58use doiget_core::provenance::{Capability, LogEvent, LogResult, ProvenanceLog, RowInput};
59use doiget_core::rate_limiter::RateLimiter;
60use doiget_core::source::{FetchContext, FetchError};
61use doiget_core::store::FsStore;
62use doiget_core::{CapabilityProfile, DenialContext, DenialReason, ErrorCode, RateLimits, Ref};
63
64/// Defer to docs/PROVENANCE_LOG.md §3: 26-char ULID per process invocation.
65pub(crate) fn new_session_id() -> String {
66 ulid::Ulid::generate().to_string()
67}
68
69// ---------------------------------------------------------------------------
70// Dry-run plan / preview (ADR-0022)
71// ---------------------------------------------------------------------------
72
73// The structured `FetchPlan` shape, the `build_fetch_plan` builder, and
74// the `build_dry_run_envelope` JSON-shape helper live in `doiget-core`
75// so the MCP server can produce a bit-identical envelope without
76// depending on `doiget-cli`. The CLI re-exports them here for callers
77// that already `use doiget_cli::commands::fetch`.
78pub use doiget_core::dry_run::{
79 build_dry_run_envelope, build_fetch_plan, FetchPlan, PdfSourcePlan, RateLimitBudget,
80};
81
82/// Serialize the dry-run envelope and write it to stdout. Used by the
83/// `--dry-run` flag on `doiget fetch` and `doiget batch`. The envelope
84/// shape matches ADR-0022 §1 / `docs/MCP_TOOLS.md` §10.
85///
86/// `pub` so `commands::batch` (multi-ref dry-run) can reuse it. The
87/// function lives in `doiget-cli` (not `doiget-core`) because `println!`
88/// is a CLI concern; the MCP server uses [`build_dry_run_envelope`]
89/// directly and routes the bytes via JSON-RPC.
90///
91/// `print_stdout` is workspace-deny for MCP stdio safety (ADR-0001 /
92/// `docs/SECURITY.md` §3); `--dry-run` is a CLI-only path that never
93/// runs under the MCP server, so the localized `#[allow]` is the
94/// minimal intervention — same pattern used by `commands::config`,
95/// `commands::info`, etc.
96#[allow(clippy::print_stdout)]
97pub fn emit_dry_run_plan_to_stdout(ref_: &Ref, plan: &FetchPlan) -> Result<()> {
98 let envelope = build_dry_run_envelope(ref_, plan);
99 let s = serde_json::to_string(&envelope).context("serializing dry-run envelope to JSON")?;
100 println!("{s}");
101 Ok(())
102}
103
104/// Resolve the provenance log path. `DOIGET_LOG_PATH` wins; otherwise
105/// fall back to `<config>/doiget/access.jsonl` per `docs/PROVENANCE_LOG.md`
106/// §1.
107pub(crate) fn resolve_log_path() -> Result<Utf8PathBuf> {
108 if let Some(s) = read_env_utf8("DOIGET_LOG_PATH")? {
109 return Ok(Utf8PathBuf::from(s));
110 }
111 let cfg = config_dir_utf8()?;
112 Ok(cfg.join("doiget").join("access.jsonl"))
113}
114
115/// Read an env var and assert it is valid UTF-8. Returns `Ok(None)` if
116/// unset; `Ok(Some(s))` if set and UTF-8; `Err(...)` if set but non-UTF-8.
117/// `std::env::var` already requires UTF-8 (returns `VarError::NotUnicode`
118/// otherwise); we wrap it to surface a friendlier error and avoid the
119/// banned `std::path::PathBuf` round-trip.
120fn read_env_utf8(key: &str) -> Result<Option<String>> {
121 match std::env::var(key) {
122 Ok(s) => Ok(Some(s)),
123 Err(std::env::VarError::NotPresent) => Ok(None),
124 Err(std::env::VarError::NotUnicode(_)) => Err(anyhow!("{key} is not valid UTF-8")),
125 }
126}
127
128/// Best-effort home-dir resolution without depending on the `dirs` crate
129/// (every new dep adds cargo-vet exemption churn). Honors `HOME` first
130/// (POSIX + most CI), then `USERPROFILE` (Windows).
131fn home_dir_utf8() -> Result<Utf8PathBuf> {
132 if let Some(s) = read_env_utf8("HOME")? {
133 return Ok(Utf8PathBuf::from(s));
134 }
135 if let Some(s) = read_env_utf8("USERPROFILE")? {
136 return Ok(Utf8PathBuf::from(s));
137 }
138 Err(anyhow!("neither HOME nor USERPROFILE is set"))
139}
140
141/// Config-dir resolution, delegated to `doiget_core::user_extension`.
142///
143/// This used to be one of three copies. The previous comment here asked
144/// the reader to "keep the signature stable" because divergence from the
145/// MCP-side copy "would silently desync the user-extension allowlist
146/// surfaces" — and they had already diverged, this one accepting
147/// `XDG_CONFIG_HOME=""` and resolving a *relative* config path under the
148/// cwd where the MCP copy treated blank as unset. The shared resolver
149/// keeps blank-is-unset, so a blank variable no longer silently selects a
150/// different file.
151///
152/// Kept as a crate-visible wrapper so the ~20 call sites in
153/// `commands::capabilities` / `commands::config` are unchanged.
154pub(crate) fn config_dir_utf8() -> Result<Utf8PathBuf> {
155 Ok(doiget_core::user_extension::config_dir()?)
156}
157
158/// Best-effort resolver-cache root (`docs/CACHE.md`). Honors
159/// `DOIGET_CACHE_ROOT` first, then `XDG_CACHE_HOME/doiget` (POSIX), then
160/// `LOCALAPPDATA\doiget\cache` (Windows), then `$HOME/.cache/doiget`.
161/// Crate-visible so the `verify` command can enable the resolve cache.
162pub(crate) fn cache_dir_utf8() -> Result<Utf8PathBuf> {
163 if let Some(s) = read_env_utf8("DOIGET_CACHE_ROOT")? {
164 return Ok(Utf8PathBuf::from(s));
165 }
166 if let Some(s) = read_env_utf8("XDG_CACHE_HOME")? {
167 return Ok(Utf8PathBuf::from(s).join("doiget"));
168 }
169 if let Some(s) = read_env_utf8("LOCALAPPDATA")? {
170 return Ok(Utf8PathBuf::from(s).join("doiget").join("cache"));
171 }
172 let home = home_dir_utf8()?;
173 Ok(home.join(".cache").join("doiget"))
174}
175
176/// Build a metadata-resolution [`FetchContext`]: HTTP client, rate
177/// limiter, and provenance log resolved from the environment, with the
178/// resolver cache (`docs/CACHE.md`) enabled best-effort.
179///
180/// This is the shared context for the read-only resolve commands
181/// (`verify`, `cite`) — neither persists to the store, so no store
182/// handle is constructed. Enabling `cache_root` means repeat resolves of
183/// the same ref are served from disk, avoiding upstream rate limits; if
184/// the cache dir can't be resolved the run simply proceeds without it.
185pub(crate) fn build_resolve_context() -> Result<FetchContext> {
186 let session_id = new_session_id();
187 let log_path = resolve_log_path()?;
188 let http = Arc::new(build_http_client(None)?);
189 let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
190 let log = Arc::new(
191 ProvenanceLog::open(log_path, session_id.clone())
192 .context("failed to open provenance log")?,
193 );
194 let cache_root = cache_dir_utf8().ok();
195 Ok(FetchContext {
196 http,
197 rate_limiter,
198 log,
199 session_id,
200 cache_root,
201 })
202}
203
204/// Construct the workspace-wide [`HttpClient`].
205///
206/// Production path: `HttpClient::new(tier_1_allowlist() ∪ oa_publisher_allowlist())` —
207/// strict HTTPS-only with the canonical Tier-1 redirect allowlist (Crossref,
208/// Unpaywall, arXiv) plus the synthetic `"oa-publisher"` allowlist used for
209/// the OA PDF leg of the DOI fetch path (`fetch_doi` issues
210/// `HttpClient::fetch_pdf("oa-publisher", url)` against the URL Unpaywall
211/// returned in `best_oa_location`). The OA-publisher list is
212/// informed-best-effort per `docs/REDIRECT_ALLOWLIST.md` §3.
213///
214/// Test path: when any of the three `DOIGET_*_BASE` env vars is set, build a
215/// multi-source relaxed-`https_only` client whose per-source allowlist is
216/// derived from the corresponding env-var hosts. The `oa-publisher` source
217/// key is registered against the same host (typically the wiremock origin)
218/// when `DOIGET_OA_PUBLISHER_BASE` is set — this lets the integration tests
219/// under `tests/fetch_doi_oa_pdf_e2e.rs` exercise the full PDF leg without
220/// touching the real network.
221pub(crate) fn build_http_client(user_agent: Option<&str>) -> Result<HttpClient> {
222 let arxiv = std::env::var("DOIGET_ARXIV_BASE").ok();
223 let crossref = std::env::var("DOIGET_CROSSREF_BASE").ok();
224 let unpaywall = std::env::var("DOIGET_UNPAYWALL_BASE").ok();
225 let oa_publisher = std::env::var("DOIGET_OA_PUBLISHER_BASE").ok();
226 // Slice 16: `DOIGET_OPENALEX_BASE` selects a wiremock host for the
227 // citation-graph BFS. Only meaningful with `--features citation`,
228 // but reading the env unconditionally keeps the branch logic
229 // simple and is harmless for default builds.
230 let openalex_base = std::env::var("DOIGET_OPENALEX_BASE").ok();
231 // ADR-0032: `DOIGET_AR5IV_BASE` selects a wiremock host for the
232 // full-text extraction path (`doiget text`). Test-only override,
233 // mirroring `DOIGET_ARXIV_BASE`.
234 let ar5iv_base = std::env::var("DOIGET_AR5IV_BASE").ok();
235
236 if arxiv.is_none()
237 && crossref.is_none()
238 && unpaywall.is_none()
239 && oa_publisher.is_none()
240 && openalex_base.is_none()
241 && ar5iv_base.is_none()
242 {
243 let mut allowlists = tier_1_allowlist();
244 allowlists.extend(oa_publisher_allowlist());
245 // ADR-0031: discovery search (`doiget search`) is Tier-1 OA
246 // metadata, always-on, and ships in the default `oa-only` binary.
247 // Register `api.openalex.org` under the `"openalex"` source key
248 // UNCONDITIONALLY so `discovery::paper_search` can reach the
249 // `/works?search=` endpoint without `--features citation`. In
250 // citation builds the Tier-2 extend below re-registers the same
251 // host under the same key (idempotent HashMap overwrite).
252 allowlists.extend(discovery_allowlist());
253 // ADR-0032: full-text extraction (`doiget text`) is Tier-1 OA
254 // metadata, always-on. Register `ar5iv.labs.arxiv.org` under the
255 // `"ar5iv"` source key unconditionally so `paper_text::paper_text`
256 // can reach ar5iv in `oa-only` builds.
257 allowlists.extend(fulltext_allowlist());
258 // The Tier-2 transport gate. The sources it serves — OpenAlex,
259 // Semantic Scholar, DOAJ, DataCite, HAL, OpenAIRE, CORE and
260 // Europe PMC — are compiled under `metadata`, and
261 // `resolve_optional_chain` is `#[cfg(feature = "metadata")]`,
262 // so this extend MUST be gated on `metadata` too. It was gated
263 // on `citation` for six releases: in a `--features metadata`
264 // build (which CI's clippy matrix builds explicitly) the chain
265 // ran, `can_serve` passed, and the request died at
266 // `UnknownSource` because no allowlist entry existed for the
267 // key (#516). CapabilityProfile.metadata.* is the runtime gate;
268 // this is the transport gate, and the two must agree.
269 #[cfg(feature = "metadata")]
270 allowlists.extend(tier_2_allowlist());
271 // #454: the Tier-3 transport gate. #444 made the orchestrator
272 // reach these sources; without this line the fetch it then issues
273 // under `tdm-aps` / `tdm-elsevier` / `tdm-springer` dies at
274 // `UnknownSource`. Empty in a default build (ADR-0002 — no Tier-3
275 // feature is compiled into published binaries), so this is a
276 // no-op for the shipped surface.
277 allowlists.extend(tier_3_allowlists());
278
279 // ADR-0028 D2: merge user-extension hosts from
280 // `<config_dir>/doiget/config.toml`. See
281 // `doiget_core::user_extension` for the wire contract and
282 // the (deferred) S3b provenance / doctor / capabilities
283 // surfaces.
284 //
285 // Failure handling is opt-in-convenience: a missing config
286 // is silent (Ok-empty), a malformed config emits
287 // `tracing::warn!` and continues with the curated allowlist,
288 // and an unresolvable config dir emits `tracing::debug!`
289 // (only happens in stripped envs with no HOME / XDG /
290 // APPDATA — review pass I3 / A1).
291 match config_dir_utf8() {
292 Ok(cfg_dir) => {
293 let path = cfg_dir.join("doiget").join("config.toml");
294 match doiget_core::user_extension::load(&path) {
295 Ok(cfg) => {
296 let mut hosts = cfg.additional_hosts;
297 if cfg.trust_academic_repos {
298 hosts.extend(doiget_core::user_extension::academic_repo_hosts());
299 }
300 // Issue #405: the Gold-OA counterpart. Separate flag
301 // because the trust argument is different — see
302 // `oa_registry_hosts`.
303 if cfg.trust_oa_registries {
304 hosts.extend(doiget_core::user_extension::oa_registry_hosts());
305 }
306 if !hosts.is_empty() {
307 tracing::info!(
308 count = hosts.len(),
309 trust_academic_repos = cfg.trust_academic_repos,
310 trust_oa_registries = cfg.trust_oa_registries,
311 path = %path,
312 "merging user-extension allowlist hosts (ADR-0028 D2)"
313 );
314 doiget_core::user_extension::merge_into_allowlists(
315 &mut allowlists,
316 &hosts,
317 );
318 }
319 }
320 Err(e) => {
321 tracing::warn!(
322 error = %e,
323 path = %path,
324 "failed to load user-extension allowlist; \
325 falling back to curated set only"
326 );
327 }
328 }
329 }
330 Err(e) => {
331 tracing::debug!(
332 error = %e,
333 "config dir unresolvable; \
334 user-extension allowlist disabled (curated set only)"
335 );
336 }
337 }
338
339 return match user_agent {
340 Some(ua) => HttpClient::new_with_user_agent(allowlists, ua),
341 None => HttpClient::new(allowlists),
342 }
343 .context("building HTTP client");
344 }
345
346 // Test-base mode: build a relaxed client per overridden source.
347 let mut owned: Vec<(String, String)> = Vec::new();
348 for (source, base) in [
349 ("arxiv", arxiv.as_deref()),
350 ("crossref", crossref.as_deref()),
351 ("unpaywall", unpaywall.as_deref()),
352 ("oa-publisher", oa_publisher.as_deref()),
353 ("openalex", openalex_base.as_deref()),
354 ("ar5iv", ar5iv_base.as_deref()),
355 ] {
356 if let Some(b) = base {
357 let url = url::Url::parse(b)
358 .with_context(|| format!("DOIGET_*_BASE for {source} is not a URL: {b}"))?;
359 let host = url
360 .host_str()
361 .ok_or_else(|| anyhow!("base URL has no host: {b}"))?;
362 owned.push((source.to_string(), host.to_string()));
363 }
364 }
365 let entries: Vec<(&str, &str)> = owned
366 .iter()
367 .map(|(s, h)| (s.as_str(), h.as_str()))
368 .collect();
369 Ok(HttpClient::new_for_tests_allow_http_multi(&entries))
370}
371
372// Slice 2: the per-source env-aware constructors that used to live here
373// (`build_arxiv_source`, `build_crossref_source`, `build_unpaywall_source`)
374// moved into `doiget-core::orchestrator` so the core `fetch_paper`
375// orchestrator and the MCP server both honor the same `DOIGET_*_BASE`
376// test-override surface. The CLI no longer constructs sources directly —
377// it builds the `FetchContext` + `FsStore` and hands them to the core
378// orchestrator.
379
380/// Resolved configuration derived from the environment.
381///
382/// Slice 2: `contact_email` / `unpaywall_email` are read by the
383/// `doiget-core::orchestrator::fetch_paper` orchestrator itself
384/// (`resolve_contact_email` / `resolve_unpaywall_email` in that module —
385/// env var, then `[network]` in `config.toml`, then the default since
386/// #504), so the CLI no longer threads them through. The fields
387/// stay here so a future slice that adds CLI-flag overrides has a
388/// natural attachment point — the `#[allow(dead_code)]` is the minimal
389/// intervention until that slice lands.
390#[allow(dead_code)]
391pub(crate) struct OrchestratorConfig {
392 pub(crate) store_root: Utf8PathBuf,
393 pub(crate) log_path: Utf8PathBuf,
394 pub(crate) contact_email: String,
395 pub(crate) unpaywall_email: String,
396}
397
398impl OrchestratorConfig {
399 fn from_env() -> Result<Self> {
400 let store_root = super::resolve_store_root()?;
401 let log_path = resolve_log_path()?;
402 // Through the core resolver even though this struct is not read
403 // yet: a dormant third copy of the ladder is still a copy, and it
404 // is the one nobody would think to update.
405 let contact_email = doiget_core::orchestrator::contact_email_or_placeholder();
406 let unpaywall_email = std::env::var("DOIGET_UNPAYWALL_EMAIL")
407 .ok()
408 .filter(|s| !s.trim().is_empty())
409 .unwrap_or_else(|| contact_email.clone());
410 Ok(Self {
411 store_root,
412 log_path,
413 contact_email,
414 unpaywall_email,
415 })
416 }
417}
418
419/// Reusable fetch harness shared by `doiget fetch <ref>` (single ref) and
420/// `doiget batch <path>` (many refs). Owns the shared foundation modules
421/// (`HttpClient` / `RateLimiter` / `ProvenanceLog`), the on-disk store, and
422/// the resolved capability profile, plus the session bookkeeping required by
423/// `docs/PROVENANCE_LOG.md` §3 (the 26-char ULID `session_id`).
424///
425/// Construction is performed once via [`FetchHarness::from_env`]. Per-ref
426/// orchestration runs through [`FetchHarness::fetch_one`]; bookend rows go
427/// via [`FetchHarness::log_session_start`] / [`FetchHarness::log_session_end`]
428/// so the orchestrator can frame either one fetch or many.
429pub(crate) struct FetchHarness {
430 pub(crate) http: Arc<HttpClient>,
431 pub(crate) rate_limiter: Arc<RateLimiter>,
432 pub(crate) log: Arc<ProvenanceLog>,
433 pub(crate) store: FsStore,
434 pub(crate) profile: CapabilityProfile,
435 pub(crate) session_id: String,
436 /// Resolved config; Slice 2 keeps this on the harness for the
437 /// CLI-only env diagnostics path (`commands::config::doctor`), even
438 /// though `fetch_one` no longer needs it (the core orchestrator
439 /// re-reads contact email from env directly).
440 #[allow(dead_code)]
441 pub(crate) cfg: OrchestratorConfig,
442}
443
444impl FetchHarness {
445 /// Build a harness from the same env-var surface documented at the top
446 /// of this module. Creates the log parent directory if missing, opens
447 /// the provenance log (allocating a fresh `session_id`), and constructs
448 /// the HTTP client honoring `DOIGET_*_BASE` overrides for tests.
449 pub(crate) fn from_env() -> Result<Self> {
450 Self::from_env_with_ua(None)
451 }
452
453 /// Like [`from_env`](Self::from_env) but overrides the `User-Agent` on
454 /// every HTTP request. Used by `doiget batch --user-agent`.
455 pub(crate) fn from_env_with_ua(user_agent: Option<&str>) -> Result<Self> {
456 let cfg = OrchestratorConfig::from_env()?;
457 if let Some(parent) = cfg.log_path.parent() {
458 if !parent.as_str().is_empty() {
459 std::fs::create_dir_all(parent.as_std_path())
460 .with_context(|| format!("creating log dir {parent}"))?;
461 }
462 }
463 let session_id = new_session_id();
464 let log = Arc::new(
465 ProvenanceLog::open(cfg.log_path.clone(), session_id.clone())
466 .context("opening provenance log")?,
467 );
468 let http = Arc::new(build_http_client(user_agent)?);
469 let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
470 let store = FsStore::new(cfg.store_root.clone()).context("opening store")?;
471 let profile = CapabilityProfile::from_env().context("resolving capability profile")?;
472
473 Ok(Self {
474 http,
475 rate_limiter,
476 log,
477 store,
478 profile,
479 session_id,
480 cfg,
481 })
482 }
483
484 /// Build a [`FetchContext`] view over this harness's foundation modules.
485 /// Creating one is cheap (cloning three `Arc`s + a `String`); per-ref
486 /// orchestration constructs one on demand.
487 pub(crate) fn fetch_context(&self) -> FetchContext {
488 FetchContext {
489 http: self.http.clone(),
490 rate_limiter: self.rate_limiter.clone(),
491 log: self.log.clone(),
492 session_id: self.session_id.clone(),
493 cache_root: None,
494 }
495 }
496
497 /// Append a `SessionStart` row. `ref_input` is the raw user-supplied ref
498 /// string (single-fetch path); pass `None` for batch sessions where no
499 /// single ref attributes the session.
500 pub(crate) fn log_session_start(&self, ref_input: Option<&str>) -> Result<()> {
501 self.log
502 .append(RowInput {
503 event: LogEvent::SessionStart,
504 result: LogResult::Ok,
505 capability: Capability::Oa,
506 ref_: ref_input,
507 source: None,
508 error_code: None,
509 size_bytes: None,
510 license: None,
511 store_path: None,
512 // Session bookend — no audit identity (ADR-0021 §1).
513 canonical_digest: None,
514 })
515 .context("appending SessionStart row")?;
516 Ok(())
517 }
518
519 /// Append a `SessionEnd` row. `ref_input` mirrors the `log_session_start`
520 /// argument; pass `None` for batch sessions. The result is best-effort —
521 /// if this append fails, the caller already has the underlying fetch
522 /// error (if any) and we don't override it.
523 pub(crate) fn log_session_end(&self, ok: bool, ref_input: Option<&str>) {
524 let result = if ok { LogResult::Ok } else { LogResult::Err };
525 let _ = self.log.append(RowInput {
526 event: LogEvent::SessionEnd,
527 result,
528 capability: Capability::Oa,
529 ref_: ref_input,
530 source: None,
531 error_code: None,
532 size_bytes: None,
533 license: None,
534 store_path: None,
535 // Session bookend — no audit identity (ADR-0021 §1).
536 canonical_digest: None,
537 });
538 }
539
540 /// Run a single ref through the per-kind orchestration (arxiv → PDF +
541 /// metadata; doi → metadata-only via Crossref + Unpaywall, with an
542 /// informed-best-effort OA PDF leg). Errors here are scoped to this
543 /// one ref — the caller decides whether to abort the surrounding
544 /// session.
545 ///
546 /// Slice 2: delegates to
547 /// [`doiget_core::orchestrator::fetch_paper`] for the actual work
548 /// (which both CLI and MCP now share). This function keeps the
549 /// CLI-only stderr success-line print.
550 pub(crate) async fn fetch_one(&self, ref_: &Ref) -> Result<FetchPaperOutcome, FetchError> {
551 // Pure data path: return the typed outcome (or typed error)
552 // without any CLI-only rendering or exit-code synthesis. The
553 // single-fetch caller (`run_with_options`) and the batch
554 // caller (`commands::batch::classify_joined`) each render the
555 // human / JSON surface and map to `CliExit` themselves — see
556 // #210 for the rationale (batch's `--json` JSONL needs the
557 // structured `FetchPaperOutcome` to emit `result.{safekey,
558 // store_path, canonical_digest}` on success and
559 // `denial_context` on a `PdfLegStatus::Blocked` outcome, which
560 // was unreachable through the previous `Result<()>`
561 // signature).
562 let ctx = self.fetch_context();
563 core_fetch_paper(ref_, &self.profile, &ctx, &self.store, self.store.root()).await
564 }
565}
566
567/// `true` iff the outcome represents a clean fetch: `Fetched` (full
568/// PDF), `NoOaUrl` (metadata-only by design), or `PreprintFallback`
569/// (OA blocked but arXiv preprint auto-fetched — issue #325).
570/// A `Blocked` PDF leg is a failure for SessionEnd / exit-code purposes.
571/// Pulled out so both `run_with_options` and `commands::batch` agree on
572/// the failure boundary.
573pub(crate) fn outcome_is_clean_success(outcome: &FetchPaperOutcome) -> bool {
574 !matches!(outcome.pdf_leg, PdfLegStatus::Blocked { .. })
575}
576
577/// CLI-only one-line success message on stderr (ADR-0001 stdio
578/// convention). Renders the [`FetchPaperOutcome`] in the same form the
579/// pre-Slice-2 CLI emitted: a full-PDF success names the PDF path; a
580/// metadata-only DOI fallback (size_bytes == 0) names the metadata TOML
581/// path the orchestrator wrote.
582fn emit_success_line(ref_: &Ref, outcome: &FetchPaperOutcome) {
583 let label = match ref_ {
584 Ref::Arxiv(id) => format!("arxiv:{}", id.as_str()),
585 Ref::Doi(doi) => format!("doi:{}", doi.as_str()),
586 };
587 match &outcome.pdf_leg {
588 PdfLegStatus::Fetched => {
589 print_success(format_args!(
590 "fetched {} ({} bytes) -> {}",
591 label, outcome.size_bytes, outcome.path
592 ));
593 }
594 PdfLegStatus::NoOaUrl => {
595 print_success(format_args!(
596 "fetched {} (metadata-only: no OA PDF available) -> {}",
597 label, outcome.path
598 ));
599 }
600 // Issue #325: publisher PDF was blocked, arXiv preprint auto-fetched.
601 PdfLegStatus::PreprintFallback { arxiv_id, .. } => {
602 print_success(format_args!(
603 "fetched {} ({} bytes) via arXiv preprint arxiv:{} -> {}",
604 label, outcome.size_bytes, arxiv_id, outcome.path
605 ));
606 }
607 // #458: the publisher served its own copy under the user's TDM
608 // agreement. Named explicitly rather than left to the `_` arm
609 // below, which would have printed the same line as a plain OA
610 // fetch -- the user needs to know the open route failed and which
611 // agreement was drawn on, because that is the one with terms
612 // attached.
613 PdfLegStatus::TdmFetched { source, .. } => {
614 print_success(format_args!(
615 "fetched {} ({} bytes) via {} under your TDM agreement (no open copy available) -> {}",
616 label, outcome.size_bytes, source, outcome.path
617 ));
618 }
619 // Issue #145: `Blocked` is NO LONGER a success outcome. It is
620 // intercepted in `fetch_one` BEFORE `emit_success_line` is
621 // called and rendered via `render_blocked_error` with a
622 // non-zero exit (`docs/ERRORS.md` §3/§6 — no silent failures).
623 // Reaching this arm would mean the interception regressed, so we
624 // fail closed: surface the `error[CODE]:` line here too rather
625 // than printing a misleading success line.
626 PdfLegStatus::Blocked {
627 code,
628 message,
629 denial,
630 suggested_arxiv_id,
631 } => {
632 // Same #145 reclassification as the primary interception in
633 // `fetch_one`, so this fail-closed fallback stays consistent.
634 let effective = effective_blocked_code(*code, denial.as_ref());
635 render_blocked_error(
636 ref_,
637 outcome,
638 effective,
639 message,
640 denial.as_ref(),
641 suggested_arxiv_id.as_deref(),
642 );
643 }
644 // `PdfLegStatus` is `#[non_exhaustive]`; a future variant
645 // degrades to the size-based wording rather than failing the
646 // downstream-crate build.
647 _ => {
648 if outcome.size_bytes == 0 {
649 print_success(format_args!(
650 "fetched {} (metadata-only) -> {}",
651 label, outcome.path
652 ));
653 } else {
654 print_success(format_args!(
655 "fetched {} ({} bytes) -> {}",
656 label, outcome.size_bytes, outcome.path
657 ));
658 }
659 }
660 }
661
662 // #344: an identity-confirmation line so a caller can verify the RIGHT
663 // paper landed without a second `doiget info` call. Skipped for the
664 // Blocked fail-closed arm (it rendered an `error[CODE]:` line above, not
665 // a success).
666 if !matches!(outcome.pdf_leg, PdfLegStatus::Blocked { .. }) {
667 emit_identity_line(outcome);
668 }
669}
670
671/// Render the #344 identity line on stderr:
672/// ` "<title>" by <author> et al. (<year>) [<source>/<oa>]`.
673/// Empty pieces are omitted; an unknown OA status renders as `?`.
674fn emit_identity_line(outcome: &FetchPaperOutcome) {
675 let by = match outcome.authors.as_slice() {
676 [] => String::new(),
677 [a] => format!(" by {a}"),
678 [a, ..] => format!(" by {a} et al."),
679 };
680 let year = match outcome.year {
681 Some(y) => format!(" ({y})"),
682 None => String::new(),
683 };
684 let oa = outcome.oa_status.as_deref().unwrap_or("?");
685 print_success(format_args!(
686 " \"{}\"{}{} [{}/{}]",
687 outcome.title, by, year, outcome.source, oa
688 ));
689}
690
691/// Run the `doiget fetch <ref>` subcommand.
692///
693/// `dry_run` (ADR-0022 §1): when `true`, build a [`FetchPlan`] from the
694/// parsed [`Ref`] and the configured store root, serialize it as JSON to
695/// stdout, and return `Ok(())` immediately, **without** building a
696/// `FetchHarness` (no provenance log open), without contacting the
697/// network, without writing to the store, and without appending a
698/// provenance row.
699///
700/// When `dry_run` is `false`, the function runs the normal end-to-end
701/// orchestration path: open the provenance log, dispatch the per-kind
702/// orchestrator, emit a `SessionStart` / `SessionEnd` bookend pair.
703///
704/// On success returns `Ok(())` and writes a one-line success message to
705/// stderr (per ADR-0001 stdio convention — no stdout writes from `fetch`
706/// on the normal path). On failure, returns an `anyhow::Error` and emits
707/// a `SessionEnd` row with `result=err` to the provenance log before
708/// returning.
709///
710/// # History
711///
712/// Slice 5 (PR #84 advisory item A2/A3 refactor): the previous
713/// `FetchOptions { dry_run: bool }` single-field option bundle plus the
714/// thin `run(input)` backwards-compat wrapper were collapsed into this
715/// single `dry_run: bool` parameter — the option bundle's single-bool
716/// shape was YAGNI, and the wrapper only existed to spare integration
717/// tests a `FetchOptions::default()` literal.
718pub async fn run_with_options(
719 input: String,
720 dry_run: bool,
721 link: Option<Utf8PathBuf>,
722 _mode: super::output::OutputMode,
723) -> Result<()> {
724 // `_mode` is threaded per ADR-0017 / #144. Quiet-suppression of the
725 // success line is tracked in #203. The dry-run plan envelope is
726 // product output (the requested artifact) and is unaffected by
727 // mode.
728 // Step 1: parse + safekey. Issue #119: render the cargo-style
729 // `error[INVALID_REF]:` line + carry the exit code, rather than
730 // letting the granular `RefParseError` fall out as an opaque anyhow
731 // `{:?}` dump. Through the shared helper (#492) so a change to the
732 // wording or the code reaches every command at once — this and `graph`
733 // were the last two hand-inlined copies of its body.
734 let ref_ = super::parse_ref_or_exit(&input)?;
735
736 // Dry-run branch: build the plan and emit it. NO harness, NO network,
737 // NO store write, NO provenance row. Posture-lint ADR-0022 §5 will
738 // verify this branch never reaches `HttpClient::fetch_*`,
739 // `FsStore::write_*`, or `ProvenanceLog::append`.
740 if dry_run {
741 // Resolve store root for path projections. Failures here surface
742 // as a normal CLI error (not as a denial) — same behaviour the
743 // non-dry-run path would exhibit on a misconfigured environment.
744 let store_root = super::resolve_store_root()?;
745 let plan = build_fetch_plan(&ref_, &store_root);
746 emit_dry_run_plan_to_stdout(&ref_, &plan)?;
747 return Ok(());
748 }
749
750 // Step 2: build harness (foundation modules + provenance log).
751 let harness = FetchHarness::from_env()?;
752
753 // Step 3: emit SessionStart. Fail-closed if the log write fails — the
754 // surrounding fetch MUST NOT proceed (`docs/PROVENANCE_LOG.md` §5).
755 harness.log_session_start(Some(ref_.as_input_str()))?;
756
757 // Step 4: dispatch on ref kind. `fetch_one` now returns the
758 // typed `FetchPaperOutcome` / `FetchError` per #210; the
759 // single-fetch caller (this fn) owns rendering + exit code.
760 let result = harness.fetch_one(&ref_).await;
761
762 // Step 5: emit SessionEnd regardless of outcome. A `Blocked` PDF
763 // leg is NOT a clean success even though the typed `Result` is
764 // `Ok` — `outcome_is_clean_success` collapses both halves so the
765 // SessionEnd `is_ok` field matches the user-facing exit code.
766 let session_ok = match &result {
767 Ok(o) => outcome_is_clean_success(o),
768 Err(_) => false,
769 };
770 harness.log_session_end(session_ok, Some(ref_.as_input_str()));
771
772 // Step 6: render the user-facing surface and map to `CliExit`.
773 // The Blocked-PDF reclassification logic that used to live inside
774 // `fetch_one` was lifted here verbatim so the batch caller can
775 // share the same `effective_blocked_code` / `render_blocked_error`
776 // helpers (issue #210 / #145).
777 match result {
778 Ok(outcome) => {
779 if let PdfLegStatus::Blocked {
780 code,
781 message,
782 denial,
783 suggested_arxiv_id,
784 } = &outcome.pdf_leg
785 {
786 let effective = effective_blocked_code(*code, denial.as_ref());
787 render_blocked_error(
788 &ref_,
789 &outcome,
790 effective,
791 message,
792 denial.as_ref(),
793 suggested_arxiv_id.as_deref(),
794 );
795 return Err(anyhow::Error::new(CliExit(cli_exit_code(effective))));
796 }
797 emit_success_line(&ref_, &outcome);
798 // #344 Slice 2: optionally surface the artifact in the user's
799 // working tree via a symlink (copy fallback). A link failure is a
800 // warning, not a fetch failure — the PDF is already in the store.
801 if let Some(dir) = link.as_deref() {
802 emit_link_result(&ref_, &outcome, dir);
803 }
804 Ok(())
805 }
806 Err(e) => {
807 render_fetch_error(&e);
808 let code: ErrorCode = (&e).into();
809 Err(anyhow::Error::new(CliExit(cli_exit_code(code))))
810 }
811 }
812}
813
814/// `--link` (#344 Slice 2): place a link to the fetched PDF in `dir` so the
815/// artifact is visible in the user's working tree. The central store stays the
816/// single source of truth; this only adds a pointer (or, where symlinks are
817/// unavailable, a copy). Only PDF outcomes are linked — a metadata-only fetch
818/// is reported as skipped. A link failure is a warning (stderr), never a fetch
819/// failure: the artifact is already in the store.
820fn emit_link_result(ref_: &Ref, outcome: &FetchPaperOutcome, dir: &Utf8Path) {
821 let label = match ref_ {
822 Ref::Arxiv(id) => format!("arxiv:{}", id.as_str()),
823 Ref::Doi(doi) => format!("doi:{}", doi.as_str()),
824 };
825 if !matches!(
826 outcome.pdf_leg,
827 PdfLegStatus::Fetched
828 | PdfLegStatus::PreprintFallback { .. }
829 | PdfLegStatus::TdmFetched { .. }
830 ) {
831 print_success(format_args!(
832 "note: --link skipped for {label} (no PDF — metadata-only fetch)"
833 ));
834 return;
835 }
836 let name = fetch_link_filename(
837 &outcome.title,
838 &outcome.authors,
839 outcome.year,
840 &outcome.safekey,
841 );
842 match link_artifact(dir, &outcome.path, &name) {
843 Ok((path, kind)) => print_success(format_args!("linked {label} -> {path} ({kind})")),
844 Err(e) => print_err(format_args!("warning: --link failed for {label}: {e}")),
845 }
846}
847
848/// Build a human-readable, filesystem-safe PDF filename for `--link`:
849/// `<surname><year>-<title-slug>.pdf`
850/// (e.g. `vaswani2017-attention-is-all-you-need.pdf`), falling back to
851/// `<safekey>.pdf` when no usable metadata is available.
852fn fetch_link_filename(
853 title: &str,
854 authors: &[String],
855 year: Option<i32>,
856 safekey: &str,
857) -> String {
858 let surname = authors
859 .first()
860 .map(|a| slugify(a.split_whitespace().last().unwrap_or(a)))
861 .unwrap_or_default();
862 let year = year.map(|y| y.to_string()).unwrap_or_default();
863 let title_slug: String = slugify(title)
864 .split('-')
865 .take(6)
866 .collect::<Vec<_>>()
867 .join("-");
868 let mut stem = format!("{surname}{year}");
869 if !stem.is_empty() && !title_slug.is_empty() {
870 stem.push('-');
871 }
872 stem.push_str(&title_slug);
873 let stem: String = stem.chars().take(80).collect();
874 let stem = stem.trim_matches('-');
875 if stem.is_empty() {
876 format!("{safekey}.pdf")
877 } else {
878 format!("{stem}.pdf")
879 }
880}
881
882/// Lowercase ASCII-alphanumeric slug: every run of non-alphanumeric characters
883/// collapses to a single `-`, with no leading/trailing dashes. Pure and
884/// filesystem-safe (no path separators, no `..`).
885fn slugify(s: &str) -> String {
886 s.chars()
887 .map(|c| {
888 if c.is_ascii_alphanumeric() {
889 c.to_ascii_lowercase()
890 } else {
891 '-'
892 }
893 })
894 .collect::<String>()
895 .split('-')
896 .filter(|p| !p.is_empty())
897 .collect::<Vec<_>>()
898 .join("-")
899}
900
901/// Place a link to `src` (the store PDF) at `dir/name`. Tries a symlink first;
902/// on failure (e.g. Windows without privilege, or a cross-device link) falls
903/// back to a copy. Replaces a prior doiget symlink, but refuses to clobber an
904/// unrelated regular file. Returns the written path and the mechanism used
905/// (`"symlink"` | `"copy"`).
906///
907/// The symlink-vs-file check and the subsequent replace are not atomic: a
908/// concurrent process swapping the entry between the two syscalls is an
909/// accepted, out-of-scope race — the `--link` dir is the user's own working
910/// directory, assumed single-writer (review #352).
911fn link_artifact(
912 dir: &Utf8Path,
913 src: &Utf8Path,
914 name: &str,
915) -> Result<(Utf8PathBuf, &'static str)> {
916 std::fs::create_dir_all(dir.as_std_path())
917 .with_context(|| format!("creating link dir {dir}"))?;
918 let dst = dir.join(name);
919 if let Ok(meta) = std::fs::symlink_metadata(dst.as_std_path()) {
920 if meta.file_type().is_symlink() {
921 std::fs::remove_file(dst.as_std_path())
922 .with_context(|| format!("replacing existing symlink {dst}"))?;
923 } else {
924 anyhow::bail!(
925 "refusing to overwrite existing file {dst} (not a doiget symlink) — \
926 remove it or choose another --link dir"
927 );
928 }
929 }
930 match make_symlink(src, &dst) {
931 Ok(()) => Ok((dst, "symlink")),
932 Err(_) => {
933 std::fs::copy(src.as_std_path(), dst.as_std_path())
934 .with_context(|| format!("copying {src} -> {dst}"))?;
935 Ok((dst, "copy"))
936 }
937 }
938}
939
940/// Cross-platform file symlink. On platforms without symlink support the caller
941/// falls back to a copy.
942#[cfg(unix)]
943fn make_symlink(src: &Utf8Path, dst: &Utf8Path) -> std::io::Result<()> {
944 std::os::unix::fs::symlink(src.as_std_path(), dst.as_std_path())
945}
946
947#[cfg(windows)]
948fn make_symlink(src: &Utf8Path, dst: &Utf8Path) -> std::io::Result<()> {
949 std::os::windows::fs::symlink_file(src.as_std_path(), dst.as_std_path())
950}
951
952#[cfg(not(any(unix, windows)))]
953fn make_symlink(_src: &Utf8Path, _dst: &Utf8Path) -> std::io::Result<()> {
954 Err(std::io::Error::new(
955 std::io::ErrorKind::Unsupported,
956 "symlinks unsupported on this platform",
957 ))
958}
959
960/// Single-line user-visible success message, written to stderr per ADR-0001
961/// (stdio convention — the CLI never writes a success line to stdout). This
962/// is the one place where `eprintln!` is intentional; the workspace
963/// `clippy::print_stderr` lint is `warn` so the localized `#[allow]` is the
964/// minimal intervention.
965#[allow(clippy::print_stderr)]
966fn print_success(args: std::fmt::Arguments<'_>) {
967 eprintln!("{args}");
968}
969
970/// Carries a `docs/ERRORS.md` §4 process exit code out of a CLI
971/// command to `main`, which owns the actual `std::process::exit`
972/// (calling it inside `run_with_options` would kill in-process
973/// integration tests). The human-readable `error[CODE]: …` line has
974/// ALREADY been written to stderr by `render_fetch_error` before
975/// this is constructed, so `main` must NOT print it again. Issue #119.
976#[derive(Debug)]
977pub struct CliExit(pub i32);
978
979impl std::fmt::Display for CliExit {
980 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
981 write!(f, "exiting with status {}", self.0)
982 }
983}
984
985impl std::error::Error for CliExit {}
986
987/// Reclassify a `PdfLegStatus::Blocked` code at the CLI layer (issue
988/// #145 / `docs/ERRORS.md` §2 "NETWORK_ERROR" vs §3.1 / §6).
989///
990/// The core maps *every* `FetchError::Http(_)` to
991/// [`ErrorCode::NetworkError`] (`doiget_core::source`'s
992/// `From<&FetchError> for ErrorCode`). `docs/ERRORS.md` §2 defines
993/// `NETWORK_ERROR` as a transport / DNS / TLS fault where "retry usually
994/// fine" — true for a real network blip, but **false** for a deliberate
995/// supply-chain policy block (off-allowlist redirect, insecure-scheme
996/// redirect, host-blocklist hit): retrying such a block never helps, so
997/// surfacing it as `NETWORK_ERROR` (generic exit 1) misrepresents a flaky
998/// network to humans and agents.
999///
1000/// The orchestrator already preserves the true reason on the
1001/// [`DenialContext`] side-channel (the `From<&HttpError> for
1002/// Option<DenialContext>` impl walks reqwest's `source()` chain, so even
1003/// a redirect denial wrapped as `HttpError::Network` still yields
1004/// [`DenialReason::RedirectNotInAllowlist`]). When that reason is one of
1005/// the closed-set *policy* denials, promote the surface code to
1006/// [`ErrorCode::CapabilityDenied`] so the CLI renders
1007/// `error[CAPABILITY_DENIED]:` and [`cli_exit_code`] returns exit 3 —
1008/// the same code `fetch` / `graph` already use for capability denials.
1009/// Non-policy blocks (no `denial`, or a non-policy reason such as
1010/// `SizeCapExceeded` / `ContentTypeMismatch`) keep the core's code so a
1011/// genuine transport failure still reads as `NETWORK_ERROR`.
1012pub(crate) fn effective_blocked_code(code: ErrorCode, denial: Option<&DenialContext>) -> ErrorCode {
1013 match denial.map(|d| d.reason) {
1014 Some(
1015 DenialReason::RedirectNotInAllowlist
1016 | DenialReason::InsecureScheme
1017 | DenialReason::HostInBlockList,
1018 ) => ErrorCode::CapabilityDenied,
1019 _ => code,
1020 }
1021}
1022
1023/// Snake-case wire token for a [`DenialReason`], matching the
1024/// `#[serde(rename_all = "snake_case")]` JSON/MCP surface (ADR-0023 §2)
1025/// so the CLI human line uses the SAME vocabulary as the machine
1026/// envelope (`docs/ERRORS.md` §3.1). Only the policy-denial reasons the
1027/// CLI inlines are enumerated; everything else degrades to a generic
1028/// token rather than drifting from the serde form.
1029fn denial_reason_wire(reason: DenialReason) -> &'static str {
1030 match reason {
1031 DenialReason::RedirectNotInAllowlist => "redirect_not_in_allowlist",
1032 DenialReason::InsecureScheme => "insecure_scheme",
1033 DenialReason::HostInBlockList => "host_in_block_list",
1034 _ => "policy_denied",
1035 }
1036}
1037
1038/// `docs/ERRORS.md` §4 closed-code → process exit code. Anything not
1039/// individually listed falls under "at least one fetch failed" (1).
1040///
1041/// `pub(crate)` so sibling subcommands (`commands::graph`, …) route
1042/// their typed denials through the SAME centralized mapping instead of
1043/// open-coding magic exit numbers — keeps the `ErrorCode`→exit contract
1044/// single-sourced (issue #149).
1045pub(crate) fn cli_exit_code(code: ErrorCode) -> i32 {
1046 match code {
1047 ErrorCode::CapabilityDenied => 3,
1048 ErrorCode::StoreError | ErrorCode::LogError => 4,
1049 ErrorCode::FetchTimeout => 124,
1050 // A name filter that matched several entities is user-fixable by
1051 // narrowing the query → `docs/ERRORS.md` §4 exit 2 ("misuse").
1052 ErrorCode::Ambiguous => 2,
1053 // An unparsable ref is a bad argument, and §4's exit 1 is "at
1054 // least one fetch failed" — which does not describe a run where
1055 // nothing was fetched. `graph` had followed the table with a
1056 // hard-coded 2 while `fetch` and the eight #477 commands fell to
1057 // the `_ => 1` arm below, so the same input produced different
1058 // exit codes from the same binary (#492, ADR-0049).
1059 ErrorCode::InvalidRef => 2,
1060 _ => 1,
1061 }
1062}
1063
1064// `widening_suggestions` / `looks_like_public_suffix` moved to
1065// `doiget_core::remediation` in #459 so the MCP and `batch --json`
1066// surfaces render the same suggestions this block does, rather than a
1067// second implementation of them. #454 is the recent lesson about two
1068// surfaces each keeping their own copy of a rule.
1069
1070/// Build the ADR-0023 `denial_context` advisory lines shared by
1071/// [`render_fetch_error`] and [`render_blocked_error`]: the `= note:`
1072/// naming what was attempted and what the allowlist held, plus — for
1073/// `redirect_not_in_allowlist` — a `= help:` block naming the config file
1074/// and the two keys that widen the allowlist.
1075///
1076/// Issue #405: the note on its own reads as "this host is forbidden", when
1077/// what actually happened is "you have not enabled the class it belongs
1078/// to". `trust_academic_repos` and `[[network.additional_hosts]]` are the
1079/// supported fixes, so the denial names them instead of leaving the user to
1080/// find them in `CHANGELOG.md`.
1081///
1082/// Pure (returns the lines rather than printing them) so the wording is
1083/// unit-testable without capturing process stderr; `config_path` is passed
1084/// in for the same reason. `None` means the platform has no config dir, in
1085/// which case the file is named generically — a missing config dir must
1086/// never turn an advisory line into a hard error.
1087fn denial_note_lines(dc: &DenialContext, config_path: Option<&camino::Utf8Path>) -> Vec<String> {
1088 let attempted = dc.attempted.as_deref().unwrap_or("(unknown)");
1089 let mut out = vec![match &dc.expected {
1090 Some(exp) if !exp.is_empty() => {
1091 format!(
1092 " = note: attempted {attempted}; allowed: {}",
1093 exp.join(", ")
1094 )
1095 }
1096 _ => format!(" = note: attempted {attempted}"),
1097 }];
1098 if dc.reason != DenialReason::RedirectNotInAllowlist {
1099 return out;
1100 }
1101 let where_ = config_path.map_or_else(
1102 || "your doiget config.toml".to_string(),
1103 |p| p.as_str().to_string(),
1104 );
1105 out.push(format!(
1106 " = help: that host is not on the allowlist yet; widen it in {where_}"
1107 ));
1108 // #478: name the ONE flag that covers this host, not both.
1109 //
1110 // `trust_flag_for_host` already computes it, and
1111 // `remediation::for_denial` calls it -- so MCP and `batch --json`
1112 // consumers were getting the precise answer while the human was shown
1113 // two flags with nothing to choose between them, and following the
1114 // wrong one cost a round. The human has less context than the agent,
1115 // not more.
1116 //
1117 // `None` means neither flag covers the host (a genuine publisher). The
1118 // machine path offers no flag there; so does this one now, rather than
1119 // suggesting two settings that cannot possibly help.
1120 //
1121 // Three cases, and "we did not compute it" is not the same answer as
1122 // "we computed it and neither applies":
1123 match dc.attempted.as_deref() {
1124 // Known host, one flag covers it. Name that one.
1125 Some(h) => match doiget_core::remediation::trust_flag_for_host(h) {
1126 Some((flag, pattern, note)) => out.push(format!(
1127 " [network] {flag} = true # covers {pattern} ({note})"
1128 )),
1129 // Known host, neither flag covers it -- a genuine publisher.
1130 // The machine path offers no flag here (there is a test for
1131 // it: `a_publisher_host_offers_no_trust_flag`), so neither
1132 // does this one. Suggesting two settings that cannot help is
1133 // worse than saying so.
1134 None => out.push(
1135 " # neither trust_academic_repos nor trust_oa_registries covers this host"
1136 .to_string(),
1137 ),
1138 },
1139 // No host to test. Both flags stay listed, because the reason for
1140 // narrowing is absent rather than resolved.
1141 None => {
1142 out.push(
1143 " [network] trust_academic_repos = true # 15 curated academic suffixes"
1144 .to_string(),
1145 );
1146 out.push(
1147 " [network] trust_oa_registries = true # DOAJ, SciELO, Zenodo, OSF, HAL"
1148 .to_string(),
1149 );
1150 }
1151 }
1152 if dc.attempted.is_some() {
1153 for (pattern, why) in doiget_core::remediation::widening_suggestions(attempted) {
1154 out.push(format!(
1155 " [[network.additional_hosts]] host = \"{pattern}\" # {why}"
1156 ));
1157 }
1158 }
1159 out.push(" see docs/CONFIG.md §3.1 for both".to_string());
1160 out
1161}
1162
1163/// Print the [`denial_note_lines`] advisory block on stderr.
1164fn print_denial_notes(dc: &DenialContext) {
1165 for line in denial_note_lines(dc, super::user_config_path().as_deref()) {
1166 print_err(format_args!("{line}"));
1167 }
1168}
1169
1170/// Render a terminal [`FetchError`] in the `docs/ERRORS.md` §3
1171/// "Researcher (CLI human)" form: `error[CODE]: message` on stderr,
1172/// plus an actionable `= note:` line carrying the ADR-0023
1173/// `denial_context` (attempted / expected hosts) when the failure was
1174/// a denial class. stdout stays clean (ADR-0001).
1175///
1176/// `pub(crate)` so sibling resolve commands (`commands::link`, …) render
1177/// typed failures — including the actionable denial note — through the
1178/// SAME path instead of open-coding `error[CODE]: msg` and dropping the
1179/// `denial_context` note (review #287).
1180pub(crate) fn render_fetch_error(e: &FetchError) {
1181 let code: ErrorCode = e.into();
1182 print_err(format_args!("error[{}]: {}", code.as_wire(), e));
1183 if let Some(dc) = Option::<DenialContext>::from(e) {
1184 print_denial_notes(&dc);
1185 }
1186}
1187
1188/// Render a `PdfLegStatus::Blocked` outcome in the `docs/ERRORS.md` §3
1189/// "Researcher (CLI human)" form. Issue #145: an OA PDF was discovered
1190/// but could not be retrieved — the metadata WAS written, but this is a
1191/// denial, not a clean success. We emit the same `error[CODE]:` stderr
1192/// shape as [`render_fetch_error`] (so pipelines and humans see an
1193/// unambiguous failure), name the metadata path that DID land so the
1194/// partial result is still discoverable, and surface the ADR-0023
1195/// `denial_context` note when present. stdout stays clean (ADR-0001).
1196fn render_blocked_error(
1197 ref_: &Ref,
1198 outcome: &FetchPaperOutcome,
1199 code: ErrorCode,
1200 message: &str,
1201 denial: Option<&DenialContext>,
1202 suggested_arxiv_id: Option<&str>,
1203) {
1204 let label = match ref_ {
1205 Ref::Arxiv(id) => format!("arxiv:{}", id.as_str()),
1206 Ref::Doi(doi) => format!("doi:{}", doi.as_str()),
1207 };
1208 // Issue #145: when the block is a deliberate policy denial, name the
1209 // closed-set reason inline so a human/agent reading the
1210 // `error[CAPABILITY_DENIED]:` line immediately sees this is a
1211 // supply-chain policy block (retrying is futile), not a flaky network.
1212 match denial.map(|d| d.reason) {
1213 Some(
1214 reason @ (DenialReason::RedirectNotInAllowlist
1215 | DenialReason::InsecureScheme
1216 | DenialReason::HostInBlockList),
1217 ) => {
1218 print_err(format_args!(
1219 "error[{}]: {label}: an OA PDF was found but its host is blocked by \
1220 supply-chain policy ({}): {message}",
1221 code.as_wire(),
1222 denial_reason_wire(reason)
1223 ));
1224 }
1225 _ => {
1226 print_err(format_args!(
1227 "error[{}]: {label}: an OA PDF was found but could not be retrieved: {message}",
1228 code.as_wire()
1229 ));
1230 }
1231 }
1232 if let Some(dc) = denial {
1233 print_denial_notes(dc);
1234 }
1235 // The metadata TOML still landed; point the user at it so the
1236 // partial result is not lost (it is still useful), without
1237 // pretending the fetch succeeded.
1238 print_err(format_args!(
1239 " = note: metadata-only record written to {}",
1240 outcome.path
1241 ));
1242 if let Some(arxiv_id) = suggested_arxiv_id {
1243 print_err(format_args!(
1244 " = suggest: Try fetching the arXiv version: doiget fetch arxiv:{}",
1245 arxiv_id
1246 ));
1247 }
1248 for line in blocked_trace_lines(&outcome.attempts, message) {
1249 print_err(format_args!("{line}"));
1250 }
1251}
1252
1253/// The `= note:`/`= suggest:` block appended to a blocked PDF leg (#445).
1254///
1255/// #413 attached the resolution trace to `NotFound` only. But "found
1256/// nowhere" and "found at one host that refused me" raise the same next
1257/// question — *did anything else have it?* — and only the first one got an
1258/// answer. A user with five optional sources enabled saw a bare 429 and no
1259/// indication that none of the five had been consulted.
1260///
1261/// Pure so the wording is asserted rather than assumed.
1262fn blocked_trace_lines(attempts: &[SourceAttempt], message: &str) -> Vec<String> {
1263 let mut out = Vec::new();
1264 // A rate limit is the one failure where retrying the same host later is
1265 // right and reconfiguring is wrong. The bare text reads like a
1266 // permanent block, so say which it is.
1267 if message.contains("429") {
1268 out.push(
1269 " = suggest: HTTP 429 is a rate limit, not a policy block — it is transient. Retry \
1270 later, and set DOIGET_CONTACT_EMAIL for the polite pool."
1271 .to_string(),
1272 );
1273 }
1274 if attempts.is_empty() {
1275 return out;
1276 }
1277 let lead = if doiget_core::orchestrator::nothing_was_consulted(attempts) {
1278 "no other source was consulted for this DOI"
1279 } else {
1280 "the other sources were consulted and offered no alternative copy"
1281 };
1282 out.push(format!(" = note: {lead}:"));
1283 out.extend(
1284 doiget_core::orchestrator::render_attempts(attempts)
1285 .lines()
1286 .map(|l| format!(" {l}")),
1287 );
1288 out
1289}
1290
1291// ---------------------------------------------------------------------------
1292// Tests
1293// ---------------------------------------------------------------------------
1294
1295#[cfg(test)]
1296#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1297mod tests {
1298 use super::*;
1299 use serial_test::serial;
1300
1301 /// Save an env var and restore it on drop.
1302 ///
1303 /// Both client-builder tests below have to clear the
1304 /// `DOIGET_*_BASE` overrides to reach the production branch, and
1305 /// leaving one cleared would silently reroute an unrelated test.
1306 struct EnvGuard {
1307 key: &'static str,
1308 prev: Option<String>,
1309 }
1310 impl EnvGuard {
1311 fn save(key: &'static str) -> Self {
1312 Self {
1313 key,
1314 prev: std::env::var(key).ok(),
1315 }
1316 }
1317 }
1318 impl Drop for EnvGuard {
1319 fn drop(&mut self) {
1320 match &self.prev {
1321 Some(v) => std::env::set_var(self.key, v),
1322 None => std::env::remove_var(self.key),
1323 }
1324 }
1325 }
1326
1327 /// #454: the guard the list-level one in `http.rs` cannot be.
1328 ///
1329 /// `every_tier_3_source_has_a_transport_allowlist_entry` asserts that
1330 /// `tier_3_aps_allowlist()` *contains* `"tdm-aps"`. It says nothing
1331 /// about whether anything hands that list to a client, and for three
1332 /// releases nothing did — so the assertion passed while a production
1333 /// fetch returned `UnknownSource { source_key: "tdm-aps" }`.
1334 ///
1335 /// This asserts the object the fetch actually goes through. It cannot
1336 /// pass for the reason that one did, because there is no list here to
1337 /// be right about in isolation.
1338 #[test]
1339 #[serial]
1340 #[cfg(any(
1341 feature = "tdm-aps",
1342 feature = "tdm-elsevier",
1343 feature = "tdm-springer",
1344 feature = "tdm-ieee"
1345 ))]
1346 #[allow(clippy::vec_init_then_push)]
1347 fn the_production_client_registers_every_tier_3_source_key() {
1348 // Every base override must be clear or `build_http_client` takes
1349 // the test-mode branch, which registers whatever it is given and
1350 // would prove nothing.
1351 let _g: Vec<EnvGuard> = [
1352 "DOIGET_ARXIV_BASE",
1353 "DOIGET_CROSSREF_BASE",
1354 "DOIGET_UNPAYWALL_BASE",
1355 "DOIGET_OA_PUBLISHER_BASE",
1356 "DOIGET_OPENALEX_BASE",
1357 "DOIGET_AR5IV_BASE",
1358 ]
1359 .iter()
1360 .map(|k| {
1361 let g = EnvGuard::save(k);
1362 std::env::remove_var(k);
1363 g
1364 })
1365 .collect();
1366
1367 let client = build_http_client(None).expect("production client builds");
1368
1369 // Built by push rather than an array literal: with a single
1370 // `tdm-*` feature compiled the literal is a one-element loop,
1371 // which clippy denies. Same shape as `tier_3_allowlists()`,
1372 // and the `vec_init_then_push` allow is on the fn for the same
1373 // reason it is there — the pushes are `#[cfg]`-gated, so an
1374 // attribute per element is not expressible.
1375 let mut keys: Vec<&str> = Vec::new();
1376 #[cfg(feature = "tdm-aps")]
1377 keys.push("tdm-aps");
1378 #[cfg(feature = "tdm-elsevier")]
1379 keys.push("tdm-elsevier");
1380 #[cfg(feature = "tdm-springer")]
1381 keys.push("tdm-springer");
1382 #[cfg(feature = "tdm-ieee")]
1383 keys.push("tdm-ieee");
1384 assert!(!keys.is_empty(), "the guard must have checked something");
1385 for key in keys {
1386 assert!(
1387 client.source_allowlist(key).is_some(),
1388 "the production client has no allowlist for `{key}`; the orchestrator \
1389 reaches this source and the fetch would die at UnknownSource (#454)"
1390 );
1391 }
1392 }
1393
1394 /// #516: the Tier-2 half of the same guard, and the reason it is
1395 /// needed is that the Tier-3 lesson was not applied one tier up.
1396 ///
1397 /// `every_tier_2_source_has_a_transport_allowlist_entry` (in
1398 /// `http.rs`) asserts that `tier_2_allowlist()` *contains* every
1399 /// source key. That stayed true while the extend into the production
1400 /// client was gated on `citation` rather than `metadata`, so in a
1401 /// `--features metadata` build — which CI's clippy matrix builds
1402 /// explicitly — `resolve_optional_chain` ran, `can_serve` passed,
1403 /// and the request died at `UnknownSource`.
1404 ///
1405 /// This asserts the object the fetch goes through, and it enumerates
1406 /// from `tier_2_allowlist()` rather than a literal so a new source
1407 /// cannot be added to the list and missed here.
1408 #[test]
1409 #[serial]
1410 #[cfg(feature = "metadata")]
1411 fn the_production_client_registers_every_tier_2_source_key() {
1412 // Every base override must be clear or `build_http_client` takes
1413 // the test-mode branch, which registers whatever it is given and
1414 // would prove nothing.
1415 let _g: Vec<EnvGuard> = [
1416 "DOIGET_ARXIV_BASE",
1417 "DOIGET_CROSSREF_BASE",
1418 "DOIGET_UNPAYWALL_BASE",
1419 "DOIGET_OA_PUBLISHER_BASE",
1420 "DOIGET_OPENALEX_BASE",
1421 "DOIGET_AR5IV_BASE",
1422 ]
1423 .iter()
1424 .map(|k| {
1425 let g = EnvGuard::save(k);
1426 std::env::remove_var(k);
1427 g
1428 })
1429 .collect();
1430
1431 let client = build_http_client(None).expect("production client builds");
1432
1433 // Fully qualified on purpose: the `use` at the top of this file is
1434 // itself `#[cfg(feature = "metadata")]`, so importing it here would
1435 // turn a regression into a compile error in this file rather than the
1436 // assertion failure that names the actual defect.
1437 let keys: Vec<String> = doiget_core::http::tier_2_allowlist()
1438 .iter()
1439 .map(|a| a.source.clone())
1440 .collect();
1441 assert!(!keys.is_empty(), "the guard must have checked something");
1442 for key in keys {
1443 assert!(
1444 client.source_allowlist(&key).is_some(),
1445 "the production client has no allowlist for `{key}`; \n `resolve_optional_chain` reaches this source in a \n `metadata` build and the fetch would die at \n UnknownSource (#516)"
1446 );
1447 }
1448 }
1449
1450 #[test]
1451 fn new_session_id_is_26_chars() {
1452 // ULID textual form is fixed-width 26 chars (Crockford base32).
1453 // `docs/PROVENANCE_LOG.md` §3 requires this exact length.
1454 let id = new_session_id();
1455 assert_eq!(id.len(), 26, "session id must be 26 chars: {:?}", id);
1456 // Crockford base32 uses uppercase letters and digits; specifically
1457 // I, L, O, U are excluded. Every char must be ASCII alphanumeric.
1458 assert!(
1459 id.chars().all(|c| c.is_ascii_alphanumeric()),
1460 "ulid must be ASCII alphanumeric: {:?}",
1461 id
1462 );
1463 }
1464
1465 /// Review pass C2: end-to-end coverage of the user-extension
1466 /// merge inside `build_http_client`. Without this test the
1467 /// production path that turns a `config.toml`
1468 /// `[[network.additional_hosts]]` entry into a passing
1469 /// allowlist match is unexercised — every existing e2e sets
1470 /// `DOIGET_*_BASE` and short-circuits into the test-mode
1471 /// builder above.
1472 #[test]
1473 #[serial]
1474 fn build_http_client_merges_user_extension_into_oa_publisher_allowlist() {
1475 use std::io::Write;
1476
1477 // Construct a tempdir + minimal config.toml under it.
1478 let td = tempfile::TempDir::new().expect("tempdir");
1479 let cfg_dir = td.path().join("doiget");
1480 std::fs::create_dir_all(&cfg_dir).expect("mkdir doiget/");
1481 let cfg_path = cfg_dir.join("config.toml");
1482 let mut f = std::fs::File::create(&cfg_path).expect("create config.toml");
1483 f.write_all(
1484 br#"
1485[[network.additional_hosts]]
1486host = "ruj.uj.edu.pl"
1487note = "Jagiellonian"
1488
1489[[network.additional_hosts]]
1490host = "*.uj.edu.pl"
1491"#,
1492 )
1493 .expect("write config.toml");
1494 drop(f);
1495
1496 // Save + override env so `config_dir_utf8()` lands on the
1497 // tempdir. Restored on Drop by `EnvGuard` (module-level since
1498 // #454, which needed the same save/restore). We also clear the
1499 // five `DOIGET_*_BASE` env vars to force the production
1500 // branch of `build_http_client`.
1501 let _g0 = EnvGuard::save("XDG_CONFIG_HOME");
1502 let _g1 = EnvGuard::save("APPDATA");
1503 let _g2 = EnvGuard::save("HOME");
1504 let _g3 = EnvGuard::save("USERPROFILE");
1505 let _g4 = EnvGuard::save("DOIGET_ARXIV_BASE");
1506 let _g5 = EnvGuard::save("DOIGET_CROSSREF_BASE");
1507 let _g6 = EnvGuard::save("DOIGET_UNPAYWALL_BASE");
1508 let _g7 = EnvGuard::save("DOIGET_OA_PUBLISHER_BASE");
1509 let _g8 = EnvGuard::save("DOIGET_OPENALEX_BASE");
1510 std::env::set_var("XDG_CONFIG_HOME", td.path());
1511 std::env::set_var("APPDATA", td.path());
1512 std::env::set_var("HOME", td.path());
1513 std::env::set_var("USERPROFILE", td.path());
1514 std::env::remove_var("DOIGET_ARXIV_BASE");
1515 std::env::remove_var("DOIGET_CROSSREF_BASE");
1516 std::env::remove_var("DOIGET_UNPAYWALL_BASE");
1517 std::env::remove_var("DOIGET_OA_PUBLISHER_BASE");
1518 std::env::remove_var("DOIGET_OPENALEX_BASE");
1519
1520 let client = build_http_client(None).expect("HttpClient builds");
1521 let oa = client
1522 .source_allowlist("oa-publisher")
1523 .expect("oa-publisher source registered");
1524
1525 // Pre-existing curated allowlist still effective.
1526 assert!(
1527 oa.redirect_hosts.iter().any(|p| p == "*.aps.org"),
1528 "curated *.aps.org MUST still be present after merge; got {:?}",
1529 oa.redirect_hosts
1530 );
1531 // User-added literal host passes match.
1532 assert!(
1533 oa.matches("ruj.uj.edu.pl"),
1534 "literal `ruj.uj.edu.pl` from user config MUST match"
1535 );
1536 // User-added wildcard passes match for a subdomain.
1537 assert!(
1538 oa.matches("alpha.uj.edu.pl"),
1539 "wildcard `*.uj.edu.pl` from user config MUST match alpha.uj.edu.pl"
1540 );
1541 // Unrelated host MUST still fail.
1542 assert!(
1543 !oa.matches("ruj.uj.edu.ru"),
1544 "host outside the suffix MUST NOT match"
1545 );
1546 }
1547
1548 /// Issue #405: `[network] trust_oa_registries = true` MUST widen the
1549 /// production `oa-publisher` allowlist through the same
1550 /// `build_http_client` path a real fetch takes — the flag is worthless
1551 /// if it only sets a struct field. Pinned on the exact host that
1552 /// denied the reported Gold-OA fetch (`doaj.org`, an apex, which a
1553 /// single-suffix wildcard would NOT cover), and on the academic flag
1554 /// staying off so the two sets cannot silently imply each other.
1555 #[test]
1556 #[serial]
1557 fn build_http_client_merges_oa_registries_when_flag_is_set() {
1558 use std::io::Write;
1559
1560 let td = tempfile::TempDir::new().expect("tempdir");
1561 let cfg_dir = td.path().join("doiget");
1562 std::fs::create_dir_all(&cfg_dir).expect("mkdir doiget/");
1563 let mut f = std::fs::File::create(cfg_dir.join("config.toml")).expect("create config");
1564 f.write_all(b"[network]\ntrust_oa_registries = true\n")
1565 .expect("write config.toml");
1566 drop(f);
1567
1568 struct EnvGuard {
1569 key: &'static str,
1570 prev: Option<String>,
1571 }
1572 impl EnvGuard {
1573 fn save(key: &'static str) -> Self {
1574 Self {
1575 key,
1576 prev: std::env::var(key).ok(),
1577 }
1578 }
1579 }
1580 impl Drop for EnvGuard {
1581 fn drop(&mut self) {
1582 match &self.prev {
1583 Some(v) => std::env::set_var(self.key, v),
1584 None => std::env::remove_var(self.key),
1585 }
1586 }
1587 }
1588 let _g: Vec<EnvGuard> = [
1589 "XDG_CONFIG_HOME",
1590 "APPDATA",
1591 "HOME",
1592 "USERPROFILE",
1593 "DOIGET_ARXIV_BASE",
1594 "DOIGET_CROSSREF_BASE",
1595 "DOIGET_UNPAYWALL_BASE",
1596 "DOIGET_OA_PUBLISHER_BASE",
1597 "DOIGET_OPENALEX_BASE",
1598 ]
1599 .iter()
1600 .map(|k| EnvGuard::save(k))
1601 .collect();
1602 for k in ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"] {
1603 std::env::set_var(k, td.path());
1604 }
1605 for k in [
1606 "DOIGET_ARXIV_BASE",
1607 "DOIGET_CROSSREF_BASE",
1608 "DOIGET_UNPAYWALL_BASE",
1609 "DOIGET_OA_PUBLISHER_BASE",
1610 "DOIGET_OPENALEX_BASE",
1611 ] {
1612 std::env::remove_var(k);
1613 }
1614
1615 let client = build_http_client(None).expect("HttpClient builds");
1616 let oa = client
1617 .source_allowlist("oa-publisher")
1618 .expect("oa-publisher source registered");
1619
1620 // ADR-0037: DOAJ is a DEFAULT allowlist entry now, so it is not
1621 // evidence that the flag worked. Assert on a host only the flag can
1622 // provide.
1623 assert!(
1624 oa.matches("zenodo.org"),
1625 "the zenodo apex must match with the flag set; got {:?}",
1626 oa.redirect_hosts
1627 );
1628 assert!(oa.matches("data.zenodo.org"), "wildcard covers subdomains");
1629 assert!(oa.matches("hal.science"), "hal apex must match");
1630 assert!(
1631 oa.redirect_hosts.iter().any(|p| p == "*.aps.org"),
1632 "the curated allowlist MUST survive the merge"
1633 );
1634 // The academic flag was NOT set, so its set must NOT be merged —
1635 // otherwise one flag silently grants what the other advertises.
1636 assert!(
1637 !oa.matches("strathprints.strath.ac.uk"),
1638 "trust_oa_registries MUST NOT imply trust_academic_repos"
1639 );
1640 assert!(
1641 !oa.matches("evil.example.com"),
1642 "unrelated host still denied"
1643 );
1644 }
1645
1646 /// ADR-0031 D2: discovery search (`doiget search`) ships in the default
1647 /// `oa-only` binary, so `api.openalex.org` MUST be on the production
1648 /// allowlist under the `"openalex"` source key WITHOUT `--features
1649 /// metadata`. The Tier-2 `tier_2_allowlist()` extend is
1650 /// `#[cfg(feature = "metadata")]` (#516 moved it off `citation`);
1651 /// this test proves `discovery_allowlist()` covers that gap in the
1652 /// shipped `oa-only` build, where neither feature is compiled.
1653 #[test]
1654 #[serial]
1655 fn build_http_client_registers_openalex_for_discovery() {
1656 struct EnvGuard {
1657 key: &'static str,
1658 prev: Option<String>,
1659 }
1660 impl EnvGuard {
1661 fn save(key: &'static str) -> Self {
1662 Self {
1663 key,
1664 prev: std::env::var(key).ok(),
1665 }
1666 }
1667 }
1668 impl Drop for EnvGuard {
1669 fn drop(&mut self) {
1670 match &self.prev {
1671 Some(v) => std::env::set_var(self.key, v),
1672 None => std::env::remove_var(self.key),
1673 }
1674 }
1675 }
1676
1677 // Point config resolution at an empty tempdir and clear every
1678 // `DOIGET_*_BASE` so `build_http_client` takes the PRODUCTION
1679 // branch (not the test-base builder, which would register
1680 // "openalex" itself and mask the gap this test guards).
1681 let td = tempfile::TempDir::new().expect("tempdir");
1682 let _g0 = EnvGuard::save("XDG_CONFIG_HOME");
1683 let _g1 = EnvGuard::save("APPDATA");
1684 let _g2 = EnvGuard::save("HOME");
1685 let _g3 = EnvGuard::save("USERPROFILE");
1686 let _g4 = EnvGuard::save("DOIGET_ARXIV_BASE");
1687 let _g5 = EnvGuard::save("DOIGET_CROSSREF_BASE");
1688 let _g6 = EnvGuard::save("DOIGET_UNPAYWALL_BASE");
1689 let _g7 = EnvGuard::save("DOIGET_OA_PUBLISHER_BASE");
1690 let _g8 = EnvGuard::save("DOIGET_OPENALEX_BASE");
1691 std::env::set_var("XDG_CONFIG_HOME", td.path());
1692 std::env::set_var("APPDATA", td.path());
1693 std::env::set_var("HOME", td.path());
1694 std::env::set_var("USERPROFILE", td.path());
1695 std::env::remove_var("DOIGET_ARXIV_BASE");
1696 std::env::remove_var("DOIGET_CROSSREF_BASE");
1697 std::env::remove_var("DOIGET_UNPAYWALL_BASE");
1698 std::env::remove_var("DOIGET_OA_PUBLISHER_BASE");
1699 std::env::remove_var("DOIGET_OPENALEX_BASE");
1700
1701 let client = build_http_client(None).expect("HttpClient builds");
1702 let oa = client
1703 .source_allowlist("openalex")
1704 .expect("openalex source registered for discovery (ADR-0031 D2)");
1705 assert!(
1706 oa.matches("api.openalex.org"),
1707 "api.openalex.org MUST be on the discovery allowlist; got {:?}",
1708 oa.redirect_hosts
1709 );
1710 }
1711
1712 // Slice 2: the `extract_crossref_fields_*` unit tests moved to
1713 // `doiget_core::orchestrator::tests` along with the function they
1714 // covered. The CLI no longer owns those helpers; the marker test
1715 // below keeps the CLI's `fetch::tests` non-empty after the helper
1716 // migration so a future regression that nukes the delegation path
1717 // surfaces as a build failure (the `FetchPaperOutcome` re-import
1718 // would stop resolving).
1719 #[test]
1720 fn fetch_paper_outcome_is_reachable_from_cli() {
1721 let _ = std::any::type_name::<doiget_core::orchestrator::FetchPaperOutcome>();
1722 }
1723
1724 #[test]
1725 fn ambiguous_maps_to_exit_code_2() {
1726 // ADR-0031 D5: a name-filter ambiguity is user-fixable → exit 2,
1727 // distinct from the generic exit 1.
1728 assert_eq!(cli_exit_code(ErrorCode::Ambiguous), 2);
1729 }
1730
1731 #[test]
1732 fn invalid_ref_maps_to_exit_code_2() {
1733 // ADR-0049: an unparsable ref is misuse. `docs/ERRORS.md` §4
1734 // reserves 1 for "at least one fetch was attempted and failed",
1735 // and nothing is fetched here. `Ambiguous` — a value that fails
1736 // to select one entity — was already 2; `InvalidRef` is a value
1737 // that fails to parse, and sat at the catch-all 1 next to it.
1738 assert_eq!(cli_exit_code(ErrorCode::InvalidRef), 2);
1739 }
1740
1741 /// Minimal `DenialContext` carrying only `reason`; every other field
1742 /// is optional (ADR-0023 §3) so `None`/empty is a valid producer
1743 /// shape for the reclassification decision under test.
1744 fn denial(reason: DenialReason) -> DenialContext {
1745 DenialContext {
1746 reason,
1747 source: None,
1748 attempted: None,
1749 expected: None,
1750 hop_index: None,
1751 cap: None,
1752 actual: None,
1753 }
1754 }
1755
1756 /// Issue #145 / `docs/ERRORS.md` §6.1: a policy-class denial reason
1757 /// on a `Blocked` OA-PDF leg must be reclassified from the core's
1758 /// blanket `NetworkError` to `CapabilityDenied` at the CLI layer, so
1759 /// the user-facing exit becomes 3 (not the generic 1) and a flaky
1760 /// network is not implied for a deliberate supply-chain block.
1761 #[test]
1762 fn policy_denials_reclassify_network_error_to_capability_denied() {
1763 for r in [
1764 DenialReason::RedirectNotInAllowlist,
1765 DenialReason::InsecureScheme,
1766 DenialReason::HostInBlockList,
1767 ] {
1768 let d = denial(r);
1769 assert_eq!(
1770 effective_blocked_code(ErrorCode::NetworkError, Some(&d)),
1771 ErrorCode::CapabilityDenied,
1772 "policy reason {r:?} must promote NetworkError -> CapabilityDenied"
1773 );
1774 assert_eq!(
1775 cli_exit_code(effective_blocked_code(ErrorCode::NetworkError, Some(&d))),
1776 3,
1777 "policy reason {r:?} must map to exit 3 (docs/ERRORS.md §4/§6.1)"
1778 );
1779 }
1780 }
1781
1782 /// A genuine transport fault carries NO `DenialContext`; it must stay
1783 /// `NetworkError` / exit 1 — `docs/ERRORS.md` §2 "retry usually fine"
1784 /// is the correct signal there. (This is exactly the e2e
1785 /// `..._host_off_allowlist` path: first-leg connect failure, no
1786 /// redirect hop, so no allowlist denial is produced.)
1787 #[test]
1788 fn absent_denial_context_keeps_network_error() {
1789 assert_eq!(
1790 effective_blocked_code(ErrorCode::NetworkError, None),
1791 ErrorCode::NetworkError
1792 );
1793 assert_eq!(
1794 cli_exit_code(effective_blocked_code(ErrorCode::NetworkError, None)),
1795 1
1796 );
1797 }
1798
1799 /// Non-policy denial reasons (size cap, content-type mismatch) are
1800 /// NOT supply-chain policy blocks; they keep the core's code so a
1801 /// genuine cap/transport class is not masked as a capability denial.
1802 #[test]
1803 fn non_policy_denials_keep_core_code() {
1804 for r in [
1805 DenialReason::SizeCapExceeded,
1806 DenialReason::ContentTypeMismatch,
1807 ] {
1808 let d = denial(r);
1809 assert_eq!(
1810 effective_blocked_code(ErrorCode::NetworkError, Some(&d)),
1811 ErrorCode::NetworkError,
1812 "non-policy reason {r:?} must NOT be reclassified"
1813 );
1814 }
1815 }
1816
1817 /// The closed-set wire token used in the human `error[...]:` line
1818 /// must match the serde `snake_case` form so the CLI vocabulary does
1819 /// not drift from the JSON/MCP envelope (`docs/ERRORS.md` §3.1).
1820 #[test]
1821 fn denial_reason_wire_matches_serde_snake_case() {
1822 for r in [
1823 DenialReason::RedirectNotInAllowlist,
1824 DenialReason::InsecureScheme,
1825 DenialReason::HostInBlockList,
1826 ] {
1827 let serde_form = serde_json::to_string(&r).expect("serialize DenialReason");
1828 // serde_json wraps the enum unit variant in quotes.
1829 let serde_token = serde_form.trim_matches('"');
1830 assert_eq!(
1831 denial_reason_wire(r),
1832 serde_token,
1833 "CLI wire token for {r:?} must equal the serde snake_case form"
1834 );
1835 }
1836 }
1837
1838 /// The `= help:` line names a file for the user to edit, so it MUST be
1839 /// the file `build_http_client` actually reads. `user_config_path` used
1840 /// `dirs::config_dir()`, which ignores `XDG_CONFIG_HOME` on Windows —
1841 /// so on a machine with cross-platform dotfiles the denial pointed at a
1842 /// `config.toml` the fetch path never opened. Naming the wrong file is
1843 /// worse than naming none.
1844 #[test]
1845 #[serial]
1846 fn denial_help_names_the_file_the_reader_loads() {
1847 struct EnvGuard(&'static str, Option<String>);
1848 impl Drop for EnvGuard {
1849 fn drop(&mut self) {
1850 match &self.1 {
1851 Some(v) => std::env::set_var(self.0, v),
1852 None => std::env::remove_var(self.0),
1853 }
1854 }
1855 }
1856 let td = tempfile::TempDir::new().expect("tempdir");
1857 let _g: Vec<EnvGuard> = ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
1858 .iter()
1859 .map(|k| EnvGuard(k, std::env::var(k).ok()))
1860 .collect();
1861 std::env::set_var("XDG_CONFIG_HOME", td.path());
1862
1863 let reader = super::config_dir_utf8()
1864 .expect("reader resolves")
1865 .join("doiget")
1866 .join("config.toml");
1867 let helped = crate::commands::user_config_path().expect("help path resolves");
1868 assert_eq!(
1869 helped, reader,
1870 "the denial help must name the config.toml the reader loads"
1871 );
1872
1873 let mut dc = denial(DenialReason::RedirectNotInAllowlist);
1874 dc.attempted = Some("strathprints.strath.ac.uk".to_string());
1875 let joined = denial_note_lines(&dc, Some(helped.as_path())).join("\n");
1876 assert!(
1877 joined.contains(reader.as_str()),
1878 "rendered help must carry that path; got:\n{joined}"
1879 );
1880 }
1881
1882 // ── #405: the denial must name the knob that unblocks it ─────────────
1883
1884 /// A `redirect_not_in_allowlist` denial is not "this host is forbidden",
1885 /// it is "you have not enabled the class it belongs to". The advisory
1886 /// block MUST name the config file and BOTH supported keys, and echo the
1887 /// attempted host into the `additional_hosts` line so the fix is
1888 /// copy-pasteable (issue #405).
1889 #[test]
1890 fn redirect_denial_names_both_allowlist_keys_and_the_config_file() {
1891 let mut dc = denial(DenialReason::RedirectNotInAllowlist);
1892 dc.attempted = Some("strathprints.strath.ac.uk".to_string());
1893 dc.expected = Some(vec!["*.springer.com".to_string()]);
1894
1895 let cfg = camino::Utf8PathBuf::from("/home/alice/.config/doiget/config.toml");
1896 let lines = denial_note_lines(&dc, Some(cfg.as_path()));
1897 let joined = lines.join("\n");
1898
1899 assert!(
1900 joined.contains("attempted strathprints.strath.ac.uk; allowed: *.springer.com"),
1901 "the pre-existing note must survive; got:\n{joined}"
1902 );
1903 assert!(
1904 joined.contains("trust_academic_repos = true"),
1905 "the curated-set knob must be named; got:\n{joined}"
1906 );
1907 assert!(
1908 joined.contains("[[network.additional_hosts]] host = \"strathprints.strath.ac.uk\""),
1909 "the per-host escape hatch must echo the attempted host; got:\n{joined}"
1910 );
1911 assert!(
1912 joined.contains("/home/alice/.config/doiget/config.toml"),
1913 "the file the user must edit must be named; got:\n{joined}"
1914 );
1915 assert!(
1916 joined.contains("docs/CONFIG.md §3.1"),
1917 "the schema section must be named; got:\n{joined}"
1918 );
1919 }
1920
1921 /// #478. Only ONE of the two flags covers any given host, and
1922 /// `remediation::trust_flag_for_host` already computes which -- so the
1923 /// MCP and `batch --json` consumers got the precise answer while the
1924 /// human was shown both with nothing to choose between them.
1925 #[test]
1926 fn the_help_names_only_the_trust_flag_that_covers_the_host() {
1927 let mut dc = denial(DenialReason::RedirectNotInAllowlist);
1928 dc.attempted = Some("strathprints.strath.ac.uk".to_string());
1929 let joined = denial_note_lines(&dc, None).join(
1930 "
1931",
1932 );
1933
1934 assert!(
1935 joined.contains("trust_academic_repos = true"),
1936 "an *.ac.uk host is covered by the academic list; got:
1937{joined}"
1938 );
1939 assert!(
1940 !joined.contains("trust_oa_registries"),
1941 "trust_oa_registries does nothing for this host and must not be offered; got:
1942{joined}"
1943 );
1944 assert!(
1945 joined.contains("*.ac.uk"),
1946 "naming the pattern is what makes the suggestion checkable; got:
1947{joined}"
1948 );
1949 }
1950
1951 /// And when neither covers it -- a genuine publisher host -- the human
1952 /// is told so rather than handed two settings that cannot help. The
1953 /// machine path already behaved this way
1954 /// (`a_publisher_host_offers_no_trust_flag` in `doiget-core`).
1955 #[test]
1956 fn a_publisher_host_is_offered_no_trust_flag_in_the_human_help() {
1957 let mut dc = denial(DenialReason::RedirectNotInAllowlist);
1958 dc.attempted = Some("link.springer.com".to_string());
1959 let joined = denial_note_lines(&dc, None).join(
1960 "
1961",
1962 );
1963
1964 assert!(
1965 !joined.contains("trust_academic_repos = true"),
1966 "neither flag covers a publisher host; got:
1967{joined}"
1968 );
1969 assert!(
1970 !joined.contains("trust_oa_registries = true"),
1971 "neither flag covers a publisher host; got:
1972{joined}"
1973 );
1974 assert!(
1975 joined.contains("neither trust_academic_repos nor trust_oa_registries"),
1976 "saying so is the point -- silence would read as an omission; got:
1977{joined}"
1978 );
1979 // The per-host escape hatch is still the real answer here.
1980 assert!(
1981 joined.contains("additional_hosts]] host = \"link.springer.com\""),
1982 "got:
1983{joined}"
1984 );
1985 }
1986
1987 /// The help block is specific to the allowlist. Other denial classes
1988 /// (an insecure scheme, a blocklisted host) are NOT fixed by widening
1989 /// the allowlist, so pointing at `trust_academic_repos` there would be
1990 /// actively misleading — they keep the bare `= note:`.
1991 #[test]
1992 fn non_allowlist_denials_get_no_allowlist_help() {
1993 for reason in [DenialReason::InsecureScheme, DenialReason::HostInBlockList] {
1994 let mut dc = denial(reason);
1995 dc.attempted = Some("evil.example.com".to_string());
1996 let lines = denial_note_lines(&dc, None);
1997 assert_eq!(
1998 lines.len(),
1999 1,
2000 "{reason:?} must emit the note only, got: {lines:?}"
2001 );
2002 assert!(
2003 !lines[0].contains("trust_academic_repos"),
2004 "{reason:?} is not fixed by widening the allowlist: {lines:?}"
2005 );
2006 }
2007 }
2008
2009 /// A platform with no config dir still gets both keys — the advisory
2010 /// degrades to a generic file name rather than being suppressed, and
2011 /// `attempted: None` drops only the host-specific line.
2012 #[test]
2013 fn redirect_denial_help_degrades_without_config_dir_or_host() {
2014 let lines = denial_note_lines(&denial(DenialReason::RedirectNotInAllowlist), None);
2015 let joined = lines.join("\n");
2016 assert!(joined.contains("your doiget config.toml"), "{joined}");
2017 assert!(joined.contains("trust_academic_repos = true"), "{joined}");
2018 assert!(
2019 !joined.contains("additional_hosts]] host ="),
2020 "no attempted host means no copy-pasteable host line; got:\n{joined}"
2021 );
2022 }
2023
2024 // ── #344 Slice 2: --link helpers ──────────────────────────────────────
2025
2026 #[test]
2027 fn slugify_lowercases_and_collapses_non_alnum() {
2028 assert_eq!(
2029 slugify("Attention Is All You Need"),
2030 "attention-is-all-you-need"
2031 );
2032 assert_eq!(slugify("Foo/Bar: Baz!!"), "foo-bar-baz");
2033 assert_eq!(slugify(" spaced "), "spaced");
2034 assert_eq!(slugify("!!!"), ""); // no alphanumerics → empty
2035 }
2036
2037 #[test]
2038 fn fetch_link_filename_builds_readable_name() {
2039 let name = fetch_link_filename(
2040 "Attention Is All You Need",
2041 &["Ashish Vaswani".to_string()],
2042 Some(2017),
2043 "arxiv_1706.03762",
2044 );
2045 assert_eq!(name, "vaswani2017-attention-is-all-you-need.pdf");
2046 }
2047
2048 #[test]
2049 fn fetch_link_filename_falls_back_to_safekey() {
2050 // No usable metadata (empty title, no authors/year) → safekey.pdf.
2051 assert_eq!(
2052 fetch_link_filename("", &[], None, "doi_10.1234_x"),
2053 "doi_10.1234_x.pdf"
2054 );
2055 // A title that slugifies to nothing also falls back.
2056 assert_eq!(
2057 fetch_link_filename("…—", &[], None, "doi_10.1234_y"),
2058 "doi_10.1234_y.pdf"
2059 );
2060 }
2061
2062 #[test]
2063 fn link_artifact_creates_readable_artifact() {
2064 let td = tempfile::TempDir::new().expect("tempdir");
2065 let dir = camino::Utf8Path::from_path(td.path()).expect("utf8");
2066 let src = dir.join("src.pdf");
2067 std::fs::write(src.as_std_path(), b"%PDF-DATA").expect("write src");
2068
2069 let (dst, _kind) = link_artifact(dir, &src, "out.pdf").expect("link");
2070 assert!(dst.exists(), "linked artifact must exist: {dst}");
2071 assert_eq!(
2072 std::fs::read(dst.as_std_path()).expect("read dst"),
2073 b"%PDF-DATA",
2074 "linked artifact (symlink or copy) must resolve to the source bytes"
2075 );
2076 }
2077
2078 #[test]
2079 fn link_artifact_refuses_to_clobber_unrelated_file() {
2080 let td = tempfile::TempDir::new().expect("tempdir");
2081 let dir = camino::Utf8Path::from_path(td.path()).expect("utf8");
2082 let src = dir.join("src.pdf");
2083 std::fs::write(src.as_std_path(), b"%PDF-DATA").expect("write src");
2084 // A pre-existing, unrelated regular file at the target name.
2085 let taken = dir.join("taken.pdf");
2086 std::fs::write(taken.as_std_path(), b"USER-DATA").expect("write taken");
2087
2088 let err = link_artifact(dir, &src, "taken.pdf").expect_err("must refuse");
2089 assert!(
2090 err.to_string().contains("refusing to overwrite"),
2091 "error must explain the refusal: {err}"
2092 );
2093 assert_eq!(
2094 std::fs::read(taken.as_std_path()).expect("read taken"),
2095 b"USER-DATA",
2096 "the user's file must be left untouched"
2097 );
2098 }
2099 /// #443, the reported case: `www.ams.org -> pubs.ams.org` cost two
2100 /// edit-run cycles because the help named only the hop that failed.
2101 #[test]
2102 fn a_refused_hop_also_offers_the_registrable_domain() {
2103 let mut dc = denial(DenialReason::RedirectNotInAllowlist);
2104 dc.attempted = Some("pubs.ams.org".to_string());
2105 let joined = denial_note_lines(&dc, None).join("\n");
2106
2107 assert!(joined.contains(r#"host = "pubs.ams.org""#), "{joined}");
2108 assert!(
2109 joined.contains(r#"host = "*.ams.org""#),
2110 "the whole-publisher wildcard is what ends the loop in one step:\n{joined}"
2111 );
2112 assert!(
2113 joined.contains(r#"host = "ams.org""#),
2114 "a single-suffix wildcard does not match the apex, so offer it too:\n{joined}"
2115 );
2116 }
2117
2118 /// A suggestion the config parser would reject is worse than none: the
2119 /// user pastes it and gets a second, more confusing error.
2120 #[test]
2121 fn every_suggestion_is_a_pattern_the_validator_accepts() {
2122 for host in [
2123 "pubs.ams.org",
2124 "www.ams.org",
2125 "ams.org",
2126 "strathprints.strath.ac.uk",
2127 "repository.ruj.uj.edu.pl",
2128 "link.springer.com",
2129 ] {
2130 for (pattern, _) in doiget_core::remediation::widening_suggestions(host) {
2131 doiget_core::user_extension::validate_pattern(&pattern).unwrap_or_else(|e| {
2132 panic!("suggested `{pattern}` for `{host}`, which the validator rejects: {e:?}")
2133 });
2134 }
2135 }
2136 }
2137
2138 /// The one suggestion that must never appear. Deriving the registrable
2139 /// domain by stripping a label is right for `pubs.ams.org` and very
2140 /// wrong for `foo.co.uk` — trusting `*.co.uk` is trusting a whole
2141 /// country's registry.
2142 #[test]
2143 fn a_public_suffix_is_never_offered() {
2144 for (host, forbidden) in [
2145 ("foo.co.uk", "co.uk"),
2146 ("foo.ac.jp", "ac.jp"),
2147 ("foo.com.au", "com.au"),
2148 ("example.org", "org"),
2149 ] {
2150 let joined: String = doiget_core::remediation::widening_suggestions(host)
2151 .into_iter()
2152 .map(|(p, _)| p)
2153 .collect::<Vec<_>>()
2154 .join(" ");
2155 assert!(
2156 !joined
2157 .split(' ')
2158 .any(|p| p == forbidden || p == format!("*.{forbidden}")),
2159 "offered the public suffix `{forbidden}` for `{host}`: {joined}"
2160 );
2161 }
2162 }
2163
2164 /// `strathprints.strath.ac.uk` — four labels, so the parent
2165 /// `strath.ac.uk` is a real registration, not a public suffix.
2166 #[test]
2167 fn a_four_label_academic_host_still_gets_its_institution_wildcard() {
2168 let got: Vec<String> =
2169 doiget_core::remediation::widening_suggestions("strathprints.strath.ac.uk")
2170 .into_iter()
2171 .map(|(p, _)| p)
2172 .collect();
2173 assert!(
2174 got.iter().any(|p| p == "*.strath.ac.uk"),
2175 "expected the institution wildcard; got {got:?}"
2176 );
2177 }
2178
2179 /// An apex host has no parent worth naming; the useful widening is
2180 /// downward.
2181 #[test]
2182 fn an_apex_host_offers_its_subdomains() {
2183 let got: Vec<String> = doiget_core::remediation::widening_suggestions("ams.org")
2184 .into_iter()
2185 .map(|(p, _)| p)
2186 .collect();
2187 assert_eq!(got, vec!["ams.org".to_string(), "*.ams.org".to_string()]);
2188 }
2189 /// #445: a 429 reads like a permanent block. It is the one failure
2190 /// where retrying the same host later is right and reconfiguring is
2191 /// wrong, so the message has to say which it is.
2192 #[test]
2193 fn a_rate_limited_block_says_the_limit_is_transient() {
2194 let joined = blocked_trace_lines(&[], "network error: HTTP 429 from https://ams.org/x.pdf")
2195 .join("\n");
2196 assert!(joined.contains("429"), "{joined}");
2197 assert!(joined.contains("transient"), "{joined}");
2198 assert!(
2199 joined.contains("Retry later"),
2200 "say what to DO, not just what happened:\n{joined}"
2201 );
2202 // A lost `\` line continuation leaves the source indentation
2203 // inside the literal, and every test above still passes because
2204 // each only asserts `contains`. Nothing in this block is
2205 // column-aligned, so an internal double space is that bug.
2206 for line in blocked_trace_lines(&[], "network error: HTTP 429 from https://ams.org/x.pdf") {
2207 assert!(
2208 !line.trim_start().contains(" "),
2209 "a lost line continuation left source indentation in the message:\n{line}"
2210 );
2211 }
2212 }
2213
2214 /// The converse: a policy denial must not be described as transient,
2215 /// or the user retries forever instead of editing the allowlist.
2216 #[test]
2217 fn a_policy_block_is_not_described_as_transient() {
2218 let joined =
2219 blocked_trace_lines(&[], "redirect target x.example not in allowlist").join("\n");
2220 assert!(
2221 !joined.contains("transient"),
2222 "an allowlist denial is permanent until reconfigured:\n{joined}"
2223 );
2224 }
2225
2226 /// The half of #445 that the #413 trace already answered for
2227 /// `NotFound`: *did anything else have it?*
2228 #[test]
2229 fn a_blocked_leg_reports_which_other_sources_were_consulted() {
2230 use doiget_core::orchestrator::{AttemptOutcome, SourceAttempt};
2231 let attempts = vec![
2232 SourceAttempt::new("core", AttemptOutcome::NoRecord),
2233 SourceAttempt::new(
2234 "hal",
2235 AttemptOutcome::Disabled {
2236 env: &["DOIGET_ENABLE_HAL"],
2237 },
2238 ),
2239 ];
2240 let joined = blocked_trace_lines(&attempts, "HTTP 429").join("\n");
2241 assert!(
2242 joined.contains("the other sources were consulted"),
2243 "at least one WAS consulted:\n{joined}"
2244 );
2245 assert!(
2246 joined.contains("core") && joined.contains("no record"),
2247 "{joined}"
2248 );
2249 assert!(
2250 joined.contains("DOIGET_ENABLE_HAL"),
2251 "a source that was never asked must still name its switch:\n{joined}"
2252 );
2253 }
2254
2255 /// All five flags off is a configuration problem, not a data problem,
2256 /// and must not read as "nothing else has this paper".
2257 #[test]
2258 fn a_blocked_leg_with_nothing_consulted_says_so() {
2259 use doiget_core::orchestrator::{AttemptOutcome, SourceAttempt};
2260 let attempts = vec![
2261 SourceAttempt::new(
2262 "core",
2263 AttemptOutcome::Disabled {
2264 env: &["DOIGET_ENABLE_CORE"],
2265 },
2266 ),
2267 SourceAttempt::new(
2268 "hal",
2269 AttemptOutcome::Disabled {
2270 env: &["DOIGET_ENABLE_HAL"],
2271 },
2272 ),
2273 ];
2274 let joined = blocked_trace_lines(&attempts, "HTTP 429").join("\n");
2275 assert!(
2276 joined.contains("no other source was consulted"),
2277 "must not imply the paper is unavailable elsewhere:\n{joined}"
2278 );
2279 }
2280
2281 /// An arXiv fetch has no optional chain; it must not grow an empty
2282 /// note block.
2283 #[test]
2284 fn no_attempts_means_no_trace_block() {
2285 let lines = blocked_trace_lines(&[], "not-a-pdf body");
2286 assert!(lines.is_empty(), "{lines:?}");
2287 }
2288}