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::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 /// Directory holding doiget's append-only logs. Derived from
38 /// `log_path`'s parent so it always agrees with the writer.
39 pub log_dir: Utf8PathBuf,
40 /// JSON-Lines provenance log file path. `DOIGET_LOG_PATH` when set,
41 /// otherwise `<config_dir>/doiget/access.jsonl` (`docs/CONFIG.md` §4).
42 pub log_path: Utf8PathBuf,
43 /// Directory holding `config.toml` and `credentials.toml`.
44 pub config_dir: Utf8PathBuf,
45 /// Path of the user config file (may not exist on disk yet).
46 pub config_path: Utf8PathBuf,
47 /// Contact email for the polite User-Agent header (and Unpaywall fallback).
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub contact_email: Option<String>,
50 /// Unpaywall-specific contact email; falls back to `contact_email` when unset.
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub unpaywall_email: Option<String>,
53}
54
55impl ResolvedConfig {
56 /// Resolve the live config from process environment + platform defaults.
57 ///
58 /// Errors only if the platform config directory (`dirs::config_dir()`) or
59 /// the current working directory cannot be determined or is non-UTF-8
60 /// (an unknown / locked-down platform); on every realistic POSIX or
61 /// Windows host this returns `Ok` even with no `DOIGET_*` env vars set.
62 pub fn from_env() -> Result<Self> {
63 // `dirs::config_dir()` returns `std::path::PathBuf`; hoist it into
64 // `Utf8PathBuf` immediately at the OS boundary so the rest of the
65 // function (and the public struct) stays UTF-8-only per the workspace
66 // `disallowed-types` clippy rule.
67 let cfg = Utf8PathBuf::try_from(
68 dirs::config_dir().ok_or_else(|| anyhow::anyhow!("no config dir"))?,
69 )?;
70
71 // Store root: identical resolution to where artifacts actually land
72 // (`super::resolve_store_root`) so `config show` / `doctor` never drifts
73 // from the writer — `DOIGET_STORE_ROOT` else `./papers` under the cwd
74 // (#344 / ADR-0036).
75 let store_root = super::resolve_store_root()?;
76
77 // Issue #142: resolve the log path the SAME way the writer does
78 // (`commands::fetch::resolve_log_path` / `commands::audit_log`):
79 // `DOIGET_LOG_PATH` (the only log env var documented in
80 // `docs/CONFIG.md` §4) when set, otherwise
81 // `<config_dir>/doiget/access.jsonl`. The undocumented
82 // `DOIGET_LOG_DIR` is no longer read, so `config show` can no
83 // longer disagree with the path the provenance log is written to.
84 let log_path = match std::env::var("DOIGET_LOG_PATH") {
85 Ok(s) if !s.is_empty() => Utf8PathBuf::from(s),
86 _ => cfg.join("doiget").join("access.jsonl"),
87 };
88 // `log_dir` is purely derived from `log_path` so the two can never
89 // drift; fall back to the config dir for a path with no parent.
90 let log_dir = log_path
91 .parent()
92 .map(Utf8PathBuf::from)
93 .unwrap_or_else(|| cfg.join("doiget"));
94
95 let config_dir = cfg.join("doiget");
96 let config_path = config_dir.join("config.toml");
97
98 Ok(Self {
99 store_root,
100 log_dir,
101 log_path,
102 config_dir,
103 config_path,
104 contact_email: std::env::var("DOIGET_CONTACT_EMAIL").ok(),
105 unpaywall_email: std::env::var("DOIGET_UNPAYWALL_EMAIL").ok(),
106 })
107 }
108}
109
110/// Dispatch entrypoint for `doiget config <action>`.
111///
112/// `action` is one of `show`, `path`, `doctor`. Anything else returns
113/// `Err`; clap currently passes the raw string through.
114//
115// `print_stdout` and `print_stderr` are workspace-deny / workspace-warn for
116// MCP stdio safety. The `config` subcommand is the explicit human-facing
117// stdout channel for the resolved config; `doctor`'s checklist lines also
118// belong on stderr by design (stdout stays clean for `| jq` style pipes
119// when we add `--json` later).
120#[allow(clippy::print_stdout, clippy::print_stderr)]
121pub fn run(action: String, mode: super::output::OutputMode) -> Result<()> {
122 // `mode` honors ADR-0017: `Quiet` suppresses the TOML dump (`show`)
123 // and the path println! (`path`); `doctor` is unaffected because its
124 // per-check output is on stderr and only the failure/success exit
125 // code is the user-visible signal (#203). Json body for `show` is
126 // tracked in #204.
127 let cfg = ResolvedConfig::from_env()?;
128 match action.as_str() {
129 "show" => match mode {
130 super::output::OutputMode::Quiet => {}
131 super::output::OutputMode::Json => {
132 // #204: `ResolvedConfig` is `Serialize` (already used for
133 // the TOML branch).
134 let s = serde_json::to_string_pretty(&cfg)
135 .map_err(|e| anyhow::anyhow!("serialise config to JSON: {e}"))?;
136 println!("{s}");
137 }
138 _ => {
139 let s = toml::to_string_pretty(&cfg)?;
140 print!("{s}");
141 }
142 },
143 "path" => match mode {
144 super::output::OutputMode::Quiet => {}
145 super::output::OutputMode::Json => {
146 // Minimal JSON object so callers can parse the path
147 // uniformly; no trailing-newline ambiguity vs the raw
148 // `path` form.
149 println!(
150 "{}",
151 serde_json::json!({ "config_path": cfg.config_path.as_str() })
152 );
153 }
154 _ => {
155 println!("{}", cfg.config_path);
156 }
157 },
158 "doctor" => {
159 let mut all_ok = true;
160 let store_parent = cfg.store_root.parent().map(|p| p.as_str()).unwrap_or("");
161 check(
162 "store_root parent exists",
163 cfg.store_root.parent().map(|p| p.exists()).unwrap_or(true),
164 Some(&format!(
165 "create the parent directory or override via \
166 DOIGET_STORE_ROOT\n \
167 missing parent: {store_parent}"
168 )),
169 &mut all_ok,
170 );
171 let log_parent = cfg.log_dir.parent().map(|p| p.as_str()).unwrap_or("");
172 check(
173 "log_dir parent exists",
174 cfg.log_dir.parent().map(|p| p.exists()).unwrap_or(true),
175 Some(&format!(
176 "create the parent directory or override via \
177 DOIGET_LOG_PATH\n \
178 missing parent: {log_parent}"
179 )),
180 &mut all_ok,
181 );
182 check(
183 "contact_email set",
184 cfg.contact_email.is_some(),
185 Some(
186 "set DOIGET_CONTACT_EMAIL to your email address\n \
187 e.g. export DOIGET_CONTACT_EMAIL=you@institution.edu\n \
188 (required for the polite User-Agent header and Unpaywall API)",
189 ),
190 &mut all_ok,
191 );
192 // ADR-0028 D2: surface user-extension allowlist health. A
193 // missing config.toml is normal (curated set only); a
194 // present-but-malformed config.toml is a doctor failure so
195 // the operator finds out before fetch attempts silently
196 // skip the extension path. `user_extension::load` returns
197 // `Ok(vec![])` for not-found, so the OK arm always reports
198 // a count.
199 match doiget_core::user_extension::load(&cfg.config_path) {
200 Ok(cfg_ext) => check(
201 &format!(
202 "user-extension hosts loaded: {} (trust_academic_repos={})",
203 cfg_ext.additional_hosts.len(),
204 cfg_ext.trust_academic_repos
205 ),
206 true,
207 None,
208 &mut all_ok,
209 ),
210 Err(e) => check(
211 &format!("user-extension config invalid: {e}"),
212 false,
213 Some(&format!(
214 "fix {} — see docs/CONFIG.md §3 for the \
215 [[network.additional_hosts]] schema",
216 cfg.config_path
217 )),
218 &mut all_ok,
219 ),
220 }
221 // Trying to actually create the dirs would have side-effects;
222 // keep doctor read-only and just check existence of parents.
223 if !all_ok {
224 // Issue #149: a failing doctor means missing/invalid
225 // config — `docs/ERRORS.md` §4 classes "missing config"
226 // as misuse → exit 2 (the per-check `[FAIL]` lines were
227 // already written to stderr by `check`).
228 eprintln_err("error: config doctor: one or more checks failed");
229 return Err(anyhow::Error::new(CliExit(2)));
230 }
231 }
232 other => {
233 // Issue #149: an unknown subcommand action is clear argument
234 // misuse → `docs/ERRORS.md` §4 exit 2, not the generic exit 1
235 // a bare `bail!` produced.
236 eprintln_err(&format!(
237 "error: unknown config action: {other}; expected `show` / `path` / `doctor`"
238 ));
239 return Err(anyhow::Error::new(CliExit(2)));
240 }
241 }
242 Ok(())
243}
244
245/// Stderr sink for the `docs/ERRORS.md` §3 human-error lines. The
246/// localized `#[allow]` is the minimal intervention for the workspace
247/// `clippy::print_stderr` lint (same pattern as `commands::fetch`).
248#[allow(clippy::print_stderr)]
249fn eprintln_err(msg: &str) {
250 eprintln!("{msg}");
251}
252
253/// Emit one `[ ok ]` / `[FAIL]` checklist line to stderr and update the
254/// running pass/fail flag. Stderr is used so that `doiget config doctor`
255/// stdout stays empty for green runs (script-friendly).
256///
257/// When `ok` is `false` and `tip` is `Some`, a remediation tip is printed
258/// on the next line, indented so it is visually attached to the failed
259/// check (issue #322).
260#[allow(clippy::print_stderr)]
261fn check(label: &str, ok: bool, tip: Option<&str>, all_ok: &mut bool) {
262 let mark = if ok { "[ ok ]" } else { "[FAIL]" };
263 eprintln!("{mark} {label}");
264 if !ok {
265 if let Some(t) = tip {
266 eprintln!(" tip: {t}");
267 }
268 *all_ok = false;
269 }
270}
271
272// ---------------------------------------------------------------------------
273// Tests — env-mutating, serialized via serial_test (same convention as
274// `doiget-core::tests`). Each test resets the four env vars it touches via
275// an EnvGuard RAII drop guard so that prior values are restored on panic.
276// ---------------------------------------------------------------------------
277#[cfg(test)]
278mod tests {
279 #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
280
281 use super::*;
282
283 /// RAII guard that captures the prior value of an env var on
284 /// construction and restores it on drop. Mirrors the convention in
285 /// `crates/doiget-core/src/lib.rs::tests`.
286 struct EnvGuard {
287 var: &'static str,
288 prior: Option<std::ffi::OsString>,
289 }
290
291 impl EnvGuard {
292 fn unset(var: &'static str) -> Self {
293 let prior = std::env::var_os(var);
294 // SAFETY: tests are serialized via `#[serial_test::serial]`;
295 // no other thread reads/writes env state concurrently.
296 std::env::remove_var(var);
297 EnvGuard { var, prior }
298 }
299
300 fn set(var: &'static str, value: &str) -> Self {
301 let prior = std::env::var_os(var);
302 std::env::set_var(var, value);
303 EnvGuard { var, prior }
304 }
305 }
306
307 impl Drop for EnvGuard {
308 fn drop(&mut self) {
309 match &self.prior {
310 Some(v) => std::env::set_var(self.var, v),
311 None => std::env::remove_var(self.var),
312 }
313 }
314 }
315
316 /// Unset every env var the `config` subcommand reads. Returns guards
317 /// that restore prior values on drop.
318 fn unset_all_doiget_config_env() -> Vec<EnvGuard> {
319 [
320 "DOIGET_STORE_ROOT",
321 "DOIGET_LOG_PATH",
322 "DOIGET_CONTACT_EMAIL",
323 "DOIGET_UNPAYWALL_EMAIL",
324 ]
325 .iter()
326 .map(|v| EnvGuard::unset(v))
327 .collect()
328 }
329
330 #[test]
331 #[serial_test::serial]
332 fn from_env_uses_cwd_default_when_unset() {
333 let _g = unset_all_doiget_config_env();
334 let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
335 // The default must be `<cwd>/papers` (ADR-0036), NOT `<home>/papers` —
336 // assert the full path so a regression back to the home directory is
337 // actually caught (a bare `ends_with("papers")` passes for both).
338 let cwd =
339 camino::Utf8PathBuf::from_path_buf(std::env::current_dir().expect("cwd is available"))
340 .expect("cwd is valid UTF-8");
341 assert_eq!(
342 cfg.store_root,
343 cwd.join("papers"),
344 "store_root should default to <cwd>/papers when DOIGET_STORE_ROOT is unset; got {}",
345 cfg.store_root
346 );
347 assert_eq!(cfg.contact_email, None);
348 assert_eq!(cfg.unpaywall_email, None);
349 }
350
351 #[test]
352 #[serial_test::serial]
353 fn from_env_overrides_via_env() {
354 let _g = unset_all_doiget_config_env();
355 // Use a platform-appropriate absolute path so Utf8PathBuf::try_from
356 // succeeds on Windows too (where "/tmp/foo" is a relative path on
357 // the current drive — still UTF-8, still fine for this assertion).
358 let _override = EnvGuard::set("DOIGET_STORE_ROOT", "/tmp/foo");
359 let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
360 assert_eq!(cfg.store_root.as_str(), "/tmp/foo");
361 }
362
363 /// Issue #142: `config show` MUST report the same `log_path` the
364 /// provenance-log writer uses. The writer keys off `DOIGET_LOG_PATH`
365 /// (the only log env var documented in `docs/CONFIG.md` §4); the
366 /// resolver must do the same, and `log_dir` must be that path's
367 /// parent — never an independently-resolved (and divergent) value.
368 #[test]
369 #[serial_test::serial]
370 fn log_path_follows_doiget_log_path_env() {
371 let _g = unset_all_doiget_config_env();
372 let _override = EnvGuard::set("DOIGET_LOG_PATH", "/var/lib/doiget/access.jsonl");
373 let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
374 assert_eq!(
375 cfg.log_path.as_str(),
376 "/var/lib/doiget/access.jsonl",
377 "config show must echo DOIGET_LOG_PATH verbatim (issue #142)"
378 );
379 assert_eq!(
380 cfg.log_dir.as_str(),
381 "/var/lib/doiget",
382 "log_dir must be derived from log_path's parent so the two cannot drift"
383 );
384 }
385
386 #[test]
387 #[serial_test::serial]
388 fn doctor_fails_without_contact_email() {
389 // Issue #149: a failing doctor is "missing config" → exit 2.
390 // The human-readable line moved to stderr; the error now carries
391 // a `CliExit(2)` rather than a Display-formatted anyhow string.
392 let _g = unset_all_doiget_config_env();
393 let err = run("doctor".into(), crate::commands::output::OutputMode::Human)
394 .expect_err("doctor should fail when DOIGET_CONTACT_EMAIL is unset");
395 let cli_exit = err
396 .downcast_ref::<CliExit>()
397 .expect("failing doctor must carry a CliExit (issue #149)");
398 assert_eq!(
399 cli_exit.0, 2,
400 "missing/invalid config is misuse → exit 2, not the generic exit 1"
401 );
402 }
403
404 #[test]
405 #[serial_test::serial]
406 fn doctor_passes_with_contact_email() {
407 let _g = unset_all_doiget_config_env();
408 let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
409 // config_dir() and the cwd resolve to real, existing parents on every
410 // supported test host (store root defaults to <cwd>/papers, ADR-0036).
411 run("doctor".into(), crate::commands::output::OutputMode::Human)
412 .expect("doctor should pass with contact email + valid config dir and cwd");
413 }
414
415 /// ADR-0028 D2: a malformed `<config_dir>/doiget/config.toml`
416 /// causes `doiget config doctor` to FAIL (exit 2). Linux-only
417 /// because `dirs::config_dir()` resolves differently on each
418 /// platform:
419 /// - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` (env-driven,
420 /// testable).
421 /// - macOS: `~/Library/Application Support` (Known Folder via
422 /// `NSSearchPathForDirectoriesInDomains`, ignores
423 /// `XDG_CONFIG_HOME`).
424 /// - Windows: `%FOLDERID_RoamingAppData%` (Known Folder API,
425 /// ignores `APPDATA` env in child processes via
426 /// `assert_cmd`).
427 /// The malformed-config FAIL path is platform-independent; this
428 /// test covers the wiring on the one platform where it CAN be
429 /// exercised in a hermetic test.
430 #[cfg(target_os = "linux")]
431 #[test]
432 #[serial_test::serial]
433 fn doctor_fails_with_malformed_user_extension_config() {
434 let _g = unset_all_doiget_config_env();
435 let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
436
437 let tmp = tempfile::TempDir::new().expect("tempdir");
438 let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
439 let doiget_dir = cfg_root.join("doiget");
440 std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
441 let config_toml = doiget_dir.join("config.toml");
442 // Empty `host` value triggers `PatternError::Empty`, which
443 // the doctor surfaces as a FAIL. `note` is valid TOML so the
444 // top-level parse succeeds — only the pattern validation
445 // path produces the error we're pinning.
446 std::fs::write(
447 config_toml.as_std_path(),
448 "[[network.additional_hosts]]\nhost = \"\"\n",
449 )
450 .expect("write config.toml");
451
452 // POSIX `dirs::config_dir()` honors `XDG_CONFIG_HOME` first,
453 // so pointing it at our tempdir routes `cfg.config_path` to
454 // our crafted file.
455 let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
456
457 let err = run("doctor".into(), crate::commands::output::OutputMode::Human)
458 .expect_err("doctor should fail when user-extension config is malformed");
459 let cli_exit = err
460 .downcast_ref::<CliExit>()
461 .expect("failing doctor must carry a CliExit");
462 assert_eq!(cli_exit.0, 2);
463 }
464
465 /// Issue #322: `check` must emit a `tip:` line to stderr when the
466 /// check fails and a tip is provided. Passing `ok=true` must NOT
467 /// emit the tip line even when one is supplied.
468 #[test]
469 fn check_emits_tip_on_failure_only() {
470 let mut flag = true;
471 // Passing check — tip must be swallowed.
472 check("passing check", true, Some("should not appear"), &mut flag);
473 assert!(flag, "all_ok must stay true for a passing check");
474
475 // Failing check with tip — all_ok must flip.
476 check(
477 "failing check",
478 false,
479 Some("set DOIGET_CONTACT_EMAIL"),
480 &mut flag,
481 );
482 assert!(!flag, "all_ok must flip to false on a failing check");
483 }
484
485 #[test]
486 #[serial_test::serial]
487 fn unknown_action_errors() {
488 // Issue #149: an unknown action is clear argument misuse →
489 // `docs/ERRORS.md` §4 exit 2. The descriptive line moved to
490 // stderr; the error carries `CliExit(2)`.
491 let _g = unset_all_doiget_config_env();
492 let err = run("bogus".into(), crate::commands::output::OutputMode::Human)
493 .expect_err("bogus action should error");
494 let cli_exit = err
495 .downcast_ref::<CliExit>()
496 .expect("unknown config action must carry a CliExit (issue #149)");
497 assert_eq!(
498 cli_exit.0, 2,
499 "unknown config action is misuse → exit 2, not the generic exit 1"
500 );
501 }
502}