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}