Skip to main content

doiget_cli/commands/
verify.rs

1//! `doiget verify <path>` — check that every DOI / arXiv reference in a
2//! bibliography file resolves to real metadata, WITHOUT downloading any
3//! PDF or writing to the store.
4//!
5//! Each entry is classified:
6//!
7//! - **valid** — the id resolved to metadata (Crossref / arXiv).
8//! - **illegal** — the id is malformed (`Ref::parse` rejected it, e.g. a
9//!   typo like `1O.1234`), or the whole file failed to parse. Always
10//!   counts toward the exit code: a malformed id is a definite source
11//!   error, independent of the network.
12//! - **absent** — a well-formed id that the metadata source
13//!   authoritatively reports does not exist (HTTP 404 / 410, surfaced as
14//!   `ErrorCode::NotFound`). Network-independent and reproducible, so
15//!   it is a definite dead reference and **always** counts toward the
16//!   exit code — independent of `--strict`.
17//! - **unreachable** — a well-formed id whose resolution failed for any
18//!   transient reason (transport / DNS / TLS error, 429, 5xx, timeout).
19//!   This is tolerated by default (a flaky network must not fail a build
20//!   over a reference that is probably fine) and fails the run only under
21//!   `--strict` (the network-stable lane that demands every id resolve).
22//! - **unverifiable** — the entry carried no DOI / arXiv id at all.
23//!   Warning by default; fails under `--strict` / `on_missing_id="error"`.
24//!
25//! The split between **absent** and **unreachable** is the load-bearing
26//! distinction: it lets the default mode catch a genuinely dead DOI while
27//! still passing when the network merely hiccuped on a real id.
28//!
29//! Exit code = number of failing entries, capped at 255 (mirrors
30//! `doiget batch`). "Failing" = illegal + absent always; plus unreachable
31//! when `--strict`; plus unverifiable when `--strict` **or**
32//! `on_missing_id = "error"`. JSON-Lines (one record per entry) is written
33//! to stdout regardless of mode; the summary goes to stderr unless
34//! `--quiet`.
35
36use anyhow::{bail, Context, Result};
37use camino::Utf8Path;
38
39use doiget_core::orchestrator::resolve_only;
40use doiget_core::refs::{parse_input, Format, ParseError};
41use doiget_core::verify_config::{self, OnMissingId};
42use doiget_core::CapabilityProfile;
43use doiget_core::ErrorCode;
44
45use super::fetch::CliExit;
46use super::output::OutputMode;
47
48/// Resolve the `[verify]` config from `<config_dir>/doiget/config.toml`.
49/// Best-effort: a missing file is defaults; a malformed file degrades to
50/// defaults with a stderr warning rather than aborting the run.
51fn load_verify_config() -> verify_config::VerifyConfig {
52    let path = match crate::commands::fetch::config_dir_utf8() {
53        Ok(dir) => dir.join("doiget").join("config.toml"),
54        Err(_) => return verify_config::VerifyConfig::default(),
55    };
56    match verify_config::load(&path) {
57        Ok(cfg) => cfg,
58        Err(e) => {
59            #[allow(clippy::print_stderr)]
60            {
61                eprintln!("warning: ignoring [verify] config: {e}");
62            }
63            verify_config::VerifyConfig::default()
64        }
65    }
66}
67
68/// Map the `--format` flag token to a [`Format`].
69fn parse_format(s: &str) -> Result<Format> {
70    match s {
71        "auto" => Ok(Format::Auto),
72        "refs" => Ok(Format::Refs),
73        "csl-json" => Ok(Format::CslJson),
74        "bibtex" => Ok(Format::Bibtex),
75        other => bail!("unknown --format {other:?} (expected auto|refs|csl-json|bibtex)"),
76    }
77}
78
79/// Outcome class for one bibliography entry.
80///
81/// Single source of truth for the JSON-Lines `status` string
82/// ([`Self::as_wire`]) and the exit-code policy ([`Self::is_failing`]),
83/// so the wire format and the fail rule cannot drift apart as the
84/// taxonomy evolves.
85#[derive(Clone, Copy, PartialEq, Eq)]
86enum VerifyStatus {
87    /// Resolved to real metadata.
88    Valid,
89    /// Malformed id / unparsable input — a definite source error.
90    Illegal,
91    /// Authoritatively does not exist (`ErrorCode::NotFound`).
92    Absent,
93    /// Well-formed id, transient resolution failure.
94    Unreachable,
95    /// Entry carried no DOI / arXiv id.
96    Unverifiable,
97}
98
99impl VerifyStatus {
100    /// Every status, in summary-display order.
101    const ALL: [VerifyStatus; 5] = [
102        VerifyStatus::Valid,
103        VerifyStatus::Illegal,
104        VerifyStatus::Absent,
105        VerifyStatus::Unreachable,
106        VerifyStatus::Unverifiable,
107    ];
108
109    /// The public JSON-Lines `status` field value.
110    fn as_wire(self) -> &'static str {
111        match self {
112            VerifyStatus::Valid => "valid",
113            VerifyStatus::Illegal => "illegal",
114            VerifyStatus::Absent => "absent",
115            VerifyStatus::Unreachable => "unreachable",
116            VerifyStatus::Unverifiable => "unverifiable",
117        }
118    }
119
120    /// Stable index into a per-status counts array.
121    fn index(self) -> usize {
122        match self {
123            VerifyStatus::Valid => 0,
124            VerifyStatus::Illegal => 1,
125            VerifyStatus::Absent => 2,
126            VerifyStatus::Unreachable => 3,
127            VerifyStatus::Unverifiable => 4,
128        }
129    }
130
131    /// Does this outcome count toward the non-zero exit code?
132    ///
133    /// `illegal` + `absent` are definite, network-independent source
134    /// errors → always fail. `unreachable` is transient → fails only in
135    /// the network-stable `--strict` lane. `unverifiable` (no id) fails
136    /// only when the id-less policy is `Error` (which `--strict` forces).
137    fn is_failing(self, strict: bool, on_missing: OnMissingId) -> bool {
138        match self {
139            VerifyStatus::Valid => false,
140            VerifyStatus::Illegal | VerifyStatus::Absent => true,
141            VerifyStatus::Unreachable => strict,
142            VerifyStatus::Unverifiable => on_missing == OnMissingId::Error,
143        }
144    }
145}
146
147/// Entry point for `doiget verify <path> [--format] [--strict]`.
148pub async fn run(path: String, format: String, cli_strict: bool, mode: OutputMode) -> Result<()> {
149    let fmt = parse_format(&format)?;
150    // #477: the misuse form, not a raw `anyhow` dump. `docs/ERRORS.md` §4
151    // classes an unusable argument as misuse (exit 2), and the closed
152    // `ErrorCode` set has no member for "your input file is missing" -- it
153    // describes fetch outcomes. Inventing one would be a wire change to a
154    // NORMATIVE closed set and deserves its own decision, so this uses the
155    // bare `error:` misuse shape the CLI already uses elsewhere (e.g.
156    // `config --network` applied to the wrong action).
157    let text = match std::fs::read_to_string(&path) {
158        Ok(t) => t,
159        Err(e) => {
160            super::output::print_err(format_args!(
161                "error: failed to read reference file {path}: {e}"
162            ));
163            return Err(anyhow::Error::new(super::fetch::CliExit(2)));
164        }
165    };
166    let entries = parse_input(&text, fmt, Some(Utf8Path::new(&path)));
167
168    // Resolve effective policy: CLI `--strict` is the strictest setting,
169    // forcing both unreachable and id-less entries to fail and overriding
170    // the `[verify]` config.
171    let config = load_verify_config();
172    let strict = cli_strict || config.strict;
173    let on_missing = if cli_strict {
174        // CLI --strict is the strictest setting: id-less entries fail.
175        OnMissingId::Error
176    } else if strict {
177        // strict came from `[verify] strict = true`: unreachable ids fail.
178        // Do not let `skip` silently drop id-less entries in a strict run —
179        // surface them at least as a warning so the summary is honest.
180        match config.on_missing_id {
181            OnMissingId::Skip => OnMissingId::Warn,
182            other => other,
183        }
184    } else {
185        config.on_missing_id
186    };
187
188    let ctx = crate::commands::fetch::build_resolve_context()?;
189    let profile = CapabilityProfile::from_env().context("resolving capability profile")?;
190
191    // One counter per VerifyStatus, indexed by `VerifyStatus::index`.
192    let mut counts = [0u32; VerifyStatus::ALL.len()];
193
194    for entry in entries {
195        // `on_missing_id = "skip"` drops id-less entries entirely —
196        // before they are counted or emitted.
197        if matches!(&entry, Err(ParseError::NoIdentifier { .. })) && on_missing == OnMissingId::Skip
198        {
199            continue;
200        }
201        let (status, record) = match entry {
202            Ok(parsed) => {
203                let ref_ = parsed.ref_;
204                let entry_key = parsed.entry_key;
205                match resolve_only(&ref_, &profile, &ctx).await {
206                    Ok(_) => (
207                        VerifyStatus::Valid,
208                        serde_json::json!({
209                            "ok": true,
210                            "ref": ref_.as_input_str(),
211                            "status": VerifyStatus::Valid.as_wire(),
212                            "entry_key": entry_key,
213                        }),
214                    ),
215                    Err(e) => {
216                        let code: doiget_core::ErrorCode = (&e).into();
217                        // A provenance-log write failure is fail-closed
218                        // (docs/SECURITY.md §1.8): it is an operator-side
219                        // fault, NOT a "this reference doesn't resolve"
220                        // signal, so it must abort the run rather than be
221                        // counted as a soft outcome that CI passes.
222                        if code == doiget_core::ErrorCode::LogError {
223                            return Err(anyhow::anyhow!(
224                                "provenance log error during verify (aborting): {e}"
225                            ));
226                        }
227                        // An InternalError is a bug, not a property of the
228                        // reference; aborting (rather than silently bucketing
229                        // it as a tolerable `unreachable`) surfaces it.
230                        if code == doiget_core::ErrorCode::InternalError {
231                            return Err(anyhow::anyhow!(
232                                "internal error during verify (aborting; please report): {e}"
233                            ));
234                        }
235                        // A NotFound (HTTP 404/410/451 or a source-specific
236                        // absence) is an authoritative dead reference. Every
237                        // other resolve error is transient (unreachable). See
238                        // the module-level taxonomy.
239                        let status = if code == doiget_core::ErrorCode::NotFound {
240                            VerifyStatus::Absent
241                        } else {
242                            VerifyStatus::Unreachable
243                        };
244                        (
245                            status,
246                            serde_json::json!({
247                                "ok": false,
248                                "ref": ref_.as_input_str(),
249                                "status": status.as_wire(),
250                                "entry_key": entry_key,
251                                "error": { "code": code.as_wire(), "message": e.to_string() },
252                            }),
253                        )
254                    }
255                }
256            }
257            Err(ParseError::InvalidRef {
258                raw,
259                entry_key,
260                source,
261            }) => (
262                VerifyStatus::Illegal,
263                serde_json::json!({
264                    "ok": false,
265                    "ref": raw,
266                    "status": VerifyStatus::Illegal.as_wire(),
267                    "entry_key": entry_key,
268                    "error": { "code": ErrorCode::InvalidRef.as_wire(), "message": source.to_string() },
269                }),
270            ),
271            // #500: still unverifiable, but for a reason the reader can act on
272            // -- and the action is not "fix the bibliography".
273            Err(ParseError::UnsupportedIdentifier {
274                kind,
275                value,
276                entry_key,
277            }) => (
278                VerifyStatus::Unverifiable,
279                serde_json::json!({
280                    "ok": false,
281                    "ref": serde_json::Value::Null,
282                    "status": VerifyStatus::Unverifiable.as_wire(),
283                    "entry_key": entry_key,
284                    "error": {
285                        "code": ErrorCode::NotImplemented.as_wire(),
286                        "message": doiget_core::refs::unsupported_identifier_claim(
287                            kind, &value,
288                        ),
289                    },
290                }),
291            ),
292            Err(ParseError::NoIdentifier { entry_key }) => (
293                VerifyStatus::Unverifiable,
294                serde_json::json!({
295                    "ok": false,
296                    "ref": serde_json::Value::Null,
297                    "status": VerifyStatus::Unverifiable.as_wire(),
298                    "entry_key": entry_key,
299                    "error": { "code": ErrorCode::InvalidRef.as_wire(), "message": "entry has no DOI / arXiv id" },
300                }),
301            ),
302            Err(ParseError::Decode { format, message }) => (
303                VerifyStatus::Illegal,
304                serde_json::json!({
305                    "ok": false,
306                    "status": VerifyStatus::Illegal.as_wire(),
307                    "error": {
308                        "code": ErrorCode::InvalidRef.as_wire(),
309                        "message": format!("input did not parse as {format}: {message}"),
310                    },
311                }),
312            ),
313            Err(ParseError::UnsupportedFormat { format }) => {
314                bail!("{format} parsing is not supported for verification");
315            }
316            Err(_) => {
317                // `ParseError` is #[non_exhaustive]; a future variant is
318                // treated as a whole-input failure the operator must fix.
319                bail!("reference file could not be parsed");
320            }
321        };
322        counts[status.index()] += 1;
323        #[allow(clippy::print_stdout)]
324        {
325            println!("{record}");
326        }
327    }
328
329    let total = counts.iter().copied().fold(0u32, u32::saturating_add);
330    if mode != OutputMode::Quiet {
331        #[allow(clippy::print_stderr)]
332        {
333            eprintln!(
334                "verify: {total} entries — {} valid, {} illegal, {} absent, \
335                 {} unreachable, {} unverifiable{}",
336                counts[VerifyStatus::Valid.index()],
337                counts[VerifyStatus::Illegal.index()],
338                counts[VerifyStatus::Absent.index()],
339                counts[VerifyStatus::Unreachable.index()],
340                counts[VerifyStatus::Unverifiable.index()],
341                if strict { " (strict)" } else { "" }
342            );
343        }
344    }
345
346    // Sum the counts of every status whose policy marks it failing.
347    let failing = VerifyStatus::ALL
348        .iter()
349        .filter(|s| s.is_failing(strict, on_missing))
350        .map(|s| counts[s.index()])
351        .fold(0u32, u32::saturating_add);
352    if failing == 0 {
353        Ok(())
354    } else {
355        Err(anyhow::Error::new(CliExit(failing.min(255) as i32)))
356    }
357}