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}