Skip to main content

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