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