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