Skip to main content

doiget_cli/commands/
lint.rs

1//! `doiget lint <path>` — structural validation of a BibTeX bibliography,
2//! independent of DOI resolution (`doiget verify`'s job).
3//!
4//! **Read-only**: lint never rewrites the file. It is also **math-aware** —
5//! inline `$...$` in a title is content, not a malformed field — so a
6//! hand-edited maths title (e.g. `$T\bar{T}$`) survives untouched and is
7//! never flagged.
8//!
9//! One JSON-Lines record per finding on stdout:
10//! `{"key","entry_type","rule","severity","message"}`. The summary goes to
11//! stderr unless `--quiet`.
12//!
13//! Rules and default severity:
14//!
15//! - `parse_error` (**error**) — the file is not parseable BibTeX. The
16//!   `biblatex` parser also rejects duplicate / blank citation keys at
17//!   this stage, so those surface as a `parse_error` (with a descriptive
18//!   message) rather than via a dedicated rule.
19//! - `missing_required_field` (**warning**) — an expected field for the
20//!   entry type is absent. Advisory: real-world `.bib` files are often
21//!   loose, so this is a comment, not a failure.
22//! - `empty_field` (**warning**) — a present field is blank / whitespace.
23//! - `title_math_hazard` (**warning**) — a `title` carries `$$` display
24//!   math, which some downstream renderers (e.g. DocumenterCitations)
25//!   cannot process; inline `$...$` is fine. Best-effort, advisory.
26//!
27//! Exit code = number of `error` findings (capped at 255). `--strict`
28//! promotes warnings so that ANY finding fails the run.
29
30use anyhow::{Context, Result};
31use biblatex::{Bibliography, ChunksExt, Entry, EntryType};
32
33use super::fetch::CliExit;
34use super::output::OutputMode;
35
36/// Finding severity. The label is intrinsic to the rule; `--strict` only
37/// changes whether warnings count toward the exit code (mirrors `verify`).
38#[derive(Clone, Copy, PartialEq, Eq)]
39enum Severity {
40    Error,
41    Warning,
42}
43
44impl Severity {
45    fn as_str(self) -> &'static str {
46        match self {
47            Severity::Error => "error",
48            Severity::Warning => "warning",
49        }
50    }
51}
52
53/// A required-field slot: presence of ANY listed field satisfies it. This
54/// absorbs BibTeX/BibLaTeX spelling variants (`journal` vs `journaltitle`,
55/// `year` vs `date`, `author` vs `editor`).
56type Req = &'static [&'static str];
57
58/// Required-field sets per entry type. Deliberately modest — the goal is
59/// to flag obviously-incomplete entries, not to enforce the full BibLaTeX
60/// data model. Unknown / other types require only a title.
61fn required_fields(t: &EntryType) -> Vec<Req> {
62    match t {
63        EntryType::Article => vec![
64            &["author"],
65            &["title"],
66            &["journal", "journaltitle"],
67            &["year", "date"],
68        ],
69        EntryType::Book | EntryType::MvBook => vec![
70            &["author", "editor"],
71            &["title"],
72            &["publisher"],
73            &["year", "date"],
74        ],
75        EntryType::InProceedings | EntryType::InCollection | EntryType::InBook => {
76            vec![&["author"], &["title"], &["booktitle"], &["year", "date"]]
77        }
78        EntryType::Proceedings | EntryType::MvProceedings => {
79            vec![&["title"], &["year", "date"]]
80        }
81        EntryType::PhdThesis | EntryType::MastersThesis | EntryType::Thesis => vec![
82            &["author"],
83            &["title"],
84            &["school", "institution"],
85            &["year", "date"],
86        ],
87        EntryType::TechReport | EntryType::Report => {
88            vec![&["author"], &["title"], &["institution"], &["year", "date"]]
89        }
90        _ => vec![&["title"]],
91    }
92}
93
94/// `true` when at least one of `names` is present on `entry` with a
95/// non-empty value.
96fn has_any(entry: &Entry, names: Req) -> bool {
97    names.iter().any(|n| {
98        entry
99            .fields
100            .get(*n)
101            .is_some_and(|c| !c.format_verbatim().trim().is_empty())
102    })
103}
104
105/// Canonical lowercase entry-type label for the JSON record (informational).
106fn entry_type_label(t: &EntryType) -> String {
107    format!("{t:?}").to_ascii_lowercase()
108}
109
110/// Entry point for `doiget lint <path> [--strict]`.
111pub fn run(path: String, strict: bool, mode: OutputMode) -> Result<()> {
112    let text = std::fs::read_to_string(&path)
113        .with_context(|| format!("failed to read bibliography file {path}"))?;
114
115    let mut errors = 0u32;
116    let mut warnings = 0u32;
117
118    // Tally + emit one JSON-Lines record. Severity is intrinsic to the
119    // rule; `strict` is applied only to the exit code below.
120    let mut emit = |key: &str, entry_type: &str, rule: &str, sev: Severity, message: String| {
121        match sev {
122            Severity::Error => errors += 1,
123            Severity::Warning => warnings += 1,
124        }
125        let record = serde_json::json!({
126            "key": key,
127            "entry_type": entry_type,
128            "rule": rule,
129            "severity": sev.as_str(),
130            "message": message,
131        });
132        #[allow(clippy::print_stdout)]
133        {
134            println!("{record}");
135        }
136    };
137
138    match Bibliography::parse(&text) {
139        Err(e) => {
140            emit(
141                "",
142                "",
143                "parse_error",
144                Severity::Error,
145                format!("file did not parse as BibTeX: {e}"),
146            );
147        }
148        Ok(bib) => {
149            for entry in bib.iter() {
150                let key = entry.key.clone();
151                let et = entry_type_label(&entry.entry_type);
152
153                for slot in required_fields(&entry.entry_type) {
154                    if !has_any(entry, slot) {
155                        emit(
156                            &key,
157                            &et,
158                            "missing_required_field",
159                            Severity::Warning,
160                            format!("missing expected field for `{et}`: {}", slot.join(" / ")),
161                        );
162                    }
163                }
164
165                for (name, chunks) in &entry.fields {
166                    if chunks.format_verbatim().trim().is_empty() {
167                        emit(
168                            &key,
169                            &et,
170                            "empty_field",
171                            Severity::Warning,
172                            format!("field `{name}` is present but empty"),
173                        );
174                    }
175                }
176
177                // Math-aware title hazard: inline `$...$` is fine, but `$$`
178                // display math breaks some renderers (DocumenterCitations).
179                // Best-effort: inspect the re-serialised title.
180                if let Some(chunks) = entry.fields.get("title") {
181                    let rendered = chunks.to_biblatex_string(false);
182                    let dollars = rendered.matches('$').count();
183                    if rendered.contains("$$") || dollars % 2 == 1 {
184                        emit(
185                            &key,
186                            &et,
187                            "title_math_hazard",
188                            Severity::Warning,
189                            "title math is not clean inline `$...$` (found `$$` or an unbalanced \
190                             `$`); some renderers (e.g. DocumenterCitations) cannot process it"
191                                .to_string(),
192                        );
193                    }
194                }
195            }
196        }
197    }
198
199    let total = errors + warnings;
200    if mode != OutputMode::Quiet {
201        #[allow(clippy::print_stderr)]
202        {
203            eprintln!(
204                "lint: {total} findings — {errors} error, {warnings} warning{}",
205                if strict {
206                    " (strict: warnings fail)"
207                } else {
208                    ""
209                }
210            );
211        }
212    }
213
214    let failing = errors + if strict { warnings } else { 0 };
215    if failing == 0 {
216        Ok(())
217    } else {
218        Err(anyhow::Error::new(CliExit(failing.min(255) as i32)))
219    }
220}