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}