scitadel_export/
diff_input.rs1use std::path::Path;
14
15use crate::diff::Entry;
16use crate::import::parse::parse_bibtex;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum BibFormat {
23 Bibtex,
24 CslJson,
25}
26
27pub 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
36pub 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#[must_use]
65pub fn detect_format_from_str(src: &str) -> BibFormat {
66 if src.trim().is_empty() {
67 return BibFormat::Bibtex;
68 }
69 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 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 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 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 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 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}