Skip to main content

doiget_core/
verify_config.rs

1//! `[verify]` section of the user `config.toml`, read by `doiget verify`.
2//!
3//! Mirrors [`crate::user_extension`]: this is a minimal, section-scoped
4//! reader (NOT the full `docs/CONFIG.md` resolution ladder). It parses
5//! only the `[verify]` table, tolerates every other section, and treats
6//! a missing file as "all defaults" rather than an error. CLI flags
7//! (`--strict`) layer on top in the command handler; this module knows
8//! nothing about flags or env vars.
9//!
10//! ```toml
11//! [verify]
12//! # How to treat an entry that carries no DOI / arXiv id:
13//! #   "warn"  — report it, do not fail the run (default)
14//! #   "error" — count it as a failure (exit non-zero)
15//! #   "skip"  — do not emit a record for it at all
16//! on_missing_id = "warn"
17//! # Treat a well-formed id that does not resolve as a failure.
18//! strict = false
19//! ```
20
21use camino::Utf8Path;
22use serde::Deserialize;
23use thiserror::Error;
24
25/// Policy for an entry with no DOI / arXiv identifier.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum OnMissingId {
29    /// Report the entry but do not fail the run.
30    #[default]
31    Warn,
32    /// Count the entry as a failure (non-zero exit).
33    Error,
34    /// Do not emit a record for the entry at all.
35    Skip,
36}
37
38/// Resolved `[verify]` configuration.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40#[non_exhaustive]
41pub struct VerifyConfig {
42    /// Treat a well-formed-but-non-resolving id as a failure.
43    pub strict: bool,
44    /// Policy for id-less entries.
45    pub on_missing_id: OnMissingId,
46}
47
48/// Why loading the `[verify]` section failed.
49#[derive(Debug, Error)]
50#[non_exhaustive]
51pub enum VerifyConfigError {
52    /// Filesystem error other than not-found (which is not an error).
53    #[error("reading verify config {path}: {source}")]
54    Io {
55        /// The config path that could not be read.
56        path: String,
57        /// The underlying I/O error.
58        #[source]
59        source: std::io::Error,
60    },
61    /// The file is not valid TOML, or `[verify]` has an unknown key /
62    /// an invalid `on_missing_id` value.
63    #[error("parsing verify config {path}: {message}")]
64    Parse {
65        /// The config path that failed to parse.
66        path: String,
67        /// `toml::de::Error::to_string()`.
68        message: String,
69    },
70}
71
72/// Top-level shape; only `[verify]` is load-bearing. Other sections
73/// (e.g. `[network]`) are tolerated so this reader can run against the
74/// same `config.toml` the user-extension loader reads.
75#[derive(Debug, Default, Deserialize)]
76struct RawConfig {
77    #[serde(default)]
78    verify: Option<RawVerify>,
79    #[serde(flatten)]
80    _other: serde::de::IgnoredAny,
81}
82
83/// The `[verify]` table. `deny_unknown_fields` so a typo
84/// (`on_missing_ids = …`) fails loudly rather than silently defaulting.
85#[derive(Debug, Default, Deserialize)]
86#[serde(deny_unknown_fields)]
87struct RawVerify {
88    #[serde(default)]
89    strict: bool,
90    #[serde(default)]
91    on_missing_id: OnMissingId,
92}
93
94/// Load the `[verify]` section from `config_path`.
95///
96/// A missing file yields [`VerifyConfig::default`] (not an error), so a
97/// user who never wrote a `config.toml` gets the lenient defaults. A
98/// present-but-malformed file is an error the caller surfaces (verify
99/// degrades to defaults with a warning rather than aborting).
100pub fn load(config_path: &Utf8Path) -> Result<VerifyConfig, VerifyConfigError> {
101    let text = match std::fs::read_to_string(config_path) {
102        Ok(t) => t,
103        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
104            return Ok(VerifyConfig::default());
105        }
106        Err(e) => {
107            return Err(VerifyConfigError::Io {
108                path: config_path.to_string(),
109                source: e,
110            });
111        }
112    };
113    let raw: RawConfig = toml::from_str(&text).map_err(|e| VerifyConfigError::Parse {
114        path: config_path.to_string(),
115        message: e.to_string(),
116    })?;
117    Ok(match raw.verify {
118        Some(v) => VerifyConfig {
119            strict: v.strict,
120            on_missing_id: v.on_missing_id,
121        },
122        None => VerifyConfig::default(),
123    })
124}
125
126#[cfg(test)]
127#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
128mod tests {
129    use super::*;
130    use camino::Utf8PathBuf;
131
132    fn write(dir: &tempfile::TempDir, body: &str) -> Utf8PathBuf {
133        let p = Utf8PathBuf::try_from(dir.path().join("config.toml")).expect("utf-8");
134        std::fs::write(&p, body).expect("write config");
135        p
136    }
137
138    #[test]
139    fn missing_file_is_defaults() {
140        let cfg = load(Utf8Path::new("/no/such/config.toml")).expect("missing is ok");
141        assert_eq!(cfg, VerifyConfig::default());
142        assert_eq!(cfg.on_missing_id, OnMissingId::Warn);
143        assert!(!cfg.strict);
144    }
145
146    #[test]
147    fn empty_or_unrelated_sections_are_defaults() {
148        let dir = tempfile::TempDir::new().unwrap();
149        let p = write(&dir, "[network]\nadditional_hosts = []\n");
150        let cfg = load(&p).expect("parses");
151        assert_eq!(cfg, VerifyConfig::default());
152    }
153
154    #[test]
155    fn reads_on_missing_id_and_strict() {
156        let dir = tempfile::TempDir::new().unwrap();
157        let p = write(&dir, "[verify]\non_missing_id = \"error\"\nstrict = true\n");
158        let cfg = load(&p).expect("parses");
159        assert_eq!(cfg.on_missing_id, OnMissingId::Error);
160        assert!(cfg.strict);
161    }
162
163    #[test]
164    fn skip_value_parses() {
165        let dir = tempfile::TempDir::new().unwrap();
166        let p = write(&dir, "[verify]\non_missing_id = \"skip\"\n");
167        let cfg = load(&p).expect("parses");
168        assert_eq!(cfg.on_missing_id, OnMissingId::Skip);
169    }
170
171    #[test]
172    fn unknown_key_in_verify_is_an_error() {
173        let dir = tempfile::TempDir::new().unwrap();
174        let p = write(&dir, "[verify]\non_missing_ids = \"warn\"\n");
175        assert!(matches!(load(&p), Err(VerifyConfigError::Parse { .. })));
176    }
177
178    #[test]
179    fn invalid_on_missing_id_value_is_an_error() {
180        let dir = tempfile::TempDir::new().unwrap();
181        let p = write(&dir, "[verify]\non_missing_id = \"sometimes\"\n");
182        assert!(matches!(load(&p), Err(VerifyConfigError::Parse { .. })));
183    }
184}