Skip to main content

scitadel_export/
diff_input.rs

1//! Loaders for `bib diff`: read a file path, sniff format, return
2//! format-neutral [`Entry`] records.
3//!
4//! Lives next to [`crate::diff`] so the diff CLI / MCP tool can call a
5//! single helper rather than re-implementing the BibTeX-or-JSON branch
6//! at every call site. Content-sniffing is the source of truth: a
7//! `.bib` file containing valid JSON is detected as JSON. Extension is
8//! only consulted when content-sniffing is ambiguous (which it never
9//! actually is — JSON either parses or it doesn't), so extension is
10//! effectively cosmetic in this helper. Kept here purely so callers
11//! can also accept content via [`load_entries_from_str`].
12
13use std::path::Path;
14
15use crate::diff::Entry;
16use crate::import::parse::parse_bibtex;
17
18/// Detected format of a bibliography file. Returned by
19/// [`detect_format_from_str`] so callers can branch (e.g. log a warn
20/// when extension and content disagree).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum BibFormat {
23    Bibtex,
24    CslJson,
25}
26
27/// Load a file and return its entries in the format-neutral shape.
28/// Errors are bubbled up verbatim with file-path context so the
29/// caller can present a useful error.
30pub fn load_entries_from_path(path: &Path) -> Result<(Vec<Entry>, BibFormat), String> {
31    let bytes =
32        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
33    load_entries_from_str(&bytes).map_err(|e| format!("parse {}: {e}", path.display()))
34}
35
36/// Same as [`load_entries_from_path`] but takes the bytes directly.
37/// The CLI uses this for the `--question-id` form (where the "before"
38/// side is a fresh DB snapshot, not a file).
39pub fn load_entries_from_str(src: &str) -> Result<(Vec<Entry>, BibFormat), String> {
40    match detect_format_from_str(src) {
41        BibFormat::CslJson => {
42            let v: serde_json::Value =
43                serde_json::from_str(src).map_err(|e| format!("not valid JSON: {e}"))?;
44            let arr = v
45                .as_array()
46                .ok_or_else(|| "CSL-JSON top-level must be an array".to_string())?;
47            let entries: Vec<Entry> = arr.iter().filter_map(Entry::from_csl_value).collect();
48            Ok((entries, BibFormat::CslJson))
49        }
50        BibFormat::Bibtex => {
51            let parsed = parse_bibtex(src).map_err(|e| format!("BibTeX parse failed: {e}"))?;
52            let entries: Vec<Entry> = parsed.iter().map(Entry::from_bib_entry).collect();
53            Ok((entries, BibFormat::Bibtex))
54        }
55    }
56}
57
58/// Sniff format. Strategy: try parsing as JSON first (fast: `serde_json`
59/// will reject within bytes if the leading non-whitespace is `@`). If
60/// JSON parse fails, fall back to BibTeX. Note that an empty BibTeX
61/// file (zero entries) is legal, and an empty string parses as neither
62/// JSON nor BibTeX cleanly; we treat empty-or-whitespace-only as
63/// BibTeX so an empty `.bib` round-trips correctly.
64#[must_use]
65pub fn detect_format_from_str(src: &str) -> BibFormat {
66    if src.trim().is_empty() {
67        return BibFormat::Bibtex;
68    }
69    // Quick eyeball: JSON docs start with `[` or `{` after whitespace.
70    // `@article{...}` (BibTeX) starts with `@`. Don't even bother
71    // calling serde_json if the first non-whitespace byte rules JSON
72    // out — saves allocation on large `.bib` files.
73    let first = src.trim_start().as_bytes().first().copied();
74    if matches!(first, Some(b'[' | b'{')) && serde_json::from_str::<serde_json::Value>(src).is_ok()
75    {
76        return BibFormat::CslJson;
77    }
78    BibFormat::Bibtex
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn detects_csl_json_array() {
87        let s = r#"[{"id":"a","type":"article-journal","title":"T"}]"#;
88        assert_eq!(detect_format_from_str(s), BibFormat::CslJson);
89    }
90
91    #[test]
92    fn detects_bibtex_canonical() {
93        let s = r"@article{a, title = {T}, year = {2024}}";
94        assert_eq!(detect_format_from_str(s), BibFormat::Bibtex);
95    }
96
97    #[test]
98    fn empty_input_defaults_to_bibtex() {
99        assert_eq!(detect_format_from_str(""), BibFormat::Bibtex);
100        assert_eq!(detect_format_from_str("   \n  "), BibFormat::Bibtex);
101    }
102
103    #[test]
104    fn malformed_json_falls_back_to_bibtex() {
105        // Looks JSON-ish but isn't — should fall through to bibtex parse.
106        let s = "[ this is not json {} ]";
107        assert_eq!(detect_format_from_str(s), BibFormat::Bibtex);
108    }
109
110    #[test]
111    fn content_sniff_overrides_extension_intent() {
112        // `.bib` extension by intent, but content is valid JSON.
113        // Caller should still treat as JSON.
114        let s = r#"[{"id":"x","type":"article-journal","title":"T"}]"#;
115        let (entries, fmt) = load_entries_from_str(s).unwrap();
116        assert_eq!(fmt, BibFormat::CslJson);
117        assert_eq!(entries.len(), 1);
118        assert_eq!(entries[0].citekey, "x");
119    }
120
121    #[test]
122    fn loads_bibtex_entries_into_neutral_shape() {
123        let s = r"
124@article{smith2024,
125  title = {T},
126  author = {Smith, J.},
127  year = {2024},
128  doi = {10.1/ABC}
129}";
130        let (entries, fmt) = load_entries_from_str(s).unwrap();
131        assert_eq!(fmt, BibFormat::Bibtex);
132        assert_eq!(entries.len(), 1);
133        assert_eq!(entries[0].citekey, "smith2024");
134        assert_eq!(entries[0].title.as_deref(), Some("T"));
135        // DOI normalized.
136        assert_eq!(entries[0].doi.as_deref(), Some("10.1/abc"));
137    }
138
139    #[test]
140    fn loads_csl_json_entries_into_neutral_shape() {
141        let s = r#"[
142            {"id":"smith2024","type":"article-journal","title":"T",
143             "author":[{"family":"Smith","given":"J."}],
144             "issued":{"date-parts":[[2024]]},
145             "DOI":"10.1/ABC"}
146        ]"#;
147        let (entries, fmt) = load_entries_from_str(s).unwrap();
148        assert_eq!(fmt, BibFormat::CslJson);
149        assert_eq!(entries.len(), 1);
150        assert_eq!(entries[0].citekey, "smith2024");
151        assert_eq!(entries[0].title.as_deref(), Some("T"));
152        // CSL DOI is also normalized via the lift helper.
153        assert_eq!(entries[0].doi.as_deref(), Some("10.1/abc"));
154        assert_eq!(entries[0].year, Some(2024));
155    }
156
157    #[test]
158    fn rejects_csl_top_level_object() {
159        // A single CSL entry as a bare object is not the canonical
160        // shape (must be an array). We reject explicitly.
161        let s = r#"{"id":"x","type":"article-journal","title":"T"}"#;
162        let err = load_entries_from_str(s).unwrap_err();
163        assert!(err.contains("must be an array"), "got: {err}");
164    }
165}