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