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}