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    /// Which rung produced `contact_email` (#504) — same reason
58    /// `store_root_source` exists (#441): a setting that did nothing must
59    /// not look like a setting that worked.
60    pub contact_email_source: String,
61    /// Unpaywall-specific contact email. Inherits `contact_email` when no
62    /// Unpaywall-specific rung is set, matching
63    /// `doiget_core::orchestrator::resolve_contact_emails` — the reporting
64    /// side used to omit that fallback and print `unset` for the commonest
65    /// configuration of all (only `DOIGET_CONTACT_EMAIL` set), while the
66    /// fetch it describes was sending the contact address.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub unpaywall_email: Option<String>,
69    /// Which rung produced `unpaywall_email` (#504).
70    pub unpaywall_email_source: String,
71}
72
73/// Which rung of the #504 ladder produced an address.
74///
75/// Reported for the reason [`super::StoreRootSource`] is: `config init`
76/// wrote `[network] unpaywall_email`, the template called it STRONGLY
77/// RECOMMENDED, `doctor` never mentioned it, and nothing read it.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) enum EmailSource {
80    /// The env var for this field.
81    Env(&'static str),
82    /// `[network] <field>` in the user's `config.toml`.
83    ConfigFile(&'static str),
84    /// No Unpaywall-specific rung is set, so it inherits `contact_email`.
85    /// Only ever produced for `unpaywall_email`.
86    InheritedFromContact,
87    /// Nothing set it.
88    Unset,
89}
90
91impl EmailSource {
92    /// Short label for `config show` / `doctor`.
93    fn label(self) -> String {
94        match self {
95            Self::Env(v) => v.to_string(),
96            Self::ConfigFile(k) => format!("[network] {k} in config.toml"),
97            Self::InheritedFromContact => "inherited from contact_email".to_string(),
98            Self::Unset => "unset".to_string(),
99        }
100    }
101}
102
103/// Resolve one address across env → `config.toml` → nothing.
104///
105/// Mirrors `doiget_core::orchestrator::configured_contact_email`. Two
106/// separate readers is the hazard #441 named, so the shared half — which
107/// file, and how it parses — lives in `doiget_core::user_extension`, and
108/// only the rung order is restated here. Both ends are pinned:
109/// `contact_email_comes_from_config_toml_when_the_env_is_unset` below,
110/// and `resolve_contact_email_reads_the_config_file_rung` in
111/// `doiget-core`.
112fn resolve_email(
113    env_var: &'static str,
114    key: &'static str,
115    from_file: Option<String>,
116) -> (Option<String>, EmailSource) {
117    if let Some(v) = std::env::var(env_var).ok().filter(|s| !s.trim().is_empty()) {
118        return (Some(v), EmailSource::Env(env_var));
119    }
120    match from_file {
121        Some(v) => (Some(v), EmailSource::ConfigFile(key)),
122        None => (None, EmailSource::Unset),
123    }
124}
125
126impl ResolvedConfig {
127    /// Resolve the live config from process environment + platform defaults.
128    ///
129    /// Errors only if the platform config directory (`dirs::config_dir()`) or
130    /// the current working directory cannot be determined or is non-UTF-8
131    /// (an unknown / locked-down platform); on every realistic POSIX or
132    /// Windows host this returns `Ok` even with no `DOIGET_*` env vars set.
133    pub fn from_env() -> Result<Self> {
134        // Issue #405: resolve the config dir the SAME way the READER does
135        // (`commands::fetch::config_dir_utf8`, which `build_http_client`
136        // uses to load `[[network.additional_hosts]]`), for the same
137        // reason `store_root` and `log_path` already reuse their writers'
138        // resolvers — `config show` / `config path` / `doctor` must never
139        // name a file other than the one that is actually read.
140        //
141        // They diverged before this: `dirs::config_dir()` resolves the
142        // Windows roaming AppData through the known-folder API and ignores
143        // `XDG_CONFIG_HOME` entirely, while `config_dir_utf8()` checks
144        // `XDG_CONFIG_HOME` first on every platform. So on Windows a user
145        // with `XDG_CONFIG_HOME` set — normal for cross-platform dotfiles —
146        // got `doiget fetch` reading one `config.toml` while
147        // `doiget config doctor` validated a different one and reported
148        // "user-extension hosts loaded: 0" about a file the fetch path had
149        // never opened. That makes the #405 doctor hint point at the wrong
150        // file, which is worse than not printing it.
151        let cfg = super::fetch::config_dir_utf8()?;
152
153        // Store root: identical resolution to where artifacts actually land
154        // (`super::resolve_store_root`) so `config show` / `doctor` never drifts
155        // from the writer — `DOIGET_STORE_ROOT` else `./papers` under the cwd
156        // (#344 / ADR-0036).
157        let (store_root, store_root_source) = super::resolve_store_root_with_source()?;
158
159        // Issue #142: resolve the log path the SAME way the writer does
160        // (`commands::fetch::resolve_log_path` / `commands::audit_log`):
161        // `DOIGET_LOG_PATH` (the only log env var documented in
162        // `docs/CONFIG.md` §4) when set, otherwise
163        // `<config_dir>/doiget/access.jsonl`. The undocumented
164        // `DOIGET_LOG_DIR` is no longer read, so `config show` can no
165        // longer disagree with the path the provenance log is written to.
166        let log_path = match std::env::var("DOIGET_LOG_PATH") {
167            Ok(s) if !s.is_empty() => Utf8PathBuf::from(s),
168            _ => cfg.join("doiget").join("access.jsonl"),
169        };
170        // `log_dir` is purely derived from `log_path` so the two can never
171        // drift; fall back to the config dir for a path with no parent.
172        let log_dir = log_path
173            .parent()
174            .map(Utf8PathBuf::from)
175            .unwrap_or_else(|| cfg.join("doiget"));
176
177        let config_dir = cfg.join("doiget");
178        let config_path = config_dir.join("config.toml");
179
180        // #504: the two addresses have a `config.toml` rung, like
181        // `store_root` since #441. Read once — a parse failure loses every
182        // file rung at once, and `doctor`'s user-extension check reports it
183        // separately, so this stays quiet and reports `unset`.
184        let file = doiget_core::user_extension::load(&config_path).unwrap_or_default();
185        let (contact_email, contact_email_source) =
186            resolve_email("DOIGET_CONTACT_EMAIL", "contact_email", file.contact_email);
187        let (unpaywall_email, unpaywall_email_source) = match resolve_email(
188            "DOIGET_UNPAYWALL_EMAIL",
189            "unpaywall_email",
190            file.unpaywall_email,
191        ) {
192            // No Unpaywall-specific rung: the fetch path falls back to the
193            // resolved contact address, so the report has to say so rather
194            // than `unset`.
195            (None, _) => (
196                contact_email.clone(),
197                if contact_email.is_some() {
198                    EmailSource::InheritedFromContact
199                } else {
200                    EmailSource::Unset
201                },
202            ),
203            found => found,
204        };
205
206        Ok(Self {
207            store_root,
208            store_root_source: store_root_source.label().to_string(),
209            log_dir,
210            log_path,
211            config_dir,
212            config_path,
213            contact_email,
214            contact_email_source: contact_email_source.label(),
215            unpaywall_email,
216            unpaywall_email_source: unpaywall_email_source.label(),
217        })
218    }
219}
220
221/// Dispatch entrypoint for `doiget config <action>`.
222///
223/// `action` is one of `show`, `path`, `doctor`. Anything else returns
224/// `Err`; clap currently passes the raw string through.
225//
226// `print_stdout` and `print_stderr` are workspace-deny / workspace-warn for
227// MCP stdio safety. The `config` subcommand is the explicit human-facing
228// stdout channel for the resolved config; `doctor`'s checklist lines also
229// belong on stderr by design (stdout stays clean for `| jq` style pipes
230// when we add `--json` later).
231#[allow(clippy::print_stdout, clippy::print_stderr)]
232pub async fn run(
233    action: String,
234    mode: super::output::OutputMode,
235    network: bool,
236    force: bool,
237    quiet_was_explicit: bool,
238) -> Result<()> {
239    // `mode` honors ADR-0017, per ACTION rather than per command -- like
240    // `audit-log`, `config` is not uniformly one class:
241    //
242    //   * `path` / `show` are ARTIFACT. Their stdout IS the thing the
243    //     caller asked for, so only an EXPLICIT Quiet silences them. The
244    //     implicit non-TTY Quiet must not, which is #476: `doiget config
245    //     path` from a pipe printed zero bytes and exited 0, so the
246    //     documented way to find your config file (`docs/CONFIG.md` SS4,
247    //     SS12) answered a script, a CI step or an agent with silence AND
248    //     success. Third time the ADR-0017 classification was found
249    //     incomplete, after #219/#220 and #301.
250    //   * `init` is a STATUS report about a write that happened. Quiet of
251    //     either kind silences it; the exit code carries the outcome.
252    //   * `doctor` is unaffected either way -- its per-check output is on
253    //     stderr and the exit code is the signal (#203).
254    //
255    // Json body for `show` is tracked in #204.
256    let artifact_quiet = mode == super::output::OutputMode::Quiet && quiet_was_explicit;
257    let cfg = ResolvedConfig::from_env()?;
258    if network && action != "doctor" {
259        eprintln_err("error: --network applies to `config doctor` only");
260        return Err(anyhow::Error::new(CliExit(2)));
261    }
262    if force && action != "init" {
263        eprintln_err("error: --force applies to `config init` only");
264        return Err(anyhow::Error::new(CliExit(2)));
265    }
266    match action.as_str() {
267        "show" if artifact_quiet => {}
268        "show" => match mode {
269            super::output::OutputMode::Quiet => {
270                // Implicit (non-TTY) Quiet: `show` is artifact-class, so
271                // render rather than suppress (#476).
272                let s = toml::to_string_pretty(&cfg)?;
273                print!("{s}");
274            }
275            super::output::OutputMode::Json => {
276                // #204: `ResolvedConfig` is `Serialize` (already used for
277                // the TOML branch).
278                let s = serde_json::to_string_pretty(&cfg)
279                    .map_err(|e| anyhow::anyhow!("serialise config to JSON: {e}"))?;
280                println!("{s}");
281            }
282            _ => {
283                let s = toml::to_string_pretty(&cfg)?;
284                print!("{s}");
285            }
286        },
287        "init" => init_config(&cfg, force, mode)?,
288        "path" if artifact_quiet => {}
289        "path" => match mode {
290            super::output::OutputMode::Quiet => {
291                // Implicit (non-TTY) Quiet: naming the file IS the job.
292                println!("{}", cfg.config_path);
293            }
294            super::output::OutputMode::Json => {
295                // Minimal JSON object so callers can parse the path
296                // uniformly; no trailing-newline ambiguity vs the raw
297                // `path` form.
298                println!(
299                    "{}",
300                    serde_json::json!({ "config_path": cfg.config_path.as_str() })
301                );
302            }
303            _ => {
304                println!("{}", cfg.config_path);
305            }
306        },
307        "doctor" => {
308            let mut all_ok = true;
309            let store_parent = cfg.store_root.parent().map(|p| p.as_str()).unwrap_or("");
310            // Issue #406: the default store root is `./papers` under the
311            // CWD (ADR-0036), so it MOVES every time the user `cd`s. A
312            // check that says only "parent exists" confirms a path the
313            // user cannot see; naming it is what makes the cwd-relative
314            // default self-evident instead of surprising.
315            check(
316                &format!("store_root: {}", cfg.store_root),
317                true,
318                None,
319                &mut all_ok,
320            );
321            // #441: naming the rung is the whole point. A `[store] root`
322            // that is set but unread lands on the cwd default, and the two
323            // coincide whenever the user runs from the directory they
324            // configured — so "store_root: /home/me/papers" alone cannot
325            // distinguish "your setting worked" from "your setting was
326            // ignored and you happen to be standing in it".
327            eprintln!("       from: {}", cfg.store_root_source);
328            if cfg.store_root_source == super::StoreRootSource::CwdDefault.label() {
329                eprintln!(
330                    "       note: relative to the current directory (ADR-0036). Set DOIGET_STORE_ROOT"
331                );
332                eprintln!("             (or store.root in config.toml) for one central library.");
333            }
334            check(
335                "store_root parent exists",
336                cfg.store_root.parent().map(|p| p.exists()).unwrap_or(true),
337                Some(&format!(
338                    "create the parent directory or override via \
339                     DOIGET_STORE_ROOT\n               \
340                     missing parent: {store_parent}"
341                )),
342                &mut all_ok,
343            );
344            let log_parent = cfg.log_dir.parent().map(|p| p.as_str()).unwrap_or("");
345            check(
346                "log_dir parent exists",
347                cfg.log_dir.parent().map(|p| p.exists()).unwrap_or(true),
348                Some(&format!(
349                    "create the parent directory or override via \
350                     DOIGET_LOG_PATH\n               \
351                     missing parent: {log_parent}"
352                )),
353                &mut all_ok,
354            );
355            check(
356                &format!("contact_email set (from: {})", cfg.contact_email_source),
357                cfg.contact_email.is_some(),
358                Some(
359                    "set DOIGET_CONTACT_EMAIL, or [network] contact_email in config.toml\n               \
360                     e.g. export DOIGET_CONTACT_EMAIL=you@institution.edu\n               \
361                     (polite User-Agent header + Unpaywall API; without it requests go\n               \
362                     out as doiget@localhost from the non-polite pool, where a throttled\n               \
363                     answer is indistinguishable from `no OA copy` — #504)",
364                ),
365                &mut all_ok,
366            );
367            // `config show` reports which rung answered for Unpaywall;
368            // doctor did not mention the address at all, so the surface
369            // meant to be the trustworthy one was silent about a request
370            // doiget actually makes. Never a failure on its own — it
371            // inherits `contact_email`, whose line above carries the remedy.
372            check(
373                &format!(
374                    "unpaywall_email: {} (from: {})",
375                    cfg.unpaywall_email.as_deref().unwrap_or("unset"),
376                    cfg.unpaywall_email_source
377                ),
378                true,
379                None,
380                &mut all_ok,
381            );
382            // ADR-0028 D2: surface user-extension allowlist health. A
383            // missing config.toml is normal (curated set only); a
384            // present-but-malformed config.toml is a doctor failure so
385            // the operator finds out before fetch attempts silently
386            // skip the extension path. `user_extension::load` returns
387            // `Ok(vec![])` for not-found, so the OK arm always reports
388            // a count.
389            match doiget_core::user_extension::load(&cfg.config_path) {
390                Ok(cfg_ext) => {
391                    check(
392                        &format!(
393                            "user-extension hosts loaded: {} (academic={}, oa_registries={})",
394                            cfg_ext.additional_hosts.len(),
395                            cfg_ext.trust_academic_repos,
396                            cfg_ext.trust_oa_registries
397                        ),
398                        true,
399                        None,
400                        &mut all_ok,
401                    );
402                    // Issue #405: reporting `trust_academic_repos=false`
403                    // states the fact without naming the fix, and a default
404                    // install (no config.toml at all) is exactly the posture
405                    // whose OA fetches get denied at an off-allowlist
406                    // redirect. When nothing has widened the allowlist, name
407                    // the file and both keys. `check` swallows tips on a
408                    // passing check by design, so this is a separate
409                    // advisory line — the check itself stays `[ ok ]`,
410                    // because having no config is a valid posture.
411                    let widened = cfg_ext.trust_academic_repos
412                        || cfg_ext.trust_oa_registries
413                        || !cfg_ext.additional_hosts.is_empty();
414                    if !widened {
415                        eprintln!("       note: built-in allowlist only. To widen it, edit");
416                        eprintln!("             {}", cfg.config_path);
417                        eprintln!(
418                            "             [network] trust_academic_repos = true   # *.ac.uk, \
419                             *.ac.jp, ..."
420                        );
421                        eprintln!(
422                            "             [network] trust_oa_registries  = true   # DOAJ, \
423                             SciELO, Zenodo, ..."
424                        );
425                        eprintln!(
426                            "             [[network.additional_hosts]]            # anything \
427                             else — docs/CONFIG.md §3.1"
428                        );
429                    }
430                }
431                Err(e) => check(
432                    &format!("user-extension config invalid: {e}"),
433                    false,
434                    Some(&format!(
435                        "fix {} — see docs/CONFIG.md §3 for the \
436                         [[network.additional_hosts]] schema",
437                        cfg.config_path
438                    )),
439                    &mut all_ok,
440                ),
441            }
442            // #509 gave `credentials.toml` a reader; this gives it the same
443            // doctor visibility its sibling above already had. Without it
444            // the file's failure modes reach only `tracing::warn!`, which
445            // `EnvFilter::from_default_env()` suppresses at the default
446            // level — so a malformed or world-readable credentials file
447            // produced no warning, no doctor line, and only the downstream
448            // "source unavailable" the whole feature exists to prevent.
449            //
450            // A missing file is normal and reports `[ ok ]` with a count of
451            // zero: TDM is opt-in, and most installs have no such file.
452            // Keys are counted, never printed.
453            let cred_path = cfg.config_dir.join("credentials.toml");
454            match doiget_core::credentials::load(&cred_path) {
455                Ok(creds) => {
456                    check(
457                        &format!(
458                            "credentials.toml keys loaded: {} ({})",
459                            creds.len(),
460                            if creds.is_empty() {
461                                "none — TDM sources need DOIGET_KEY_* or this file"
462                            } else {
463                                "publisher names only; keys are never printed"
464                            }
465                        ),
466                        true,
467                        None,
468                        &mut all_ok,
469                    );
470                    // A file that parses can still be wrong in ways that
471                    // cost the user a key: mode 0644 on a file holding
472                    // publisher credentials, an `api_key = ""` they believe
473                    // is set, an `agreed` this tool does not read. Each was
474                    // a `tracing::warn!` and nothing else, which the
475                    // default `EnvFilter` throws away — so the `0600` check
476                    // `docs/CONFIG.md` §6 promises reached nobody. Each is
477                    // a failing line here, because each is a thing the user
478                    // has to act on.
479                    for advisory in creds.advisories() {
480                        check(
481                            &format!("credentials.toml: {advisory}"),
482                            false,
483                            None,
484                            &mut all_ok,
485                        );
486                    }
487                }
488                Err(e) => check(
489                    &format!("credentials.toml invalid: {e}"),
490                    false,
491                    Some(&format!(
492                        "fix {cred_path} — see docs/CONFIG.md §6 for the \
493                         [tdm.<publisher>] schema. Only `api_key` is read; \
494                         the agreement is DOIGET_AGREE_TDM_<PUBLISHER>=1 in \
495                         the environment (ADR-0050)"
496                    )),
497                    &mut all_ok,
498                ),
499            }
500            // Issue #407: the network section is opt-in behind `--network`
501            // because it makes real outbound requests. Everything above is
502            // local and always runs, so `--network` extends the report
503            // rather than replacing it.
504            if network {
505                network_report(&cfg).await;
506            }
507            // Trying to actually create the dirs would have side-effects;
508            // keep doctor read-only and just check existence of parents.
509            if !all_ok {
510                // Issue #149: a failing doctor means missing/invalid
511                // config — `docs/ERRORS.md` §4 classes "missing config"
512                // as misuse → exit 2 (the per-check `[FAIL]` lines were
513                // already written to stderr by `check`).
514                eprintln_err("error: config doctor: one or more checks failed");
515                return Err(anyhow::Error::new(CliExit(2)));
516            }
517        }
518        other => {
519            // Issue #149: an unknown subcommand action is clear argument
520            // misuse → `docs/ERRORS.md` §4 exit 2, not the generic exit 1
521            // a bare `bail!` produced.
522            eprintln_err(&format!(
523                "error: unknown config action: {other}; expected `init` / `show` / `path` / `doctor`"
524            ));
525            return Err(anyhow::Error::new(CliExit(2)));
526        }
527    }
528    Ok(())
529}
530
531/// Stderr sink for the `docs/ERRORS.md` §3 human-error lines. The
532/// localized `#[allow]` is the minimal intervention for the workspace
533/// `clippy::print_stderr` lint (same pattern as `commands::fetch`).
534#[allow(clippy::print_stderr)]
535fn eprintln_err(msg: &str) {
536    eprintln!("{msg}");
537}
538
539/// Emit one `[ ok ]` / `[FAIL]` checklist line to stderr and update the
540/// running pass/fail flag. Stderr is used so that `doiget config doctor`
541/// stdout stays empty for green runs (script-friendly).
542///
543/// When `ok` is `false` and `tip` is `Some`, a remediation tip is printed
544/// on the next line, indented so it is visually attached to the failed
545/// check (issue #322).
546/// The commented `config.toml` template written by `doiget config init`.
547///
548/// Issue #408: on a fresh install `~/.config/doiget/config.toml` does not
549/// exist, nothing creates it, and four of the settings that decide the
550/// outcome of a session live in it. Three of those four fail *silently*.
551/// Every commented line here doubles as documentation at the place the user
552/// is already looking.
553///
554/// Pure and `pub(crate)` so the tests can assert the template actually
555/// mentions each load-bearing key — a template that silently loses one is
556/// the failure mode worth guarding against.
557pub(crate) fn config_template() -> &'static str {
558    // NOTE: every key below is commented out on purpose. Writing live values
559    // would change behaviour just by running `init`; the file's job is to be
560    // a discoverable, annotated menu, not a new set of defaults.
561    r#"# ~/.config/doiget/config.toml — written by `doiget config init`.
562#
563# Every field is optional and every line below is commented out: this file
564# documents the choices, it does not change behaviour until you uncomment
565# something. Re-run `doiget config init --force` to restore this template.
566#
567# See docs/CONFIG.md for the full schema, and run `doiget config doctor`
568# (add --network for outbound checks) to see what is actually in effect.
569
570[store]
571# Where fetched papers are written.
572#
573# DEFAULT: `./papers` — relative to the CURRENT WORKING DIRECTORY, so it
574# moves with you (ADR-0036). That is deliberate: artifacts land where the
575# work is, instead of somewhere you have to go looking for. The cost is that
576# fetching from many directories leaves several small stores. Set this for a
577# single central library.
578#
579# Overridden by DOIGET_STORE_ROOT and by --store-root, which share a rung
580# above this one. A leading `~` IS expanded here (a config file has no
581# shell, unlike the env var).
582# root = "/home/you/papers"
583
584[network]
585# Contact address for the polite pool. STRONGLY RECOMMENDED.
586#
587# Without it doiget still queries Unpaywall and Crossref, but as
588# `doiget@localhost`, from the non-polite pool — where you may be throttled
589# or refused. Since the automatic arXiv-preprint fallback fires on what
590# Unpaywall reports, a throttled response quietly costs you that fallback
591# too, and the run still exits 0 saying `no OA PDF available`.
592#
593# This is also what `doiget config doctor` checks. Overridden by
594# DOIGET_CONTACT_EMAIL, one rung above.
595# contact_email = "you@institution.edu"
596
597# Only if Unpaywall should see a DIFFERENT address from contact_email above
598# — most people should leave this alone. Overridden by
599# DOIGET_UNPAYWALL_EMAIL.
600# unpaywall_email = "you@institution.edu"
601
602# Allow the curated academic-repository suffixes, i.e. where institutions
603# host their own Green OA:
604#   *.ac.uk  *.ac.jp  *.jst.go.jp  *.edu.au  *.edu.cn  *.ac.cn  *.edu.pl
605#   *.ac.nz  *.ac.za  *.ac.in  *.edu.br  *.edu.tw  *.edu.tr  *.edu.ar
606#   *.edu.mx
607#
608# Without this, an OA PDF on e.g. `strathprints.strath.ac.uk` is denied with
609# `error[CAPABILITY_DENIED] ... redirect_not_in_allowlist`.
610# trust_academic_repos = false
611
612# Allow the curated cross-publisher OA registries and repositories:
613#   scielo.org  zenodo.org  osf.io  hal.science  core.ac.uk  (+ subdomains)
614#
615# Separate from the flag above because the trust argument differs: one is
616# "this institution publishes its own work here", the other is "this registry
617# indexes open content across publishers". DOAJ needs no flag — it is on the
618# default allowlist (ADR-0037).
619# trust_oa_registries = false
620
621# Anything outside both curated sets. Each entry is a literal FQDN or a
622# single-suffix wildcard (`*.example.edu`); multi-segment globs are rejected
623# at load time, as are unknown keys in this table.
624# [[network.additional_hosts]]
625# host = "repository.example.edu"
626# note = "free-text, optional"
627
628# Request timeouts, in seconds.
629# connect_timeout_sec = 10
630# read_timeout_sec = 60
631# total_timeout_sec = 300
632
633[output]
634# mode = "human"     # human | json | quiet | mcp
635# color = "auto"     # auto | always | never
636# progress = false
637# emoji = false
638"#
639}
640
641/// `doiget config init` — write [`config_template`] to the resolved config
642/// path (issue #408).
643///
644/// Refuses to overwrite an existing file unless `force`. That refusal is the
645/// whole safety property: the file may hold a user's hand-written allowlist,
646/// and silently replacing it with a fully commented-out template would
647/// disable every host they had added.
648#[allow(clippy::print_stdout, clippy::print_stderr)]
649fn init_config(cfg: &ResolvedConfig, force: bool, mode: super::output::OutputMode) -> Result<()> {
650    let path = &cfg.config_path;
651    let existed = path.exists();
652    if existed && !force {
653        eprintln_err(&format!(
654            "error: {path} already exists; pass --force to overwrite it"
655        ));
656        return Err(anyhow::Error::new(CliExit(2)));
657    }
658    if let Some(parent) = path.parent() {
659        std::fs::create_dir_all(parent.as_std_path())
660            .with_context(|| format!("creating config directory {parent}"))?;
661    }
662    std::fs::write(path.as_std_path(), config_template())
663        .with_context(|| format!("writing {path}"))?;
664
665    match mode {
666        super::output::OutputMode::Quiet => {}
667        super::output::OutputMode::Json => {
668            println!(
669                "{}",
670                serde_json::json!({
671                    "ok": true,
672                    "config_path": path.as_str(),
673                    "overwritten": existed,
674                })
675            );
676        }
677        _ => {
678            let verb = if existed { "overwrote" } else { "wrote" };
679            println!("{verb} {path}");
680            eprintln_err(
681                "  = note: every field is commented out; nothing changed until you edit it",
682            );
683        }
684    }
685    Ok(())
686}
687
688/// Classification of a single publisher probe (issue #407).
689///
690/// The point of the enum is the `BotChallenge` arm. A publisher WAF answers
691/// a scripted client with `202 Accepted` and an empty body; a report that
692/// only printed the status would call that a success and send the user off
693/// to debug their subscription, when the binding constraint is that they
694/// are not a browser. Status and body size together separate the two.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum ProbeVerdict {
697    /// 2xx with a non-empty body — the host served this client.
698    Ok {
699        /// HTTP status observed.
700        status: u16,
701        /// Body size in bytes.
702        bytes: usize,
703    },
704    /// 2xx with an empty body. Almost always a bot-challenge holding
705    /// response, not a paywall and not an outage.
706    BotChallenge {
707        /// HTTP status observed (typically 202).
708        status: u16,
709    },
710    /// 401 / 403 — reached the host, which refused. A subscription or
711    /// credential question, not a transport one.
712    Refused {
713        /// HTTP status observed (401 or 403).
714        status: u16,
715    },
716    /// Any other status.
717    Status {
718        /// HTTP status observed.
719        status: u16,
720    },
721    /// The host is not on the source allowlist, so no request was sent.
722    NotAllowlisted,
723    /// Transport failure — DNS, TLS, connect, or timeout.
724    Unreachable {
725        /// Rendered transport error.
726        reason: String,
727    },
728}
729
730impl ProbeVerdict {
731    /// Map a [`doiget_core::http::ProbeOutcome`] to a verdict. Pure, so
732    /// the classification —
733    /// the part with the actual judgement in it — is unit-testable without
734    /// a network or a mock server.
735    pub fn classify(status: u16, body_bytes: usize) -> Self {
736        match status {
737            200..=299 if body_bytes == 0 => Self::BotChallenge { status },
738            200..=299 => Self::Ok {
739                status,
740                bytes: body_bytes,
741            },
742            401 | 403 => Self::Refused { status },
743            other => Self::Status { status: other },
744        }
745    }
746
747    /// One-line rendering: what happened, then what it means.
748    pub fn render(&self) -> String {
749        match self {
750            Self::Ok { status, bytes } => format!("{status} {bytes} bytes    ok"),
751            Self::BotChallenge { status } => {
752                format!("{status} empty body  bot challenge — needs a TDM key or a real browser")
753            }
754            Self::Refused { status } => {
755                format!("{status}               reached, refused — subscription or credential")
756            }
757            Self::Status { status } => format!("{status}               unexpected status"),
758            Self::NotAllowlisted => {
759                "not allowlisted   no request sent; add the host or enable a trust flag".to_string()
760            }
761            Self::Unreachable { reason } => format!("unreachable       {reason}"),
762        }
763    }
764}
765
766/// The `doiget config doctor --network` contact-address block (#443).
767///
768/// Split out as a pure function for the same reason as
769/// `fetch::denial_note_lines`: it is a diagnostic whose exact wording is
770/// the whole point, and a diagnostic nothing asserts on is a diagnostic
771/// that can silently regress.
772///
773/// It used to read `unpaywall  non-polite pool (... may be throttled)`,
774/// which attributes the whole cost of an unset contact address to the
775/// metadata lookup. The 429 that prompted this came from the publisher on
776/// the CONTENT leg. The User-Agent goes out on every request, so the label
777/// has to say every request.
778fn contact_report_lines(contact_email: Option<&str>) -> Vec<String> {
779    match contact_email {
780        Some(e) => vec![format!(
781            "  contact         polite User-Agent as {e} (all outbound requests)"
782        )],
783        None => vec![
784            "  contact         unset — set DOIGET_CONTACT_EMAIL or [network] contact_email"
785                .to_string(),
786            "                  in config.toml. Until then every outbound request, metadata"
787                .to_string(),
788            "                  AND publisher content, goes out on the non-polite pool and"
789                .to_string(),
790            "                  may be throttled (HTTP 429) or refused".to_string(),
791        ],
792    }
793}
794
795/// `doiget config doctor --network` — the outbound half of the report
796/// (issue #407).
797///
798/// Answers the question a user on an institutional network actually has:
799/// *which publishers will talk to me?* One GET per probed host, no retries,
800/// and only against hosts already on the `oa-publisher` allowlist — a
801/// doctor that probed arbitrary hosts would be an SSRF gadget wearing a
802/// diagnostic hat.
803///
804/// **Egress address is deliberately not reported.** Determining it requires
805/// asking a third-party echo service, which would be a new outbound
806/// dependency and a new `PRIVACY.md` entry for a diagnostic. The report
807/// names the proxy configuration in effect — the part doiget actually
808/// knows — and leaves the address to `curl`.
809#[allow(clippy::print_stderr)]
810async fn network_report(cfg: &ResolvedConfig) {
811    eprintln!();
812    eprintln!("network (--network):");
813
814    for var in ["HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"] {
815        if let Ok(v) = std::env::var(var) {
816            if !v.is_empty() {
817                eprintln!("  proxy           {var}={v}");
818            }
819        }
820    }
821    eprintln!(
822        "  egress          not probed (needs a third-party echo service; try `curl ifconfig.me`)"
823    );
824    eprintln!("                  a proxy fixes addressing, never a bot wall");
825
826    for line in contact_report_lines(cfg.contact_email.as_deref()) {
827        eprintln!("{line}");
828    }
829
830    let client = match crate::commands::fetch::build_http_client(None) {
831        Ok(c) => c,
832        Err(e) => {
833            eprintln!("  probes          unavailable: {e}");
834            return;
835        }
836    };
837    let Some(allow) = client.source_allowlist("oa-publisher") else {
838        eprintln!("  probes          unavailable: oa-publisher source not registered");
839        return;
840    };
841    eprintln!(
842        "  oa-publisher    {} host patterns allowlisted",
843        allow.redirect_hosts.len()
844    );
845
846    // Publishers a paywalled-literature user is most likely to ask about.
847    // Listed whether or not they are allowlisted: "ieee.org NOT
848    // allowlisted" is the single most useful line in the report for the
849    // #407 case, and it can only be printed for a host we name up front.
850    const PROBES: &[(&str, &str)] = &[
851        ("link.springer.com", "https://link.springer.com/robots.txt"),
852        ("www.mdpi.com", "https://www.mdpi.com/robots.txt"),
853        ("journals.plos.org", "https://journals.plos.org/robots.txt"),
854        ("arxiv.org", "https://arxiv.org/robots.txt"),
855        (
856            "ieeexplore.ieee.org",
857            "https://ieeexplore.ieee.org/robots.txt",
858        ),
859        ("dl.acm.org", "https://dl.acm.org/robots.txt"),
860        ("epubs.siam.org", "https://epubs.siam.org/robots.txt"),
861        ("doaj.org", "https://doaj.org/robots.txt"),
862    ];
863    for (host, url) in PROBES {
864        // `permits`, not `matches` (#533). This is the fifth adjudication
865        // site, and the one whose whole job is telling a user why a host was
866        // refused: with `matches` it would report a DOI resolver as
867        // `NotAllowlisted` while the real fetch path follows it, which is the
868        // exact wrong answer #533 was about.
869        let verdict = if !allow.permits(host) {
870            ProbeVerdict::NotAllowlisted
871        } else {
872            match url::Url::parse(url) {
873                Err(e) => ProbeVerdict::Unreachable {
874                    reason: format!("bad probe URL: {e}"),
875                },
876                Ok(u) => match client.probe("oa-publisher", u).await {
877                    Ok(o) => ProbeVerdict::classify(o.status, o.body_bytes),
878                    Err(e) => ProbeVerdict::Unreachable {
879                        reason: e.to_string(),
880                    },
881                },
882            }
883        };
884        eprintln!("  probe {host:<22} {}", verdict.render());
885    }
886    eprintln!();
887    eprintln!("  IP-based subscription does not imply fetchability: a publisher WAF can");
888    eprintln!("  answer a scripted client with a challenge regardless of entitlement. The");
889    eprintln!("  routes that work are per-publisher TDM credentials (docs/CONFIG.md §6)");
890    eprintln!("  or a real browser on the subscribing network.");
891}
892
893#[allow(clippy::print_stderr)]
894fn check(label: &str, ok: bool, tip: Option<&str>, all_ok: &mut bool) {
895    let mark = if ok { "[ ok ]" } else { "[FAIL]" };
896    eprintln!("{mark} {label}");
897    if !ok {
898        if let Some(t) = tip {
899            eprintln!("       tip: {t}");
900        }
901        *all_ok = false;
902    }
903}
904
905// ---------------------------------------------------------------------------
906// Tests — env-mutating, serialized via serial_test (same convention as
907// `doiget-core::tests`). Each test resets the four env vars it touches via
908// an EnvGuard RAII drop guard so that prior values are restored on panic.
909// ---------------------------------------------------------------------------
910#[cfg(test)]
911mod tests {
912    #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
913
914    use super::*;
915
916    /// RAII guard that captures the prior value of an env var on
917    /// construction and restores it on drop. Mirrors the convention in
918    /// `crates/doiget-core/src/lib.rs::tests`.
919    struct EnvGuard {
920        var: &'static str,
921        prior: Option<std::ffi::OsString>,
922    }
923
924    impl EnvGuard {
925        fn unset(var: &'static str) -> Self {
926            let prior = std::env::var_os(var);
927            // SAFETY: tests are serialized via `#[serial_test::serial]`;
928            // no other thread reads/writes env state concurrently.
929            std::env::remove_var(var);
930            EnvGuard { var, prior }
931        }
932
933        fn set(var: &'static str, value: &str) -> Self {
934            let prior = std::env::var_os(var);
935            std::env::set_var(var, value);
936            EnvGuard { var, prior }
937        }
938    }
939
940    impl Drop for EnvGuard {
941        fn drop(&mut self) {
942            match &self.prior {
943                Some(v) => std::env::set_var(self.var, v),
944                None => std::env::remove_var(self.var),
945            }
946        }
947    }
948
949    /// Unset every env var the `config` subcommand reads. Returns guards
950    /// that restore prior values on drop.
951    fn unset_all_doiget_config_env() -> Vec<EnvGuard> {
952        [
953            "DOIGET_STORE_ROOT",
954            "DOIGET_LOG_PATH",
955            "DOIGET_CONTACT_EMAIL",
956            "DOIGET_UNPAYWALL_EMAIL",
957        ]
958        .iter()
959        .map(|v| EnvGuard::unset(v))
960        .collect()
961    }
962
963    /// Point every config-dir rung at `dir`, so `config_dir_utf8()`
964    /// resolves there on any host. Returns guards that restore prior values.
965    fn scoped_config_home(dir: &str) -> Vec<EnvGuard> {
966        ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
967            .iter()
968            .map(|v| EnvGuard::set(v, dir))
969            .collect()
970    }
971
972    #[test]
973    #[serial_test::serial]
974    fn from_env_uses_cwd_default_when_unset() {
975        let _g = unset_all_doiget_config_env();
976        let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
977        // The default must be `<cwd>/papers` (ADR-0036), NOT `<home>/papers` —
978        // assert the full path so a regression back to the home directory is
979        // actually caught (a bare `ends_with("papers")` passes for both).
980        let cwd =
981            camino::Utf8PathBuf::from_path_buf(std::env::current_dir().expect("cwd is available"))
982                .expect("cwd is valid UTF-8");
983        assert_eq!(
984            cfg.store_root,
985            cwd.join("papers"),
986            "store_root should default to <cwd>/papers when DOIGET_STORE_ROOT is unset; got {}",
987            cfg.store_root
988        );
989        assert_eq!(cfg.contact_email, None);
990        assert_eq!(cfg.unpaywall_email, None);
991        assert_eq!(cfg.contact_email_source, "unset");
992        assert_eq!(cfg.unpaywall_email_source, "unset");
993    }
994
995    /// #504, and the exact shape #441 needed: a `config.toml` carrying only
996    /// `[network] contact_email` must produce a non-default contact.
997    ///
998    /// The old tests asserted the env-derived values and the template's key
999    /// list, and **both passed with the rung missing** — so on a machine
1000    /// configured from the template every fetch went out as
1001    /// `doiget@localhost` while `doctor` reported the store root correctly
1002    /// from the very same file.
1003    #[test]
1004    #[serial_test::serial]
1005    fn contact_email_comes_from_config_toml_when_the_env_is_unset() {
1006        let _g = unset_all_doiget_config_env();
1007        let td = tempfile::TempDir::new().expect("tempdir");
1008        let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
1009            .expect("temp path is UTF-8");
1010        std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
1011        std::fs::write(
1012            dir.join("doiget").join("config.toml").as_std_path(),
1013            "[network]\ncontact_email = \"file@institution.edu\"\nunpaywall_email = \"up@institution.edu\"\n",
1014        )
1015        .expect("write config");
1016        let _scoped = scoped_config_home(dir.as_str());
1017
1018        let cfg = ResolvedConfig::from_env().expect("config resolves");
1019        assert_eq!(cfg.contact_email.as_deref(), Some("file@institution.edu"));
1020        assert_eq!(cfg.unpaywall_email.as_deref(), Some("up@institution.edu"));
1021        assert_eq!(
1022            cfg.contact_email_source, "[network] contact_email in config.toml",
1023            "doctor must name the rung that answered, or an inert setting looks like a live one"
1024        );
1025    }
1026
1027    /// The commonest configuration of all: only `DOIGET_CONTACT_EMAIL` is
1028    /// set. The fetch path sends that address to Unpaywall
1029    /// (`resolve_contact_emails` falls back to the resolved contact), so
1030    /// `doctor` and `show` must say so. They used to print `unset`, which
1031    /// described a request doiget does not make.
1032    #[test]
1033    #[serial_test::serial]
1034    fn an_unset_unpaywall_address_reports_the_contact_it_actually_inherits() {
1035        let _g = unset_all_doiget_config_env();
1036        let td = tempfile::TempDir::new().expect("tempdir");
1037        let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
1038            .expect("temp path is UTF-8");
1039        let _scoped = scoped_config_home(dir.as_str());
1040        let _c = EnvGuard::set("DOIGET_CONTACT_EMAIL", "only@institution.edu");
1041
1042        let cfg = ResolvedConfig::from_env().expect("config resolves");
1043        assert_eq!(
1044            cfg.unpaywall_email.as_deref(),
1045            Some("only@institution.edu"),
1046            "the report must match what the fetch path sends"
1047        );
1048        assert_eq!(cfg.unpaywall_email_source, "inherited from contact_email");
1049    }
1050
1051    /// With nothing set at all there is nothing to inherit, so `unset` is
1052    /// still the honest answer.
1053    #[test]
1054    #[serial_test::serial]
1055    fn with_no_address_anywhere_unpaywall_still_reports_unset() {
1056        let _g = unset_all_doiget_config_env();
1057        let td = tempfile::TempDir::new().expect("tempdir");
1058        let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
1059            .expect("temp path is UTF-8");
1060        let _scoped = scoped_config_home(dir.as_str());
1061
1062        let cfg = ResolvedConfig::from_env().expect("config resolves");
1063        assert_eq!(cfg.unpaywall_email, None);
1064        assert_eq!(cfg.unpaywall_email_source, "unset");
1065    }
1066
1067    /// The env rung stays above the file rung, per field.
1068    #[test]
1069    #[serial_test::serial]
1070    fn the_env_var_outranks_the_config_file_for_each_address() {
1071        let _g = unset_all_doiget_config_env();
1072        let td = tempfile::TempDir::new().expect("tempdir");
1073        let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
1074            .expect("temp path is UTF-8");
1075        std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
1076        std::fs::write(
1077            dir.join("doiget").join("config.toml").as_std_path(),
1078            "[network]\ncontact_email = \"file@institution.edu\"\n",
1079        )
1080        .expect("write config");
1081        let _scoped = scoped_config_home(dir.as_str());
1082        std::env::set_var("DOIGET_CONTACT_EMAIL", "env@institution.edu");
1083
1084        let cfg = ResolvedConfig::from_env().expect("config resolves");
1085        std::env::remove_var("DOIGET_CONTACT_EMAIL");
1086
1087        assert_eq!(cfg.contact_email.as_deref(), Some("env@institution.edu"));
1088        assert_eq!(cfg.contact_email_source, "DOIGET_CONTACT_EMAIL");
1089    }
1090
1091    /// Issue #405: `config show` / `config path` / `doctor` MUST name the
1092    /// same `config.toml` that `build_http_client` reads. Before this,
1093    /// `ResolvedConfig` used `dirs::config_dir()` while the reader used
1094    /// `fetch::config_dir_utf8()`; on Windows the former ignores
1095    /// `XDG_CONFIG_HOME` (known-folder API), so the doctor validated a
1096    /// file the fetch path never opened.
1097    #[test]
1098    #[serial_test::serial]
1099    fn config_path_matches_the_resolver_the_reader_uses() {
1100        struct EnvGuard(&'static str, Option<String>);
1101        impl Drop for EnvGuard {
1102            fn drop(&mut self) {
1103                match &self.1 {
1104                    Some(v) => std::env::set_var(self.0, v),
1105                    None => std::env::remove_var(self.0),
1106                }
1107            }
1108        }
1109        let td = tempfile::TempDir::new().expect("tempdir");
1110        let _guards: Vec<EnvGuard> = ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
1111            .iter()
1112            .map(|k| EnvGuard(k, std::env::var(k).ok()))
1113            .collect();
1114        std::env::set_var("XDG_CONFIG_HOME", td.path());
1115
1116        let cfg = ResolvedConfig::from_env().expect("resolve config");
1117        let reader = crate::commands::fetch::config_dir_utf8()
1118            .expect("reader resolves")
1119            .join("doiget")
1120            .join("config.toml");
1121        assert_eq!(
1122            cfg.config_path, reader,
1123            "doctor must validate the file the reader loads"
1124        );
1125        assert!(
1126            cfg.config_path.as_str().starts_with(
1127                camino::Utf8Path::from_path(td.path())
1128                    .expect("utf-8 tempdir")
1129                    .as_str()
1130            ),
1131            "XDG_CONFIG_HOME must win on every platform; got {}",
1132            cfg.config_path
1133        );
1134    }
1135
1136    #[test]
1137    #[serial_test::serial]
1138    fn from_env_overrides_via_env() {
1139        let _g = unset_all_doiget_config_env();
1140        // Use a platform-appropriate absolute path so Utf8PathBuf::try_from
1141        // succeeds on Windows too (where "/tmp/foo" is a relative path on
1142        // the current drive — still UTF-8, still fine for this assertion).
1143        let _override = EnvGuard::set("DOIGET_STORE_ROOT", "/tmp/foo");
1144        let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
1145        assert_eq!(cfg.store_root.as_str(), "/tmp/foo");
1146    }
1147
1148    /// Issue #142: `config show` MUST report the same `log_path` the
1149    /// provenance-log writer uses. The writer keys off `DOIGET_LOG_PATH`
1150    /// (the only log env var documented in `docs/CONFIG.md` §4); the
1151    /// resolver must do the same, and `log_dir` must be that path's
1152    /// parent — never an independently-resolved (and divergent) value.
1153    #[test]
1154    #[serial_test::serial]
1155    fn log_path_follows_doiget_log_path_env() {
1156        let _g = unset_all_doiget_config_env();
1157        let _override = EnvGuard::set("DOIGET_LOG_PATH", "/var/lib/doiget/access.jsonl");
1158        let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
1159        assert_eq!(
1160            cfg.log_path.as_str(),
1161            "/var/lib/doiget/access.jsonl",
1162            "config show must echo DOIGET_LOG_PATH verbatim (issue #142)"
1163        );
1164        assert_eq!(
1165            cfg.log_dir.as_str(),
1166            "/var/lib/doiget",
1167            "log_dir must be derived from log_path's parent so the two cannot drift"
1168        );
1169    }
1170
1171    // ── #408: `config init` ──────────────────────────────────────────────
1172
1173    /// The template's job is to document the keys that fail *silently* on a
1174    /// default install. If one is ever dropped from it, the file stops being
1175    /// the answer to #408 while still looking fine.
1176    #[test]
1177    fn template_documents_every_silently_defaulting_key() {
1178        let t = config_template();
1179        for key in [
1180            "[store]",
1181            "root =",
1182            "contact_email",
1183            "unpaywall_email",
1184            "trust_academic_repos",
1185            "trust_oa_registries",
1186            "[[network.additional_hosts]]",
1187        ] {
1188            assert!(t.contains(key), "template must mention {key}");
1189        }
1190        // ADR-0036 / ADR-0037 are the two non-obvious defaults; the template
1191        // must say what they are, not merely name the keys.
1192        assert!(
1193            t.contains("CURRENT WORKING DIRECTORY"),
1194            "store root default"
1195        );
1196        assert!(
1197            t.contains("doiget@localhost"),
1198            "non-polite pool consequence"
1199        );
1200        assert!(t.contains("DOAJ needs no flag"), "post-ADR-0037 accuracy");
1201    }
1202
1203    /// Every line must be inert: writing live values would mean `init`
1204    /// silently changed behaviour just by being run.
1205    #[test]
1206    fn template_is_entirely_commented_out() {
1207        for line in config_template().lines() {
1208            let t = line.trim();
1209            if t.is_empty() || t.starts_with('#') {
1210                continue;
1211            }
1212            assert!(
1213                t.starts_with('[') && t.ends_with(']') && !t.starts_with("[["),
1214                "only bare section headers may be live; found: {line:?}"
1215            );
1216        }
1217    }
1218
1219    /// The template must round-trip as TOML — a malformed one would be
1220    /// written happily and only fail on the user's next command.
1221    #[test]
1222    fn template_parses_as_toml() {
1223        let v: toml::Value = toml::from_str(config_template()).expect("template is valid TOML");
1224        // With everything commented out it must carry no live keys beyond the
1225        // empty section tables.
1226        for (name, tbl) in v.as_table().expect("table") {
1227            assert!(
1228                tbl.as_table().expect("section").is_empty(),
1229                "section [{name}] must be empty in the template"
1230            );
1231        }
1232    }
1233
1234    // ── #407: probe classification ───────────────────────────────────────
1235
1236    /// The load-bearing case. A publisher WAF answers a scripted client
1237    /// with `202 Accepted` and an empty body. Status alone reads that as
1238    /// success and sends the user off to debug a subscription that is not
1239    /// the problem — the measurement in #407 was exactly
1240    /// `status=202 body=0 bytes` from a subscribing university address.
1241    #[test]
1242    fn empty_2xx_body_is_a_bot_challenge_not_a_success() {
1243        assert_eq!(
1244            ProbeVerdict::classify(202, 0),
1245            ProbeVerdict::BotChallenge { status: 202 }
1246        );
1247        assert_eq!(
1248            ProbeVerdict::classify(200, 0),
1249            ProbeVerdict::BotChallenge { status: 200 },
1250            "an empty 200 is the same holding response wearing a different code"
1251        );
1252        assert!(
1253            ProbeVerdict::classify(202, 0)
1254                .render()
1255                .contains("bot challenge"),
1256            "the verdict must name the diagnosis, not just the status"
1257        );
1258    }
1259
1260    #[test]
1261    fn non_empty_2xx_is_ok() {
1262        assert_eq!(
1263            ProbeVerdict::classify(200, 1234),
1264            ProbeVerdict::Ok {
1265                status: 200,
1266                bytes: 1234
1267            }
1268        );
1269    }
1270
1271    /// 401/403 is a different diagnosis from a challenge: the host talked
1272    /// to us and declined, so the next step is credentials, not a browser.
1273    #[test]
1274    fn auth_statuses_are_refused_not_challenged() {
1275        for code in [401u16, 403] {
1276            assert_eq!(
1277                ProbeVerdict::classify(code, 0),
1278                ProbeVerdict::Refused { status: code },
1279                "{code} must not be misread as a bot challenge"
1280            );
1281        }
1282        assert_eq!(
1283            ProbeVerdict::classify(404, 0),
1284            ProbeVerdict::Status { status: 404 }
1285        );
1286    }
1287
1288    /// Every verdict renders something a user can act on; none is blank.
1289    #[test]
1290    fn every_verdict_renders_non_empty_advice() {
1291        let all = [
1292            ProbeVerdict::Ok {
1293                status: 200,
1294                bytes: 1,
1295            },
1296            ProbeVerdict::BotChallenge { status: 202 },
1297            ProbeVerdict::Refused { status: 403 },
1298            ProbeVerdict::Status { status: 500 },
1299            ProbeVerdict::NotAllowlisted,
1300            ProbeVerdict::Unreachable {
1301                reason: "dns".into(),
1302            },
1303        ];
1304        for v in &all {
1305            assert!(!v.render().trim().is_empty(), "{v:?} rendered empty");
1306        }
1307    }
1308
1309    #[tokio::test]
1310    #[serial_test::serial]
1311    async fn doctor_fails_without_contact_email() {
1312        // Issue #149: a failing doctor is "missing config" → exit 2.
1313        // The human-readable line moved to stderr; the error now carries
1314        // a `CliExit(2)` rather than a Display-formatted anyhow string.
1315        let _g = unset_all_doiget_config_env();
1316        let err = run(
1317            "doctor".into(),
1318            crate::commands::output::OutputMode::Human,
1319            false,
1320            false,
1321            false,
1322        )
1323        .await
1324        .expect_err("doctor should fail when DOIGET_CONTACT_EMAIL is unset");
1325        let cli_exit = err
1326            .downcast_ref::<CliExit>()
1327            .expect("failing doctor must carry a CliExit (issue #149)");
1328        assert_eq!(
1329            cli_exit.0, 2,
1330            "missing/invalid config is misuse → exit 2, not the generic exit 1"
1331        );
1332    }
1333
1334    #[tokio::test]
1335    #[serial_test::serial]
1336    async fn doctor_passes_with_contact_email() {
1337        let _g = unset_all_doiget_config_env();
1338        let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
1339        // config_dir() and the cwd resolve to real, existing parents on every
1340        // supported test host (store root defaults to <cwd>/papers, ADR-0036).
1341        run(
1342            "doctor".into(),
1343            crate::commands::output::OutputMode::Human,
1344            false,
1345            false,
1346            false,
1347        )
1348        .await
1349        .expect("doctor should pass with contact email + valid config dir and cwd");
1350    }
1351
1352    /// ADR-0028 D2: a malformed `<config_dir>/doiget/config.toml`
1353    /// causes `doiget config doctor` to FAIL (exit 2). Linux-only
1354    /// because `dirs::config_dir()` resolves differently on each
1355    /// platform:
1356    ///   - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` (env-driven,
1357    ///     testable).
1358    ///   - macOS: `~/Library/Application Support` (Known Folder via
1359    ///     `NSSearchPathForDirectoriesInDomains`, ignores
1360    ///     `XDG_CONFIG_HOME`).
1361    ///   - Windows: `%FOLDERID_RoamingAppData%` (Known Folder API,
1362    ///     ignores `APPDATA` env in child processes via
1363    ///     `assert_cmd`).
1364    /// The malformed-config FAIL path is platform-independent; this
1365    /// test covers the wiring on the one platform where it CAN be
1366    /// exercised in a hermetic test.
1367    #[cfg(target_os = "linux")]
1368    #[tokio::test]
1369    #[serial_test::serial]
1370    async fn doctor_fails_with_malformed_user_extension_config() {
1371        let _g = unset_all_doiget_config_env();
1372        let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
1373
1374        let tmp = tempfile::TempDir::new().expect("tempdir");
1375        let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
1376        let doiget_dir = cfg_root.join("doiget");
1377        std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
1378        let config_toml = doiget_dir.join("config.toml");
1379        // Empty `host` value triggers `PatternError::Empty`, which
1380        // the doctor surfaces as a FAIL. `note` is valid TOML so the
1381        // top-level parse succeeds — only the pattern validation
1382        // path produces the error we're pinning.
1383        std::fs::write(
1384            config_toml.as_std_path(),
1385            "[[network.additional_hosts]]\nhost = \"\"\n",
1386        )
1387        .expect("write config.toml");
1388
1389        // `fetch::config_dir_utf8()` — which `ResolvedConfig` now shares
1390        // with the reader — honors `XDG_CONFIG_HOME` first on every
1391        // platform, so pointing it at our tempdir routes
1392        // `cfg.config_path` to our crafted file.
1393        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1394
1395        let err = run(
1396            "doctor".into(),
1397            crate::commands::output::OutputMode::Human,
1398            false,
1399            false,
1400            false,
1401        )
1402        .await
1403        .expect_err("doctor should fail when user-extension config is malformed");
1404        let cli_exit = err
1405            .downcast_ref::<CliExit>()
1406            .expect("failing doctor must carry a CliExit");
1407        assert_eq!(cli_exit.0, 2);
1408    }
1409
1410    /// `credentials.toml`'s reader is unit-tested; its WIRING into doctor
1411    /// was not. Nothing failed if `cred_path` were built from the wrong
1412    /// directory, or if the `Err` arm stopped setting `all_ok = false` —
1413    /// which is the whole reason the check was added.
1414    ///
1415    /// Linux only, for the same reason as
1416    /// `doctor_fails_with_malformed_user_extension_config`: `XDG_CONFIG_HOME`
1417    /// is the only hermetic way to redirect the config dir.
1418    #[cfg(target_os = "linux")]
1419    #[tokio::test]
1420    #[serial_test::serial]
1421    async fn doctor_fails_when_credentials_toml_is_malformed() {
1422        let _g = unset_all_doiget_config_env();
1423        let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
1424
1425        let tmp = tempfile::TempDir::new().expect("tempdir");
1426        let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
1427        let doiget_dir = cfg_root.join("doiget");
1428        std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
1429        // An unterminated string: the commonest way a pasted key breaks
1430        // this file, and the case whose parser message must not be echoed.
1431        std::fs::write(
1432            doiget_dir.join("credentials.toml").as_std_path(),
1433            "[tdm.elsevier]\napi_key = \"sk-unterminated\n",
1434        )
1435        .expect("write credentials.toml");
1436        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1437
1438        let err = run(
1439            "doctor".into(),
1440            crate::commands::output::OutputMode::Human,
1441            false,
1442            false,
1443            false,
1444        )
1445        .await
1446        .expect_err("doctor must fail when credentials.toml is malformed");
1447        assert_eq!(
1448            err.downcast_ref::<CliExit>()
1449                .expect("failing doctor must carry a CliExit")
1450                .0,
1451            2
1452        );
1453    }
1454
1455    /// A file that parses but carries an advisory must fail doctor too.
1456    /// Otherwise `[ ok ] credentials.toml keys loaded: 0` is the entire
1457    /// report for a user who typed a key and did not get one.
1458    #[cfg(target_os = "linux")]
1459    #[tokio::test]
1460    #[serial_test::serial]
1461    async fn doctor_fails_when_credentials_toml_carries_an_advisory() {
1462        let _g = unset_all_doiget_config_env();
1463        let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
1464
1465        let tmp = tempfile::TempDir::new().expect("tempdir");
1466        let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
1467        let doiget_dir = cfg_root.join("doiget");
1468        std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
1469        // Well-formed TOML; the key is present and blank.
1470        std::fs::write(
1471            doiget_dir.join("credentials.toml").as_std_path(),
1472            "[tdm.aps]\napi_key = \"\"\n",
1473        )
1474        .expect("write credentials.toml");
1475        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1476
1477        let err = run(
1478            "doctor".into(),
1479            crate::commands::output::OutputMode::Human,
1480            false,
1481            false,
1482            false,
1483        )
1484        .await
1485        .expect_err("a blank api_key must be reported, not passed over");
1486        assert_eq!(
1487            err.downcast_ref::<CliExit>()
1488                .expect("failing doctor must carry a CliExit")
1489                .0,
1490            2
1491        );
1492    }
1493
1494    /// Issue #322: `check` must emit a `tip:` line to stderr when the
1495    /// check fails and a tip is provided. Passing `ok=true` must NOT
1496    /// emit the tip line even when one is supplied.
1497    #[test]
1498    fn check_emits_tip_on_failure_only() {
1499        let mut flag = true;
1500        // Passing check — tip must be swallowed.
1501        check("passing check", true, Some("should not appear"), &mut flag);
1502        assert!(flag, "all_ok must stay true for a passing check");
1503
1504        // Failing check with tip — all_ok must flip.
1505        check(
1506            "failing check",
1507            false,
1508            Some("set DOIGET_CONTACT_EMAIL"),
1509            &mut flag,
1510        );
1511        assert!(!flag, "all_ok must flip to false on a failing check");
1512    }
1513
1514    #[tokio::test]
1515    #[serial_test::serial]
1516    async fn unknown_action_errors() {
1517        // Issue #149: an unknown action is clear argument misuse →
1518        // `docs/ERRORS.md` §4 exit 2. The descriptive line moved to
1519        // stderr; the error carries `CliExit(2)`.
1520        let _g = unset_all_doiget_config_env();
1521        let err = run(
1522            "bogus".into(),
1523            crate::commands::output::OutputMode::Human,
1524            false,
1525            false,
1526            false,
1527        )
1528        .await
1529        .expect_err("bogus action should error");
1530        let cli_exit = err
1531            .downcast_ref::<CliExit>()
1532            .expect("unknown config action must carry a CliExit (issue #149)");
1533        assert_eq!(
1534            cli_exit.0, 2,
1535            "unknown config action is misuse → exit 2, not the generic exit 1"
1536        );
1537    }
1538    /// Build an isolated config dir containing `config.toml` with `body`.
1539    fn config_home_with(body: &str) -> (tempfile::TempDir, camino::Utf8PathBuf) {
1540        let td = tempfile::TempDir::new().expect("tempdir");
1541        let root = camino::Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8 tempdir");
1542        std::fs::create_dir_all(root.join("doiget").as_std_path()).expect("mkdir");
1543        std::fs::write(root.join("doiget").join("config.toml").as_std_path(), body)
1544            .expect("write config");
1545        (td, root)
1546    }
1547
1548    /// #441: the rung that was missing. `[store] root` must beat the cwd
1549    /// default.
1550    ///
1551    /// The assertion that matters is the NEGATIVE one. `store_root ==
1552    /// <configured>` alone would also pass if the config were ignored and
1553    /// the test happened to run from the configured directory — which is
1554    /// exactly how the bug hid: a user testing from `$HOME` with
1555    /// `root = "$HOME/papers"` sees the right answer for the wrong reason.
1556    #[test]
1557    #[serial_test::serial]
1558    fn store_root_in_config_beats_the_cwd_default() {
1559        let _g = unset_all_doiget_config_env();
1560        let lib_td = tempfile::TempDir::new().expect("tempdir");
1561        let library = camino::Utf8PathBuf::try_from(lib_td.path().to_path_buf())
1562            .expect("utf-8 tempdir")
1563            .as_str()
1564            .replace('\u{5c}', "/");
1565        let (_cfg_td, cfg_root) = config_home_with(&format!("[store]\nroot = \"{library}\"\n"));
1566
1567        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1568        let cfg = ResolvedConfig::from_env().expect("config resolves");
1569
1570        let cwd_default = camino::Utf8PathBuf::try_from(std::env::current_dir().expect("cwd"))
1571            .expect("utf-8 cwd")
1572            .join("papers");
1573        assert_ne!(
1574            cfg.store_root, cwd_default,
1575            "the config value was ignored and the cwd default answered instead"
1576        );
1577        assert_eq!(
1578            cfg.store_root.as_str().replace('\u{5c}', "/"),
1579            library,
1580            "[store] root must win over the cwd default (ADR-0036 rung 2)"
1581        );
1582        assert_eq!(
1583            cfg.store_root_source,
1584            super::super::StoreRootSource::ConfigFile.label(),
1585            "doctor must attribute it to the config file"
1586        );
1587    }
1588
1589    /// The rung ABOVE it still wins. Adding rung 2 must not demote the env
1590    /// var, which is also how `--store-root` is applied.
1591    #[test]
1592    #[serial_test::serial]
1593    fn env_beats_store_root_in_config() {
1594        let _g = unset_all_doiget_config_env();
1595        let (_cfg_td, cfg_root) = config_home_with("[store]\nroot = \"/from/config\"\n");
1596
1597        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1598        let _e = EnvGuard::set("DOIGET_STORE_ROOT", "/from/env");
1599        let cfg = ResolvedConfig::from_env().expect("config resolves");
1600
1601        assert_eq!(cfg.store_root.as_str(), "/from/env");
1602        assert_eq!(
1603            cfg.store_root_source,
1604            super::super::StoreRootSource::Env.label()
1605        );
1606    }
1607
1608    /// A blank value means "unset", not "the empty path" — otherwise it
1609    /// would resolve to the filesystem root.
1610    #[test]
1611    #[serial_test::serial]
1612    fn blank_store_root_in_config_falls_through_to_the_default() {
1613        let _g = unset_all_doiget_config_env();
1614        let (_cfg_td, cfg_root) = config_home_with("[store]\nroot = \"   \"\n");
1615
1616        let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
1617        let cfg = ResolvedConfig::from_env().expect("config resolves");
1618
1619        assert_eq!(
1620            cfg.store_root_source,
1621            super::super::StoreRootSource::CwdDefault.label(),
1622            "a blank root must not be treated as a configured value"
1623        );
1624    }
1625    /// #443: the wording must not pin the cost of an unset contact address
1626    /// on the metadata leg — the 429 that prompted this came from the
1627    /// publisher, on the content leg.
1628    ///
1629    /// #504 adds the second half: the advisory must name **both** rungs, or
1630    /// it sends a user who configured from `config init` to the one place
1631    /// they were not looking.
1632    #[test]
1633    fn the_contact_advisory_names_every_outbound_request_not_just_unpaywall() {
1634        let joined = contact_report_lines(None).join("\n");
1635        assert!(
1636            joined.contains("DOIGET_CONTACT_EMAIL") && joined.contains("[network] contact_email"),
1637            "both rungs must be named, not only the env var:\n{joined}"
1638        );
1639        assert!(
1640            joined.contains("every outbound request") && joined.contains("publisher content"),
1641            "the advisory must cover the content leg too:\n{joined}"
1642        );
1643        assert!(
1644            !joined.contains("unpaywall"),
1645            "naming only unpaywall is the bug:\n{joined}"
1646        );
1647        assert!(
1648            joined.contains("429"),
1649            "name the symptom the user will actually see:\n{joined}"
1650        );
1651    }
1652
1653    /// The set case says what is in effect, and does not warn.
1654    #[test]
1655    fn a_set_contact_address_reports_the_polite_pool_without_a_warning() {
1656        let joined = contact_report_lines(Some("a@example.org")).join("\n");
1657        assert!(joined.contains("a@example.org"), "{joined}");
1658        assert!(joined.contains("all outbound requests"), "{joined}");
1659        assert!(
1660            !joined.contains("429"),
1661            "no warning when it is set:\n{joined}"
1662        );
1663    }
1664}