Skip to main content

doiget_cli/commands/
mod.rs

1//! Subcommand implementations for the `doiget` CLI.
2//!
3//! Each module corresponds to a single `clap` subcommand declared in
4//! `main.rs`. The dispatch table in `main.rs` calls `run(...)` on the
5//! matching module. Subcommands return `anyhow::Result<()>`; any error
6//! surfaces via the CLI's top-level error reporter (stderr).
7//!
8//! ## Phase 1 surface (so far)
9//!
10//! - [`audit_log`] — `doiget audit-log --verify` recomputes the SHA-256 hash
11//!   chain on the provenance log and reports any mismatches.
12//! - [`batch`] — `doiget batch <path>` multi-ref orchestrator (rate-bounded).
13//! - [`bib`] — `doiget bib <ref>` BibTeX exporter (Phase 2 starter).
14//! - [`cite`] — `doiget cite <ref>` live-resolve BibTeX (doi2bib-style).
15//! - [`config`] — `doiget config show/path/doctor`.
16//! - [`csl`] — `doiget csl <ref>` exports a stored entry as CSL JSON 1.0.
17//! - [`fetch`] — `doiget fetch <ref>` orchestrator (arXiv E2E + DOI metadata-only).
18//! - [`info`] — prints a stored entry's `Metadata` as TOML on stdout.
19//! - [`list_recent`] — prints up to N most-recently-fetched entries.
20//! - [`search`] — case-insensitive substring search over stored metadata.
21//!
22//! `serve` has no module here: it delegates straight to `doiget-mcp`. It
23//! is wired in `main.rs` and is what every `docs/INTEGRATION/` guide tells
24//! users to run.
25
26/// Parse a ref, or render the failure in the `docs/ERRORS.md` §3
27/// "Researcher (CLI human)" form and return the CLI exit error.
28///
29/// #477. `error[CODE]: message` held on `fetch` alone -- #119 did it there
30/// and nowhere else -- while `info`, `link`, `cite`, `text`, `tag`, `bib`,
31/// `csl` and `source` each wrote their own `with_context("invalid ref: …")`
32/// and let `anyhow` print a bare `Error:` plus a `Caused by:` chain. The
33/// closed error-code set is a load-bearing promise of this project: a
34/// caller is told it can key off `error[CODE]:`, and on eight of nine
35/// commands there was no code to key off, while the `Caused by:` chain
36/// leaked internal error types that are in no contract.
37///
38/// Same shape as `render_fetch_error` and for the same reason -- one
39/// renderer, so a future change to the contract cannot reach some call
40/// sites and miss others.
41///
42/// # Errors
43///
44/// Always, when parsing fails: an [`anyhow::Error`] wrapping
45/// [`CliExit`](fetch::CliExit) with the `INVALID_REF` exit code. The
46/// message has already been written to stderr.
47pub fn parse_ref_or_exit(input: &str) -> anyhow::Result<doiget_core::Ref> {
48    match doiget_core::Ref::parse(input) {
49        Ok(r) => Ok(r),
50        Err(e) => {
51            render_ref_parse_error(&e);
52            Err(anyhow::Error::new(fetch::CliExit(fetch::cli_exit_code(
53                doiget_core::ErrorCode::InvalidRef,
54            ))))
55        }
56    }
57}
58
59/// The renderer on its own, for call sites holding a
60/// [`doiget_core::RefParseError`] that did not come from
61/// [`parse_ref_or_exit`].
62///
63/// Both now take the exit code from `fetch::cli_exit_code(InvalidRef)`,
64/// which is **2** since #492 / ADR-0049. Before that `fetch` fell to the
65/// generic `_ => 1` arm while `graph` hard-coded the 2 that
66/// `docs/ERRORS.md` §4 prescribes, so one binary gave two answers for the
67/// same input and each site's comment claimed agreement with the other.
68///
69/// One renderer and one exit code. `every_ref_taking_command_exits_2_for_
70/// an_invalid_ref` is what keeps it that way.
71pub fn render_ref_parse_error(e: &doiget_core::RefParseError) {
72    output::print_err(format_args!(
73        "error[{}]: invalid ref: {e}",
74        doiget_core::ErrorCode::InvalidRef.as_wire()
75    ));
76}
77
78pub mod audit_log;
79pub mod batch;
80pub mod bib;
81pub mod capabilities;
82pub mod cite;
83pub mod config;
84pub mod csl;
85pub mod fetch;
86pub mod frontier;
87pub mod info;
88pub mod link;
89pub mod lint;
90pub mod list_recent;
91pub mod output;
92pub mod provenance;
93pub mod resolve_citation;
94pub mod search;
95pub mod source;
96pub mod tag;
97pub mod tex_source;
98pub mod text;
99pub mod verify;
100pub mod version;
101
102// Phase 4 / Slice 16. Compile-gated by the `citation` Cargo feature
103// (which itself enables `doiget-core/citation`).
104#[cfg(feature = "citation")]
105pub mod graph;
106
107use anyhow::{Context, Result};
108use camino::Utf8PathBuf;
109
110/// Resolve the path of the user `config.toml`, for messages that need to
111/// name the file the user must edit.
112///
113/// Same resolution as [`config::ResolvedConfig::from_env`]
114/// (`<dirs::config_dir()>/doiget/config.toml`), kept here so the fetch-side
115/// denial help (issue #405) and `config doctor` cannot drift. Returns
116/// `None` only when the platform has no config dir at all, in which case
117/// callers should fall back to naming the file generically — a missing
118/// config dir must never turn an advisory line into a hard error.
119pub(crate) fn user_config_path() -> Option<Utf8PathBuf> {
120    // MUST stay `config_dir_utf8` — the resolver the READER uses. This
121    // shipped as `dirs::config_dir()`, which ignores `XDG_CONFIG_HOME` on
122    // Windows, so the denial help named `%APPDATA%\doiget\config.toml`
123    // while `build_http_client` was loading the XDG one. Naming the wrong
124    // file is worse than naming none, and it is the whole point of the
125    // #405 help line. Same fix as `ResolvedConfig::from_env`.
126    Some(
127        fetch::config_dir_utf8()
128            .ok()?
129            .join("doiget")
130            .join("config.toml"),
131    )
132}
133
134/// Where a resolved store root came from (#441).
135///
136/// Reported by `doiget config doctor` so that a setting which did nothing
137/// can no longer look like a setting that worked. That was the sharpest
138/// part of #441: `config init` recommended `[store] root`, `doctor`
139/// confirmed the recommendation, and the value was never read.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub(crate) enum StoreRootSource {
142    /// `DOIGET_STORE_ROOT` (also how `--store-root` is applied).
143    Env,
144    /// `[store] root` in the user's `config.toml`.
145    ConfigFile,
146    /// Nothing set it — `./papers` under the cwd (ADR-0036).
147    CwdDefault,
148}
149
150impl StoreRootSource {
151    /// Short label for `config show` / `doctor`.
152    pub(crate) fn label(self) -> &'static str {
153        match self {
154            Self::Env => "DOIGET_STORE_ROOT",
155            Self::ConfigFile => "[store] root in config.toml",
156            Self::CwdDefault => "default: ./papers under the cwd",
157        }
158    }
159}
160
161/// Resolve the on-disk store root.
162///
163/// Resolution order (ADR-0036, `docs/CONFIG.md` §4):
164///
165/// 1. `DOIGET_STORE_ROOT`, if set and non-empty. `--store-root` is applied
166///    by writing this variable, so the flag rides the same rung.
167/// 2. `[store] root` in the user's `config.toml`, expanded via
168///    [`doiget_core::user_extension::expand_store_root`].
169/// 3. `./papers` — `papers/` directly under the current working directory
170///    (#344 / ADR-0036), so fetched artifacts are visible where the user
171///    (or an LLM agent) is working rather than hidden in a far-off home
172///    directory.
173///
174/// Rung 2 is new in #441. It was documented from the start — ADR-0036
175/// states the order, `docs/CONFIG.md` §3 lists the key, `config init`
176/// writes it into the template it generates and `config doctor` recommends
177/// it — and read by nothing, so the store silently kept following the cwd.
178/// The old doc comment on this function said the config rung "lands with
179/// the `config` subcommand"; that subcommand shipped in 0.8.8 without it.
180pub(crate) fn resolve_store_root() -> Result<Utf8PathBuf> {
181    resolve_store_root_with_source().map(|(root, _)| root)
182}
183
184/// [`resolve_store_root`] plus which rung answered.
185pub(crate) fn resolve_store_root_with_source() -> Result<(Utf8PathBuf, StoreRootSource)> {
186    if let Ok(s) = std::env::var("DOIGET_STORE_ROOT") {
187        // Ignore an empty value or an unexpanded "${...}" placeholder — a
188        // Desktop-Extension config left blank can pass the literal
189        // "${user_config.store_root}", which must not become a path (#369).
190        let s = s.trim();
191        if !s.is_empty() && !s.contains("${") {
192            return Ok((Utf8PathBuf::from(s), StoreRootSource::Env));
193        }
194    }
195    if let Some(root) = store_root_from_config() {
196        return Ok((root, StoreRootSource::ConfigFile));
197    }
198    let cwd = std::env::current_dir().context(
199        "could not determine the current working directory for the default store root (set \
200            DOIGET_STORE_ROOT to choose an explicit store location)",
201    )?;
202    Utf8PathBuf::from_path_buf(cwd)
203        .map(|d| (d.join("papers"), StoreRootSource::CwdDefault))
204        .map_err(|p| anyhow::anyhow!("current directory path is not UTF-8: {}", p.display()))
205}
206
207/// `[store] root` from the user's `config.toml`, if any.
208///
209/// Does not fail the command on a malformed file — the store root is
210/// resolved by every subcommand, and one bad line should not be a total
211/// outage — but it does NOT stay quiet about it.
212///
213/// The previous comment here justified silence by claiming "a parse error
214/// surfaces with a proper diagnostic on the network path that owns this
215/// file". The #468 review checked that claim and it is false for most
216/// callers: `list-recent`, `search`, `info`, `tag`, `bib` and `csl` never
217/// build an HTTP client, so that diagnostic never runs for them. TOML fails
218/// the whole document, so a typo anywhere — even under `[network]` — makes
219/// `[store] root` unreadable, and the command silently used `./papers`
220/// under the cwd as though nothing had been configured.
221///
222/// `search` then reports zero results, indistinguishable from an empty
223/// library. Worse, `tag` **writes** metadata into the wrong store and
224/// reports success. A warning is the minimum; the resolved source is also
225/// reported by `config doctor`.
226fn store_root_from_config() -> Option<Utf8PathBuf> {
227    let path = user_config_path()?;
228    let cfg = match doiget_core::user_extension::load(&path) {
229        Ok(c) => c,
230        Err(e) => {
231            tracing::warn!(
232                path = %path,
233                error = %e,
234                "config.toml could not be read; [store] root ignored and the default store                  root used instead. Run `doiget config doctor` to see which root is in effect."
235            );
236            return None;
237        }
238    };
239    let raw = cfg.store_root?;
240    Some(doiget_core::user_extension::expand_store_root(&raw))
241}