Skip to main content

doiget_cli/commands/
config.rs

1//! `doiget config <action>` — config introspection.
2//!
3//! This subcommand is intentionally read-only and does NOT touch the network
4//! or instantiate the Store. Phase 1 resolves config from environment
5//! variables only with default fallbacks; the user `config.toml` reader
6//! lands in a follow-up. See `docs/CONFIG.md` for the canonical schema.
7//!
8//! `print_stdout` is denied workspace-wide for MCP stdio safety (ADR-0001 /
9//! `docs/SECURITY.md` §3). The `config show` and `config path` actions are
10//! the *spec'd* stdout channel for human-facing introspection — they are
11//! never invoked from inside an MCP session (`doiget serve` runs a
12//! different code path), so the lint is locally relaxed below.
13
14use anyhow::{Context, Result};
15use camino::Utf8PathBuf;
16
17use super::fetch::CliExit;
18
19/// Snapshot of the env-var + default-fallback config that `doiget` would
20/// use on the current machine.
21///
22/// Phase 1 surface: env vars only (`DOIGET_STORE_ROOT`, `DOIGET_LOG_PATH`,
23/// `DOIGET_CONTACT_EMAIL`, `DOIGET_UNPAYWALL_EMAIL`) layered over
24/// XDG / known-folder defaults. Phase 2 will layer the user config.toml
25/// underneath the env vars per `docs/CONFIG.md` §1.
26///
27/// Issue #142: `log_path` is resolved from `DOIGET_LOG_PATH` — the ONLY
28/// log env var `docs/CONFIG.md` §4 documents — using the exact same
29/// resolution the provenance-log *writer*
30/// (`commands::fetch::resolve_log_path` / `commands::audit_log`) uses, so
31/// `config show` reports the path the writer actually uses. The previously
32/// read, undocumented `DOIGET_LOG_DIR` has been dropped.
33#[derive(Debug, serde::Serialize)]
34pub struct ResolvedConfig {
35    /// Root of the on-disk paper store. Default: `./papers` (under the cwd).
36    pub store_root: Utf8PathBuf,
37    /// Which rung of the ADR-0036 order produced `store_root` (#441).
38    ///
39    /// Reported because the failure it guards against is invisible
40    /// otherwise: a `[store] root` that is present but unread resolves to
41    /// the cwd default, and the two coincide whenever the user happens to
42    /// run from the directory they configured.
43    pub store_root_source: String,
44    /// Directory holding doiget's append-only logs. Derived from
45    /// `log_path`'s parent so it always agrees with the writer.
46    pub log_dir: Utf8PathBuf,
47    /// JSON-Lines provenance log file path. `DOIGET_LOG_PATH` when set,
48    /// otherwise `<config_dir>/doiget/access.jsonl` (`docs/CONFIG.md` §4).
49    pub log_path: Utf8PathBuf,
50    /// Directory holding `config.toml` and `credentials.toml`.
51    pub config_dir: Utf8PathBuf,
52    /// Path of the user config file (may not exist on disk yet).
53    pub config_path: Utf8PathBuf,
54    /// Contact email for the polite User-Agent header (and Unpaywall fallback).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub contact_email: Option<String>,
57    /// Unpaywall-specific contact email; falls back to `contact_email` when unset.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub unpaywall_email: Option<String>,
60}
61
62impl ResolvedConfig {
63    /// Resolve the live config from process environment + platform defaults.
64    ///
65    /// Errors only if the platform config directory (`dirs::config_dir()`) or
66    /// the current working directory cannot be determined or is non-UTF-8
67    /// (an unknown / locked-down platform); on every realistic POSIX or
68    /// Windows host this returns `Ok` even with no `DOIGET_*` env vars set.
69    pub fn from_env() -> Result<Self> {
70        // Issue #405: resolve the config dir the SAME way the READER does
71        // (`commands::fetch::config_dir_utf8`, which `build_http_client`
72        // uses to load `[[network.additional_hosts]]`), for the same
73        // reason `store_root` and `log_path` already reuse their writers'
74        // resolvers — `config show` / `config path` / `doctor` must never
75        // name a file other than the one that is actually read.
76        //
77        // They diverged before this: `dirs::config_dir()` resolves the
78        // Windows roaming AppData through the known-folder API and ignores
79        // `XDG_CONFIG_HOME` entirely, while `config_dir_utf8()` checks
80        // `XDG_CONFIG_HOME` first on every platform. So on Windows a user
81        // with `XDG_CONFIG_HOME` set — normal for cross-platform dotfiles —
82        // got `doiget fetch` reading one `config.toml` while
83        // `doiget config doctor` validated a different one and reported
84        // "user-extension hosts loaded: 0" about a file the fetch path had
85        // never opened. That makes the #405 doctor hint point at the wrong
86        // file, which is worse than not printing it.
87        let cfg = super::fetch::config_dir_utf8()?;
88
89        // Store root: identical resolution to where artifacts actually land
90        // (`super::resolve_store_root`) so `config show` / `doctor` never drifts
91        // from the writer — `DOIGET_STORE_ROOT` else `./papers` under the cwd
92        // (#344 / ADR-0036).
93        let (store_root, store_root_source) = super::resolve_store_root_with_source()?;
94
95        // Issue #142: resolve the log path the SAME way the writer does
96        // (`commands::fetch::resolve_log_path` / `commands::audit_log`):
97        // `DOIGET_LOG_PATH` (the only log env var documented in
98        // `docs/CONFIG.md` §4) when set, otherwise
99        // `<config_dir>/doiget/access.jsonl`. The undocumented
100        // `DOIGET_LOG_DIR` is no longer read, so `config show` can no
101        // longer disagree with the path the provenance log is written to.
102        let log_path = match std::env::var("DOIGET_LOG_PATH") {
103            Ok(s) if !s.is_empty() => Utf8PathBuf::from(s),
104            _ => cfg.join("doiget").join("access.jsonl"),
105        };
106        // `log_dir` is purely derived from `log_path` so the two can never
107        // drift; fall back to the config dir for a path with no parent.
108        let log_dir = log_path
109            .parent()
110            .map(Utf8PathBuf::from)
111            .unwrap_or_else(|| cfg.join("doiget"));
112
113        let config_dir = cfg.join("doiget");
114        let config_path = config_dir.join("config.toml");
115
116        Ok(Self {
117            store_root,
118            store_root_source: store_root_source.label().to_string(),
119            log_dir,
120            log_path,
121            config_dir,
122            config_path,
123            contact_email: std::env::var("DOIGET_CONTACT_EMAIL").ok(),
124            unpaywall_email: std::env::var("DOIGET_UNPAYWALL_EMAIL").ok(),
125        })
126    }
127}
128
129/// Dispatch entrypoint for `doiget config <action>`.
130///
131/// `action` is one of `show`, `path`, `doctor`. Anything else returns
132/// `Err`; clap currently passes the raw string through.
133//
134// `print_stdout` and `print_stderr` are workspace-deny / workspace-warn for
135// MCP stdio safety. The `config` subcommand is the explicit human-facing
136// stdout channel for the resolved config; `doctor`'s checklist lines also
137// belong on stderr by design (stdout stays clean for `| jq` style pipes
138// when we add `--json` later).
139#[allow(clippy::print_stdout, clippy::print_stderr)]
140pub async fn run(
141    action: String,
142    mode: super::output::OutputMode,
143    network: bool,
144    force: bool,
145    quiet_was_explicit: bool,
146) -> Result<()> {
147    // `mode` honors ADR-0017, per ACTION rather than per command -- like
148    // `audit-log`, `config` is not uniformly one class:
149    //
150    //   * `path` / `show` are ARTIFACT. Their stdout IS the thing the
151    //     caller asked for, so only an EXPLICIT Quiet silences them. The
152    //     implicit non-TTY Quiet must not, which is #476: `doiget config
153    //     path` from a pipe printed zero bytes and exited 0, so the
154    //     documented way to find your config file (`docs/CONFIG.md` SS4,
155    //     SS12) answered a script, a CI step or an agent with silence AND
156    //     success. Third time the ADR-0017 classification was found
157    //     incomplete, after #219/#220 and #301.
158    //   * `init` is a STATUS report about a write that happened. Quiet of
159    //     either kind silences it; the exit code carries the outcome.
160    //   * `doctor` is unaffected either way -- its per-check output is on
161    //     stderr and the exit code is the signal (#203).
162    //
163    // Json body for `show` is tracked in #204.
164    let artifact_quiet = mode == super::output::OutputMode::Quiet && quiet_was_explicit;
165    let cfg = ResolvedConfig::from_env()?;
166    if network && action != "doctor" {
167        eprintln_err("error: --network applies to `config doctor` only");
168        return Err(anyhow::Error::new(CliExit(2)));
169    }
170    if force && action != "init" {
171        eprintln_err("error: --force applies to `config init` only");
172        return Err(anyhow::Error::new(CliExit(2)));
173    }
174    match action.as_str() {
175        "show" if artifact_quiet => {}
176        "show" => match mode {
177            super::output::OutputMode::Quiet => {
178                // Implicit (non-TTY) Quiet: `show` is artifact-class, so
179                // render rather than suppress (#476).
180                let s = toml::to_string_pretty(&cfg)?;
181                print!("{s}");
182            }
183            super::output::OutputMode::Json => {
184                // #204: `ResolvedConfig` is `Serialize` (already used for
185                // the TOML branch).
186                let s = serde_json::to_string_pretty(&cfg)
187                    .map_err(|e| anyhow::anyhow!("serialise config to JSON: {e}"))?;
188                println!("{s}");
189            }
190            _ => {
191                let s = toml::to_string_pretty(&cfg)?;
192                print!("{s}");
193            }
194        },
195        "init" => init_config(&cfg, force, mode)?,
196        "path" if artifact_quiet => {}
197        "path" => match mode {
198            super::output::OutputMode::Quiet => {
199                // Implicit (non-TTY) Quiet: naming the file IS the job.
200                println!("{}", cfg.config_path);
201            }
202            super::output::OutputMode::Json => {
203                // Minimal JSON object so callers can parse the path
204                // uniformly; no trailing-newline ambiguity vs the raw
205                // `path` form.
206                println!(
207                    "{}",
208                    serde_json::json!({ "config_path": cfg.config_path.as_str() })
209                );
210            }
211            _ => {
212                println!("{}", cfg.config_path);
213            }
214        },
215        "doctor" => {
216            let mut all_ok = true;
217            let store_parent = cfg.store_root.parent().map(|p| p.as_str()).unwrap_or("");
218            // Issue #406: the default store root is `./papers` under the
219            // CWD (ADR-0036), so it MOVES every time the user `cd`s. A
220            // check that says only "parent exists" confirms a path the
221            // user cannot see; naming it is what makes the cwd-relative
222            // default self-evident instead of surprising.
223            check(
224                &format!("store_root: {}", cfg.store_root),
225                true,
226                None,
227                &mut all_ok,
228            );
229            // #441: naming the rung is the whole point. A `[store] root`
230            // that is set but unread lands on the cwd default, and the two
231            // coincide whenever the user runs from the directory they
232            // configured — so "store_root: /home/me/papers" alone cannot
233            // distinguish "your setting worked" from "your setting was
234            // ignored and you happen to be standing in it".
235            eprintln!("       from: {}", cfg.store_root_source);
236            if cfg.store_root_source == super::StoreRootSource::CwdDefault.label() {
237                eprintln!(
238                    "       note: relative to the current directory (ADR-0036). Set DOIGET_STORE_ROOT"
239                );
240                eprintln!("             (or store.root in config.toml) for one central library.");
241            }
242            check(
243                "store_root parent exists",
244                cfg.store_root.parent().map(|p| p.exists()).unwrap_or(true),
245                Some(&format!(
246                    "create the parent directory or override via \
247                     DOIGET_STORE_ROOT\n               \
248                     missing parent: {store_parent}"
249                )),
250                &mut all_ok,
251            );
252            let log_parent = cfg.log_dir.parent().map(|p| p.as_str()).unwrap_or("");
253            check(
254                "log_dir parent exists",
255                cfg.log_dir.parent().map(|p| p.exists()).unwrap_or(true),
256                Some(&format!(
257                    "create the parent directory or override via \
258                     DOIGET_LOG_PATH\n               \
259                     missing parent: {log_parent}"
260                )),
261                &mut all_ok,
262            );
263            check(
264                "contact_email set",
265                cfg.contact_email.is_some(),
266                Some(
267                    "set DOIGET_CONTACT_EMAIL to your email address\n               \
268                     e.g. export DOIGET_CONTACT_EMAIL=you@institution.edu\n               \
269                     (required for the polite User-Agent header and Unpaywall API)",
270                ),
271                &mut all_ok,
272            );
273            // ADR-0028 D2: surface user-extension allowlist health. A
274            // missing config.toml is normal (curated set only); a
275            // present-but-malformed config.toml is a doctor failure so
276            // the operator finds out before fetch attempts silently
277            // skip the extension path. `user_extension::load` returns
278            // `Ok(vec![])` for not-found, so the OK arm always reports
279            // a count.
280            match doiget_core::user_extension::load(&cfg.config_path) {
281                Ok(cfg_ext) => {
282                    check(
283                        &format!(
284                            "user-extension hosts loaded: {} (academic={}, oa_registries={})",
285                            cfg_ext.additional_hosts.len(),
286                            cfg_ext.trust_academic_repos,
287                            cfg_ext.trust_oa_registries
288                        ),
289                        true,
290                        None,
291                        &mut all_ok,
292                    );
293                    // Issue #405: reporting `trust_academic_repos=false`
294                    // states the fact without naming the fix, and a default
295                    // install (no config.toml at all) is exactly the posture
296                    // whose OA fetches get denied at an off-allowlist
297                    // redirect. When nothing has widened the allowlist, name
298                    // the file and both keys. `check` swallows tips on a
299                    // passing check by design, so this is a separate
300                    // advisory line — the check itself stays `[ ok ]`,
301                    // because having no config is a valid posture.
302                    let widened = cfg_ext.trust_academic_repos
303                        || cfg_ext.trust_oa_registries
304                        || !cfg_ext.additional_hosts.is_empty();
305                    if !widened {
306                        eprintln!("       note: built-in allowlist only. To widen it, edit");
307                        eprintln!("             {}", cfg.config_path);
308                        eprintln!(
309                            "             [network] trust_academic_repos = true   # *.ac.uk, \
310                             *.ac.jp, ..."
311                        );
312                        eprintln!(
313                            "             [network] trust_oa_registries  = true   # DOAJ, \
314                             SciELO, Zenodo, ..."
315                        );
316                        eprintln!(
317                            "             [[network.additional_hosts]]            # anything \
318                             else — docs/CONFIG.md §3.1"
319                        );
320                    }
321                }
322                Err(e) => check(
323                    &format!("user-extension config invalid: {e}"),
324                    false,
325                    Some(&format!(
326                        "fix {} — see docs/CONFIG.md §3 for the \
327                         [[network.additional_hosts]] schema",
328                        cfg.config_path
329                    )),
330                    &mut all_ok,
331                ),
332            }
333            // Issue #407: the network section is opt-in behind `--network`
334            // because it makes real outbound requests. Everything above is
335            // local and always runs, so `--network` extends the report
336            // rather than replacing it.
337            if network {
338                network_report(&cfg).await;
339            }
340            // Trying to actually create the dirs would have side-effects;
341            // keep doctor read-only and just check existence of parents.
342            if !all_ok {
343                // Issue #149: a failing doctor means missing/invalid
344                // config — `docs/ERRORS.md` §4 classes "missing config"
345                // as misuse → exit 2 (the per-check `[FAIL]` lines were
346                // already written to stderr by `check`).
347                eprintln_err("error: config doctor: one or more checks failed");
348                return Err(anyhow::Error::new(CliExit(2)));
349            }
350        }
351        other => {
352            // Issue #149: an unknown subcommand action is clear argument
353            // misuse → `docs/ERRORS.md` §4 exit 2, not the generic exit 1
354            // a bare `bail!` produced.
355            eprintln_err(&format!(
356                "error: unknown config action: {other}; expected `init` / `show` / `path` / `doctor`"
357            ));
358            return Err(anyhow::Error::new(CliExit(2)));
359        }
360    }
361    Ok(())
362}
363
364/// Stderr sink for the `docs/ERRORS.md` §3 human-error lines. The
365/// localized `#[allow]` is the minimal intervention for the workspace
366/// `clippy::print_stderr` lint (same pattern as `commands::fetch`).
367#[allow(clippy::print_stderr)]
368fn eprintln_err(msg: &str) {
369    eprintln!("{msg}");
370}
371
372/// Emit one `[ ok ]` / `[FAIL]` checklist line to stderr and update the
373/// running pass/fail flag. Stderr is used so that `doiget config doctor`
374/// stdout stays empty for green runs (script-friendly).
375///
376/// When `ok` is `false` and `tip` is `Some`, a remediation tip is printed
377/// on the next line, indented so it is visually attached to the failed
378/// check (issue #322).
379/// The commented `config.toml` template written by `doiget config init`.
380///
381/// Issue #408: on a fresh install `~/.config/doiget/config.toml` does not
382/// exist, nothing creates it, and four of the settings that decide the
383/// outcome of a session live in it. Three of those four fail *silently*.
384/// Every commented line here doubles as documentation at the place the user
385/// is already looking.
386///
387/// Pure and `pub(crate)` so the tests can assert the template actually
388/// mentions each load-bearing key — a template that silently loses one is
389/// the failure mode worth guarding against.
390pub(crate) fn config_template() -> &'static str {
391    // NOTE: every key below is commented out on purpose. Writing live values
392    // would change behaviour just by running `init`; the file's job is to be
393    // a discoverable, annotated menu, not a new set of defaults.
394    r#"# ~/.config/doiget/config.toml — written by `doiget config init`.
395#
396# Every field is optional and every line below is commented out: this file
397# documents the choices, it does not change behaviour until you uncomment
398# something. Re-run `doiget config init --force` to restore this template.
399#
400# See docs/CONFIG.md for the full schema, and run `doiget config doctor`
401# (add --network for outbound checks) to see what is actually in effect.
402
403[store]
404# Where fetched papers are written.
405#
406# DEFAULT: `./papers` — relative to the CURRENT WORKING DIRECTORY, so it
407# moves with you (ADR-0036). That is deliberate: artifacts land where the
408# work is, instead of somewhere you have to go looking for. The cost is that
409# fetching from many directories leaves several small stores. Set this for a
410# single central library.
411#
412# Overridden by DOIGET_STORE_ROOT and by --store-root, which share a rung
413# above this one. A leading `~` IS expanded here (a config file has no
414# shell, unlike the env var).
415# root = "/home/you/papers"
416
417[network]
418# Contact address for the polite pool. STRONGLY RECOMMENDED.
419#
420# Without it doiget still queries Unpaywall, but as `doiget@localhost`, from
421# the non-polite pool — where you may be throttled or refused. Since the
422# automatic arXiv-preprint fallback fires on what Unpaywall reports, a
423# throttled response quietly costs you that fallback too.
424# unpaywall_email = "you@institution.edu"
425
426# Allow the curated academic-repository suffixes, i.e. where institutions
427# host their own Green OA:
428#   *.ac.uk  *.ac.jp  *.jst.go.jp  *.edu.au  *.edu.cn  *.ac.cn  *.edu.pl
429#   *.ac.nz  *.ac.za  *.ac.in  *.edu.br  *.edu.tw  *.edu.tr  *.edu.ar
430#   *.edu.mx
431#
432# Without this, an OA PDF on e.g. `strathprints.strath.ac.uk` is denied with
433# `error[CAPABILITY_DENIED] ... redirect_not_in_allowlist`.
434# trust_academic_repos = false
435
436# Allow the curated cross-publisher OA registries and repositories:
437#   scielo.org  zenodo.org  osf.io  hal.science  core.ac.uk  (+ subdomains)
438#
439# Separate from the flag above because the trust argument differs: one is
440# "this institution publishes its own work here", the other is "this registry
441# indexes open content across publishers". DOAJ needs no flag — it is on the
442# default allowlist (ADR-0037).
443# trust_oa_registries = false
444
445# Anything outside both curated sets. Each entry is a literal FQDN or a
446# single-suffix wildcard (`*.example.edu`); multi-segment globs are rejected
447# at load time, as are unknown keys in this table.
448# [[network.additional_hosts]]
449# host = "repository.example.edu"
450# note = "free-text, optional"
451
452# Request timeouts, in seconds.
453# connect_timeout_sec = 10
454# read_timeout_sec = 60
455# total_timeout_sec = 300
456
457[output]
458# mode = "human"     # human | json | quiet | mcp
459# color = "auto"     # auto | always | never
460# progress = false
461# emoji = false
462"#
463}
464
465/// `doiget config init` — write [`config_template`] to the resolved config
466/// path (issue #408).
467///
468/// Refuses to overwrite an existing file unless `force`. That refusal is the
469/// whole safety property: the file may hold a user's hand-written allowlist,
470/// and silently replacing it with a fully commented-out template would
471/// disable every host they had added.
472#[allow(clippy::print_stdout, clippy::print_stderr)]
473fn init_config(cfg: &ResolvedConfig, force: bool, mode: super::output::OutputMode) -> Result<()> {
474    let path = &cfg.config_path;
475    let existed = path.exists();
476    if existed && !force {
477        eprintln_err(&format!(
478            "error: {path} already exists; pass --force to overwrite it"
479        ));
480        return Err(anyhow::Error::new(CliExit(2)));
481    }
482    if let Some(parent) = path.parent() {
483        std::fs::create_dir_all(parent.as_std_path())
484            .with_context(|| format!("creating config directory {parent}"))?;
485    }
486    std::fs::write(path.as_std_path(), config_template())
487        .with_context(|| format!("writing {path}"))?;
488
489    match mode {
490        super::output::OutputMode::Quiet => {}
491        super::output::OutputMode::Json => {
492            println!(
493                "{}",
494                serde_json::json!({
495                    "ok": true,
496                    "config_path": path.as_str(),
497                    "overwritten": existed,
498                })
499            );
500        }
501        _ => {
502            let verb = if existed { "overwrote" } else { "wrote" };
503            println!("{verb} {path}");
504            eprintln_err(
505                "  = note: every field is commented out; nothing changed until you edit it",
506            );
507        }
508    }
509    Ok(())
510}
511
512/// Classification of a single publisher probe (issue #407).
513///
514/// The point of the enum is the `BotChallenge` arm. A publisher WAF answers
515/// a scripted client with `202 Accepted` and an empty body; a report that
516/// only printed the status would call that a success and send the user off
517/// to debug their subscription, when the binding constraint is that they
518/// are not a browser. Status and body size together separate the two.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub enum ProbeVerdict {
521    /// 2xx with a non-empty body — the host served this client.
522    Ok {
523        /// HTTP status observed.
524        status: u16,
525        /// Body size in bytes.
526        bytes: usize,
527    },
528    /// 2xx with an empty body. Almost always a bot-challenge holding
529    /// response, not a paywall and not an outage.
530    BotChallenge {
531        /// HTTP status observed (typically 202).
532        status: u16,
533    },
534    /// 401 / 403 — reached the host, which refused. A subscription or
535    /// credential question, not a transport one.
536    Refused {
537        /// HTTP status observed (401 or 403).
538        status: u16,
539    },
540    /// Any other status.
541    Status {
542        /// HTTP status observed.
543        status: u16,
544    },
545    /// The host is not on the source allowlist, so no request was sent.
546    NotAllowlisted,
547    /// Transport failure — DNS, TLS, connect, or timeout.
548    Unreachable {
549        /// Rendered transport error.
550        reason: String,
551    },
552}
553
554impl ProbeVerdict {
555    /// Map a [`doiget_core::http::ProbeOutcome`] to a verdict. Pure, so
556    /// the classification —
557    /// the part with the actual judgement in it — is unit-testable without
558    /// a network or a mock server.
559    pub fn classify(status: u16, body_bytes: usize) -> Self {
560        match status {
561            200..=299 if body_bytes == 0 => Self::BotChallenge { status },
562            200..=299 => Self::Ok {
563                status,
564                bytes: body_bytes,
565            },
566            401 | 403 => Self::Refused { status },
567            other => Self::Status { status: other },
568        }
569    }
570
571    /// One-line rendering: what happened, then what it means.
572    pub fn render(&self) -> String {
573        match self {
574            Self::Ok { status, bytes } => format!("{status} {bytes} bytes    ok"),
575            Self::BotChallenge { status } => {
576                format!("{status} empty body  bot challenge — needs a TDM key or a real browser")
577            }
578            Self::Refused { status } => {
579                format!("{status}               reached, refused — subscription or credential")
580            }
581            Self::Status { status } => format!("{status}               unexpected status"),
582            Self::NotAllowlisted => {
583                "not allowlisted   no request sent; add the host or enable a trust flag".to_string()
584            }
585            Self::Unreachable { reason } => format!("unreachable       {reason}"),
586        }
587    }
588}
589
590/// The `doiget config doctor --network` contact-address block (#443).
591///
592/// Split out as a pure function for the same reason as
593/// `fetch::denial_note_lines`: it is a diagnostic whose exact wording is
594/// the whole point, and a diagnostic nothing asserts on is a diagnostic
595/// that can silently regress.
596///
597/// It used to read `unpaywall  non-polite pool (... may be throttled)`,
598/// which attributes the whole cost of an unset contact address to the
599/// metadata lookup. The 429 that prompted this came from the publisher on
600/// the CONTENT leg. The User-Agent goes out on every request, so the label
601/// has to say every request.
602fn contact_report_lines(contact_email: Option<&str>) -> Vec<String> {
603    match contact_email {
604        Some(e) => vec![format!(
605            "  contact         polite User-Agent as {e} (all outbound requests)"
606        )],
607        None => vec![
608            "  contact         no DOIGET_CONTACT_EMAIL — every outbound request, metadata"
609                .to_string(),
610            "                  AND publisher content, goes out on the non-polite pool and"
611                .to_string(),
612            "                  may be throttled (HTTP 429) or refused".to_string(),
613        ],
614    }
615}
616
617/// `doiget config doctor --network` — the outbound half of the report
618/// (issue #407).
619///
620/// Answers the question a user on an institutional network actually has:
621/// *which publishers will talk to me?* One GET per probed host, no retries,
622/// and only against hosts already on the `oa-publisher` allowlist — a
623/// doctor that probed arbitrary hosts would be an SSRF gadget wearing a
624/// diagnostic hat.
625///
626/// **Egress address is deliberately not reported.** Determining it requires
627/// asking a third-party echo service, which would be a new outbound
628/// dependency and a new `PRIVACY.md` entry for a diagnostic. The report
629/// names the proxy configuration in effect — the part doiget actually
630/// knows — and leaves the address to `curl`.
631#[allow(clippy::print_stderr)]
632async fn network_report(cfg: &ResolvedConfig) {
633    eprintln!();
634    eprintln!("network (--network):");
635
636    for var in ["HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"] {
637        if let Ok(v) = std::env::var(var) {
638            if !v.is_empty() {
639                eprintln!("  proxy           {var}={v}");
640            }
641        }
642    }
643    eprintln!(
644        "  egress          not probed (needs a third-party echo service; try `curl ifconfig.me`)"
645    );
646    eprintln!("                  a proxy fixes addressing, never a bot wall");
647
648    for line in contact_report_lines(cfg.contact_email.as_deref()) {
649        eprintln!("{line}");
650    }
651
652    let client = match crate::commands::fetch::build_http_client(None) {
653        Ok(c) => c,
654        Err(e) => {
655            eprintln!("  probes          unavailable: {e}");
656            return;
657        }
658    };
659    let Some(allow) = client.source_allowlist("oa-publisher") else {
660        eprintln!("  probes          unavailable: oa-publisher source not registered");
661        return;
662    };
663    eprintln!(
664        "  oa-publisher    {} host patterns allowlisted",
665        allow.redirect_hosts.len()
666    );
667
668    // Publishers a paywalled-literature user is most likely to ask about.
669    // Listed whether or not they are allowlisted: "ieee.org NOT
670    // allowlisted" is the single most useful line in the report for the
671    // #407 case, and it can only be printed for a host we name up front.
672    const PROBES: &[(&str, &str)] = &[
673        ("link.springer.com", "https://link.springer.com/robots.txt"),
674        ("www.mdpi.com", "https://www.mdpi.com/robots.txt"),
675        ("journals.plos.org", "https://journals.plos.org/robots.txt"),
676        ("arxiv.org", "https://arxiv.org/robots.txt"),
677        (
678            "ieeexplore.ieee.org",
679            "https://ieeexplore.ieee.org/robots.txt",
680        ),
681        ("dl.acm.org", "https://dl.acm.org/robots.txt"),
682        ("epubs.siam.org", "https://epubs.siam.org/robots.txt"),
683        ("doaj.org", "https://doaj.org/robots.txt"),
684    ];
685    for (host, url) in PROBES {
686        let verdict = if !allow.matches(host) {
687            ProbeVerdict::NotAllowlisted
688        } else {
689            match url::Url::parse(url) {
690                Err(e) => ProbeVerdict::Unreachable {
691                    reason: format!("bad probe URL: {e}"),
692                },
693                Ok(u) => match client.probe("oa-publisher", u).await {
694                    Ok(o) => ProbeVerdict::classify(o.status, o.body_bytes),
695                    Err(e) => ProbeVerdict::Unreachable {
696                        reason: e.to_string(),
697                    },
698                },
699            }
700        };
701        eprintln!("  probe {host:<22} {}", verdict.render());
702    }
703    eprintln!();
704    eprintln!("  IP-based subscription does not imply fetchability: a publisher WAF can");
705    eprintln!("  answer a scripted client with a challenge regardless of entitlement. The");
706    eprintln!("  routes that work are per-publisher TDM credentials (docs/CONFIG.md §6)");
707    eprintln!("  or a real browser on the subscribing network.");
708}
709
710#[allow(clippy::print_stderr)]
711fn check(label: &str, ok: bool, tip: Option<&str>, all_ok: &mut bool) {
712    let mark = if ok { "[ ok ]" } else { "[FAIL]" };
713    eprintln!("{mark} {label}");
714    if !ok {
715        if let Some(t) = tip {
716            eprintln!("       tip: {t}");
717        }
718        *all_ok = false;
719    }
720}
721
722// ---------------------------------------------------------------------------
723// Tests — env-mutating, serialized via serial_test (same convention as
724// `doiget-core::tests`). Each test resets the four env vars it touches via
725// an EnvGuard RAII drop guard so that prior values are restored on panic.
726// ---------------------------------------------------------------------------
727#[cfg(test)]
728mod tests {
729    #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
730
731    use super::*;
732
733    /// RAII guard that captures the prior value of an env var on
734    /// construction and restores it on drop. Mirrors the convention in
735    /// `crates/doiget-core/src/lib.rs::tests`.
736    struct EnvGuard {
737        var: &'static str,
738        prior: Option<std::ffi::OsString>,
739    }
740
741    impl EnvGuard {
742        fn unset(var: &'static str) -> Self {
743            let prior = std::env::var_os(var);
744            // SAFETY: tests are serialized via `#[serial_test::serial]`;
745            // no other thread reads/writes env state concurrently.
746            std::env::remove_var(var);
747            EnvGuard { var, prior }
748        }
749
750        fn set(var: &'static str, value: &str) -> Self {
751            let prior = std::env::var_os(var);
752            std::env::set_var(var, value);
753            EnvGuard { var, prior }
754        }
755    }
756
757    impl Drop for EnvGuard {
758        fn drop(&mut self) {
759            match &self.prior {
760                Some(v) => std::env::set_var(self.var, v),
761                None => std::env::remove_var(self.var),
762            }
763        }
764    }
765
766    /// Unset every env var the `config` subcommand reads. Returns guards
767    /// that restore prior values on drop.
768    fn unset_all_doiget_config_env() -> Vec<EnvGuard> {
769        [
770            "DOIGET_STORE_ROOT",
771            "DOIGET_LOG_PATH",
772            "DOIGET_CONTACT_EMAIL",
773            "DOIGET_UNPAYWALL_EMAIL",
774        ]
775        .iter()
776        .map(|v| EnvGuard::unset(v))
777        .collect()
778    }
779
780    #[test]
781    #[serial_test::serial]
782    fn from_env_uses_cwd_default_when_unset() {
783        let _g = unset_all_doiget_config_env();
784        let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
785        // The default must be `<cwd>/papers` (ADR-0036), NOT `<home>/papers` —
786        // assert the full path so a regression back to the home directory is
787        // actually caught (a bare `ends_with("papers")` passes for both).
788        let cwd =
789            camino::Utf8PathBuf::from_path_buf(std::env::current_dir().expect("cwd is available"))
790                .expect("cwd is valid UTF-8");
791        assert_eq!(
792            cfg.store_root,
793            cwd.join("papers"),
794            "store_root should default to <cwd>/papers when DOIGET_STORE_ROOT is unset; got {}",
795            cfg.store_root
796        );
797        assert_eq!(cfg.contact_email, None);
798        assert_eq!(cfg.unpaywall_email, None);
799    }
800
801    /// Issue #405: `config show` / `config path` / `doctor` MUST name the
802    /// same `config.toml` that `build_http_client` reads. Before this,
803    /// `ResolvedConfig` used `dirs::config_dir()` while the reader used
804    /// `fetch::config_dir_utf8()`; on Windows the former ignores
805    /// `XDG_CONFIG_HOME` (known-folder API), so the doctor validated a
806    /// file the fetch path never opened.
807    #[test]
808    #[serial_test::serial]
809    fn config_path_matches_the_resolver_the_reader_uses() {
810        struct EnvGuard(&'static str, Option<String>);
811        impl Drop for EnvGuard {
812            fn drop(&mut self) {
813                match &self.1 {
814                    Some(v) => std::env::set_var(self.0, v),
815                    None => std::env::remove_var(self.0),
816                }
817            }
818        }
819        let td = tempfile::TempDir::new().expect("tempdir");
820        let _guards: Vec<EnvGuard> = ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
821            .iter()
822            .map(|k| EnvGuard(k, std::env::var(k).ok()))
823            .collect();
824        std::env::set_var("XDG_CONFIG_HOME", td.path());
825
826        let cfg = ResolvedConfig::from_env().expect("resolve config");
827        let reader = crate::commands::fetch::config_dir_utf8()
828            .expect("reader resolves")
829            .join("doiget")
830            .join("config.toml");
831        assert_eq!(
832            cfg.config_path, reader,
833            "doctor must validate the file the reader loads"
834        );
835        assert!(
836            cfg.config_path.as_str().starts_with(
837                camino::Utf8Path::from_path(td.path())
838                    .expect("utf-8 tempdir")
839                    .as_str()
840            ),
841            "XDG_CONFIG_HOME must win on every platform; got {}",
842            cfg.config_path
843        );
844    }
845
846    #[test]
847    #[serial_test::serial]
848    fn from_env_overrides_via_env() {
849        let _g = unset_all_doiget_config_env();
850        // Use a platform-appropriate absolute path so Utf8PathBuf::try_from
851        // succeeds on Windows too (where "/tmp/foo" is a relative path on
852        // the current drive — still UTF-8, still fine for this assertion).
853        let _override = EnvGuard::set("DOIGET_STORE_ROOT", "/tmp/foo");
854        let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
855        assert_eq!(cfg.store_root.as_str(), "/tmp/foo");
856    }
857
858    /// Issue #142: `config show` MUST report the same `log_path` the
859    /// provenance-log writer uses. The writer keys off `DOIGET_LOG_PATH`
860    /// (the only log env var documented in `docs/CONFIG.md` §4); the
861    /// resolver must do the same, and `log_dir` must be that path's
862    /// parent — never an independently-resolved (and divergent) value.
863    #[test]
864    #[serial_test::serial]
865    fn log_path_follows_doiget_log_path_env() {
866        let _g = unset_all_doiget_config_env();
867        let _override = EnvGuard::set("DOIGET_LOG_PATH", "/var/lib/doiget/access.jsonl");
868        let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
869        assert_eq!(
870            cfg.log_path.as_str(),
871            "/var/lib/doiget/access.jsonl",
872            "config show must echo DOIGET_LOG_PATH verbatim (issue #142)"
873        );
874        assert_eq!(
875            cfg.log_dir.as_str(),
876            "/var/lib/doiget",
877            "log_dir must be derived from log_path's parent so the two cannot drift"
878        );
879    }
880
881    // ── #408: `config init` ──────────────────────────────────────────────
882
883    /// The template's job is to document the keys that fail *silently* on a
884    /// default install. If one is ever dropped from it, the file stops being
885    /// the answer to #408 while still looking fine.
886    #[test]
887    fn template_documents_every_silently_defaulting_key() {
888        let t = config_template();
889        for key in [
890            "[store]",
891            "root =",
892            "unpaywall_email",
893            "trust_academic_repos",
894            "trust_oa_registries",
895            "[[network.additional_hosts]]",
896        ] {
897            assert!(t.contains(key), "template must mention {key}");
898        }
899        // ADR-0036 / ADR-0037 are the two non-obvious defaults; the template
900        // must say what they are, not merely name the keys.
901        assert!(
902            t.contains("CURRENT WORKING DIRECTORY"),
903            "store root default"
904        );
905        assert!(
906            t.contains("doiget@localhost"),
907            "non-polite pool consequence"
908        );
909        assert!(t.contains("DOAJ needs no flag"), "post-ADR-0037 accuracy");
910    }
911
912    /// Every line must be inert: writing live values would mean `init`
913    /// silently changed behaviour just by being run.
914    #[test]
915    fn template_is_entirely_commented_out() {
916        for line in config_template().lines() {
917            let t = line.trim();
918            if t.is_empty() || t.starts_with('#') {
919                continue;
920            }
921            assert!(
922                t.starts_with('[') && t.ends_with(']') && !t.starts_with("[["),
923                "only bare section headers may be live; found: {line:?}"
924            );
925        }
926    }
927
928    /// The template must round-trip as TOML — a malformed one would be
929    /// written happily and only fail on the user's next command.
930    #[test]
931    fn template_parses_as_toml() {
932        let v: toml::Value = toml::from_str(config_template()).expect("template is valid TOML");
933        // With everything commented out it must carry no live keys beyond the
934        // empty section tables.
935        for (name, tbl) in v.as_table().expect("table") {
936            assert!(
937                tbl.as_table().expect("section").is_empty(),
938                "section [{name}] must be empty in the template"
939            );
940        }
941    }
942
943    // ── #407: probe classification ───────────────────────────────────────
944
945    /// The load-bearing case. A publisher WAF answers a scripted client
946    /// with `202 Accepted` and an empty body. Status alone reads that as
947    /// success and sends the user off to debug a subscription that is not
948    /// the problem — the measurement in #407 was exactly
949    /// `status=202 body=0 bytes` from a subscribing university address.
950    #[test]
951    fn empty_2xx_body_is_a_bot_challenge_not_a_success() {
952        assert_eq!(
953            ProbeVerdict::classify(202, 0),
954            ProbeVerdict::BotChallenge { status: 202 }
955        );
956        assert_eq!(
957            ProbeVerdict::classify(200, 0),
958            ProbeVerdict::BotChallenge { status: 200 },
959            "an empty 200 is the same holding response wearing a different code"
960        );
961        assert!(
962            ProbeVerdict::classify(202, 0)
963                .render()
964                .contains("bot challenge"),
965            "the verdict must name the diagnosis, not just the status"
966        );
967    }
968
969    #[test]
970    fn non_empty_2xx_is_ok() {
971        assert_eq!(
972            ProbeVerdict::classify(200, 1234),
973            ProbeVerdict::Ok {
974                status: 200,
975                bytes: 1234
976            }
977        );
978    }
979
980    /// 401/403 is a different diagnosis from a challenge: the host talked
981    /// to us and declined, so the next step is credentials, not a browser.
982    #[test]
983    fn auth_statuses_are_refused_not_challenged() {
984        for code in [401u16, 403] {
985            assert_eq!(
986                ProbeVerdict::classify(code, 0),
987                ProbeVerdict::Refused { status: code },
988                "{code} must not be misread as a bot challenge"
989            );
990        }
991        assert_eq!(
992            ProbeVerdict::classify(404, 0),
993            ProbeVerdict::Status { status: 404 }
994        );
995    }
996
997    /// Every verdict renders something a user can act on; none is blank.
998    #[test]
999    fn every_verdict_renders_non_empty_advice() {
1000        let all = [
1001            ProbeVerdict::Ok {
1002                status: 200,
1003                bytes: 1,
1004            },
1005            ProbeVerdict::BotChallenge { status: 202 },
1006            ProbeVerdict::Refused { status: 403 },
1007            ProbeVerdict::Status { status: 500 },
1008            ProbeVerdict::NotAllowlisted,
1009            ProbeVerdict::Unreachable {
1010                reason: "dns".into(),
1011            },
1012        ];
1013        for v in &all {
1014            assert!(!v.render().trim().is_empty(), "{v:?} rendered empty");
1015        }
1016    }
1017
1018    #[tokio::test]
1019    #[serial_test::serial]
1020    async fn doctor_fails_without_contact_email() {
1021        // Issue #149: a failing doctor is "missing config" → exit 2.
1022        // The human-readable line moved to stderr; the error now carries
1023        // a `CliExit(2)` rather than a Display-formatted anyhow string.
1024        let _g = unset_all_doiget_config_env();
1025        let err = run(
1026            "doctor".into(),
1027            crate::commands::output::OutputMode::Human,
1028            false,
1029            false,
1030            false,
1031        )
1032        .await
1033        .expect_err("doctor should fail when DOIGET_CONTACT_EMAIL is unset");
1034        let cli_exit = err
1035            .downcast_ref::<CliExit>()
1036            .expect("failing doctor must carry a CliExit (issue #149)");
1037        assert_eq!(
1038            cli_exit.0, 2,
1039            "missing/invalid config is misuse → exit 2, not the generic exit 1"
1040        );
1041    }
1042
1043    #[tokio::test]
1044    #[serial_test::serial]
1045    async fn doctor_passes_with_contact_email() {
1046        let _g = unset_all_doiget_config_env();
1047        let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
1048        // config_dir() and the cwd resolve to real, existing parents on every
1049        // supported test host (store root defaults to <cwd>/papers, ADR-0036).
1050        run(
1051            "doctor".into(),
1052            crate::commands::output::OutputMode::Human,
1053            false,
1054            false,
1055            false,
1056        )
1057        .await
1058        .expect("doctor should pass with contact email + valid config dir and cwd");
1059    }
1060
1061    /// ADR-0028 D2: a malformed `<config_dir>/doiget/config.toml`
1062    /// causes `doiget config doctor` to FAIL (exit 2). Linux-only
1063    /// because `dirs::config_dir()` resolves differently on each
1064    /// platform:
1065    ///   - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` (env-driven,
1066    ///     testable).
1067    ///   - macOS: `~/Library/Application Support` (Known Folder via
1068    ///     `NSSearchPathForDirectoriesInDomains`, ignores
1069    ///     `XDG_CONFIG_HOME`).
1070    ///   - Windows: `%FOLDERID_RoamingAppData%` (Known Folder API,
1071    ///     ignores `APPDATA` env in child processes via
1072    ///     `assert_cmd`).
1073    /// The malformed-config FAIL path is platform-independent; this
1074    /// test covers the wiring on the one platform where it CAN be
1075    /// exercised in a hermetic test.
1076    #[cfg(target_os = "linux")]
1077    #[tokio::test]
1078    #[serial_test::serial]
1079    async fn doctor_fails_with_malformed_user_extension_config() {
1080        let _g = unset_all_doiget_config_env();
1081        let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
1082
1083        let tmp = tempfile::TempDir::new().expect("tempdir");
1084        let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
1085        let doiget_dir = cfg_root.join("doiget");
1086        std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
1087        let config_toml = doiget_dir.join("config.toml");
1088        // Empty `host` value triggers `PatternError::Empty`, which
1089        // the doctor surfaces as a FAIL. `note` is valid TOML so the
1090        // top-level parse succeeds — only the pattern validation
1091        // path produces the error we're pinning.
1092        std::fs::write(
1093            config_toml.as_std_path(),
1094            "[[network.additional_hosts]]\nhost = \"\"\n",
1095        )
1096        .expect("write config.toml");
1097
1098        // `fetch::config_dir_utf8()` — which `ResolvedConfig` now shares
1099        // with the reader — honors `XDG_CONFIG_HOME` first on every
1100        // platform, so pointing it at our tempdir routes
1101        // `cfg.config_path` to our crafted file.
1102        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1103
1104        let err = run(
1105            "doctor".into(),
1106            crate::commands::output::OutputMode::Human,
1107            false,
1108            false,
1109            false,
1110        )
1111        .await
1112        .expect_err("doctor should fail when user-extension config is malformed");
1113        let cli_exit = err
1114            .downcast_ref::<CliExit>()
1115            .expect("failing doctor must carry a CliExit");
1116        assert_eq!(cli_exit.0, 2);
1117    }
1118
1119    /// Issue #322: `check` must emit a `tip:` line to stderr when the
1120    /// check fails and a tip is provided. Passing `ok=true` must NOT
1121    /// emit the tip line even when one is supplied.
1122    #[test]
1123    fn check_emits_tip_on_failure_only() {
1124        let mut flag = true;
1125        // Passing check — tip must be swallowed.
1126        check("passing check", true, Some("should not appear"), &mut flag);
1127        assert!(flag, "all_ok must stay true for a passing check");
1128
1129        // Failing check with tip — all_ok must flip.
1130        check(
1131            "failing check",
1132            false,
1133            Some("set DOIGET_CONTACT_EMAIL"),
1134            &mut flag,
1135        );
1136        assert!(!flag, "all_ok must flip to false on a failing check");
1137    }
1138
1139    #[tokio::test]
1140    #[serial_test::serial]
1141    async fn unknown_action_errors() {
1142        // Issue #149: an unknown action is clear argument misuse →
1143        // `docs/ERRORS.md` §4 exit 2. The descriptive line moved to
1144        // stderr; the error carries `CliExit(2)`.
1145        let _g = unset_all_doiget_config_env();
1146        let err = run(
1147            "bogus".into(),
1148            crate::commands::output::OutputMode::Human,
1149            false,
1150            false,
1151            false,
1152        )
1153        .await
1154        .expect_err("bogus action should error");
1155        let cli_exit = err
1156            .downcast_ref::<CliExit>()
1157            .expect("unknown config action must carry a CliExit (issue #149)");
1158        assert_eq!(
1159            cli_exit.0, 2,
1160            "unknown config action is misuse → exit 2, not the generic exit 1"
1161        );
1162    }
1163    /// Build an isolated config dir containing `config.toml` with `body`.
1164    fn config_home_with(body: &str) -> (tempfile::TempDir, camino::Utf8PathBuf) {
1165        let td = tempfile::TempDir::new().expect("tempdir");
1166        let root = camino::Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8 tempdir");
1167        std::fs::create_dir_all(root.join("doiget").as_std_path()).expect("mkdir");
1168        std::fs::write(root.join("doiget").join("config.toml").as_std_path(), body)
1169            .expect("write config");
1170        (td, root)
1171    }
1172
1173    /// #441: the rung that was missing. `[store] root` must beat the cwd
1174    /// default.
1175    ///
1176    /// The assertion that matters is the NEGATIVE one. `store_root ==
1177    /// <configured>` alone would also pass if the config were ignored and
1178    /// the test happened to run from the configured directory — which is
1179    /// exactly how the bug hid: a user testing from `$HOME` with
1180    /// `root = "$HOME/papers"` sees the right answer for the wrong reason.
1181    #[test]
1182    #[serial_test::serial]
1183    fn store_root_in_config_beats_the_cwd_default() {
1184        let _g = unset_all_doiget_config_env();
1185        let lib_td = tempfile::TempDir::new().expect("tempdir");
1186        let library = camino::Utf8PathBuf::try_from(lib_td.path().to_path_buf())
1187            .expect("utf-8 tempdir")
1188            .as_str()
1189            .replace('\u{5c}', "/");
1190        let (_cfg_td, cfg_root) = config_home_with(&format!("[store]\nroot = \"{library}\"\n"));
1191
1192        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1193        let cfg = ResolvedConfig::from_env().expect("config resolves");
1194
1195        let cwd_default = camino::Utf8PathBuf::try_from(std::env::current_dir().expect("cwd"))
1196            .expect("utf-8 cwd")
1197            .join("papers");
1198        assert_ne!(
1199            cfg.store_root, cwd_default,
1200            "the config value was ignored and the cwd default answered instead"
1201        );
1202        assert_eq!(
1203            cfg.store_root.as_str().replace('\u{5c}', "/"),
1204            library,
1205            "[store] root must win over the cwd default (ADR-0036 rung 2)"
1206        );
1207        assert_eq!(
1208            cfg.store_root_source,
1209            super::super::StoreRootSource::ConfigFile.label(),
1210            "doctor must attribute it to the config file"
1211        );
1212    }
1213
1214    /// The rung ABOVE it still wins. Adding rung 2 must not demote the env
1215    /// var, which is also how `--store-root` is applied.
1216    #[test]
1217    #[serial_test::serial]
1218    fn env_beats_store_root_in_config() {
1219        let _g = unset_all_doiget_config_env();
1220        let (_cfg_td, cfg_root) = config_home_with("[store]\nroot = \"/from/config\"\n");
1221
1222        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1223        let _e = EnvGuard::set("DOIGET_STORE_ROOT", "/from/env");
1224        let cfg = ResolvedConfig::from_env().expect("config resolves");
1225
1226        assert_eq!(cfg.store_root.as_str(), "/from/env");
1227        assert_eq!(
1228            cfg.store_root_source,
1229            super::super::StoreRootSource::Env.label()
1230        );
1231    }
1232
1233    /// A blank value means "unset", not "the empty path" — otherwise it
1234    /// would resolve to the filesystem root.
1235    #[test]
1236    #[serial_test::serial]
1237    fn blank_store_root_in_config_falls_through_to_the_default() {
1238        let _g = unset_all_doiget_config_env();
1239        let (_cfg_td, cfg_root) = config_home_with("[store]\nroot = \"   \"\n");
1240
1241        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1242        let cfg = ResolvedConfig::from_env().expect("config resolves");
1243
1244        assert_eq!(
1245            cfg.store_root_source,
1246            super::super::StoreRootSource::CwdDefault.label(),
1247            "a blank root must not be treated as a configured value"
1248        );
1249    }
1250    /// #443: the wording must not pin the cost of an unset contact address
1251    /// on the metadata leg — the 429 that prompted this came from the
1252    /// publisher, on the content leg.
1253    #[test]
1254    fn the_contact_advisory_names_every_outbound_request_not_just_unpaywall() {
1255        let joined = contact_report_lines(None).join("\n");
1256        assert!(
1257            joined.contains("every outbound request") && joined.contains("publisher content"),
1258            "the advisory must cover the content leg too:\n{joined}"
1259        );
1260        assert!(
1261            !joined.contains("unpaywall"),
1262            "naming only unpaywall is the bug:\n{joined}"
1263        );
1264        assert!(
1265            joined.contains("429"),
1266            "name the symptom the user will actually see:\n{joined}"
1267        );
1268    }
1269
1270    /// The set case says what is in effect, and does not warn.
1271    #[test]
1272    fn a_set_contact_address_reports_the_polite_pool_without_a_warning() {
1273        let joined = contact_report_lines(Some("a@example.org")).join("\n");
1274        assert!(joined.contains("a@example.org"), "{joined}");
1275        assert!(joined.contains("all outbound requests"), "{joined}");
1276        assert!(
1277            !joined.contains("429"),
1278            "no warning when it is set:\n{joined}"
1279        );
1280    }
1281}