1use crate::reference::{Bibliography, Reference};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
17pub enum RefsInput {
18 Path(String),
20 Yaml(String),
22 Json(serde_json::Value),
24}
25
26impl<'de> Deserialize<'de> for RefsInput {
27 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
28 where
29 D: serde::Deserializer<'de>,
30 {
31 let v = serde_json::Value::deserialize(deserializer)?;
33
34 if let Some(object) = v.as_object() {
35 let tagged_kind = object
36 .get("kind")
37 .and_then(|k| k.as_str())
38 .filter(|k| matches!(*k, "path" | "yaml" | "json"));
39 if tagged_kind.is_none() || object.get("value").is_none() {
40 return Ok(RefsInput::Json(v));
41 }
42 } else {
43 return Err(serde::de::Error::custom(
44 "refs input must be a tagged object or legacy refs object",
45 ));
46 }
47
48 let kind = v
50 .get("kind")
51 .and_then(|k| k.as_str())
52 .ok_or_else(|| serde::de::Error::custom("refs input must have a 'kind' field"))?;
53
54 let value = v
55 .get("value")
56 .ok_or_else(|| serde::de::Error::missing_field("value"))?;
57
58 match kind {
59 "path" | "yaml" => {
60 let s = value
61 .as_str()
62 .ok_or_else(|| {
63 serde::de::Error::custom("'value' must be a string for path/yaml refs")
64 })?
65 .to_string();
66 if kind == "path" {
67 Ok(RefsInput::Path(s))
68 } else {
69 Ok(RefsInput::Yaml(s))
70 }
71 }
72 "json" => Ok(RefsInput::Json(value.clone())),
73 k => Err(serde::de::Error::unknown_variant(
74 k,
75 &["path", "yaml", "json"],
76 )),
77 }
78 }
79}
80
81impl Serialize for RefsInput {
82 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83 where
84 S: serde::Serializer,
85 {
86 use serde::ser::SerializeMap;
87 let mut map = serializer.serialize_map(Some(2))?;
88 match self {
89 RefsInput::Path(s) => {
90 map.serialize_entry("kind", "path")?;
91 map.serialize_entry("value", s)?;
92 }
93 RefsInput::Yaml(s) => {
94 map.serialize_entry("kind", "yaml")?;
95 map.serialize_entry("value", s)?;
96 }
97 RefsInput::Json(v) => {
98 map.serialize_entry("kind", "json")?;
99 map.serialize_entry("value", v)?;
100 }
101 }
102 map.end()
103 }
104}
105
106#[cfg(feature = "schema")]
107impl schemars::JsonSchema for RefsInput {
108 fn schema_name() -> std::borrow::Cow<'static, str> {
109 "RefsInput".into()
110 }
111
112 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
113 let reference_schema = generator.subschema_for::<crate::reference::Reference>();
114
115 schemars::json_schema!({
116 "oneOf": [
117 {
118 "type": "object",
119 "required": ["kind", "value"],
120 "properties": {
121 "kind": {
122 "type": "string",
123 "enum": ["path", "yaml"]
124 },
125 "value": {
126 "type": "string"
127 }
128 },
129 "additionalProperties": false
130 },
131 {
132 "type": "object",
133 "required": ["kind", "value"],
134 "properties": {
135 "kind": {
136 "type": "string",
137 "const": "json"
138 },
139 "value": {
140 "type": "object",
141 "additionalProperties": reference_schema
142 }
143 },
144 "additionalProperties": false
145 },
146 {
147 "type": "object",
148 "additionalProperties": reference_schema
149 }
150 ]
151 })
152 }
153}
154
155impl RefsInput {
156 pub fn resolve_local(&self) -> Result<Bibliography, crate::api::FormatDocumentError> {
162 match self {
163 RefsInput::Path(path) => {
164 let bytes = std::fs::read(path).map_err(|e| {
165 crate::api::FormatDocumentError::RefsInputPath(format!(
166 "Failed to read refs input from '{}': {}",
167 path, e
168 ))
169 })?;
170 let yaml_str = String::from_utf8_lossy(&bytes);
171 parse_yaml_bibliography(&yaml_str).map_err(|e| {
172 crate::api::FormatDocumentError::RefsInputParse(format!(
173 "Failed to parse refs input from '{}': {}",
174 path, e
175 ))
176 })
177 }
178 RefsInput::Yaml(yaml_str) => parse_yaml_bibliography(yaml_str).map_err(|e| {
179 crate::api::FormatDocumentError::RefsInputParse(format!(
180 "Failed to parse inline YAML refs input: {}",
181 e
182 ))
183 }),
184 RefsInput::Json(json_val) => serde_json::from_value::<Bibliography>(json_val.clone())
185 .map_err(|e| {
186 crate::api::FormatDocumentError::RefsInputParse(format!(
187 "Failed to parse JSON refs input: {}",
188 e
189 ))
190 }),
191 }
192 }
193}
194
195fn parse_yaml_bibliography(yaml_str: &str) -> Result<Bibliography, String> {
196 let native_err = match serde_yaml::from_str::<citum_schema::InputBibliography>(yaml_str) {
197 Ok(input) => return Ok(bibliography_from_references(input.references)),
198 Err(e) => e,
199 };
200
201 if let Ok(bibliography) = serde_yaml::from_str::<Bibliography>(yaml_str) {
202 return Ok(bibliography);
203 }
204
205 if let Ok(references) = serde_yaml::from_str::<Vec<Reference>>(yaml_str) {
206 return Ok(bibliography_from_references(references));
207 }
208
209 Err(format!(
210 "tried native `references:` bibliography, flat id-to-reference map, and reference sequence: {native_err}"
211 ))
212}
213
214fn bibliography_from_references(references: Vec<Reference>) -> Bibliography {
215 references
216 .into_iter()
217 .filter_map(|reference| {
218 let id = reference.id()?.to_string();
219 Some((id, reference))
220 })
221 .collect()
222}
223
224#[cfg(test)]
225#[allow(
226 clippy::unwrap_used,
227 clippy::expect_used,
228 clippy::panic,
229 reason = "test code uses assertions and panic"
230)]
231mod tests {
232 use super::*;
233 use std::io::Write;
234 use tempfile::NamedTempFile;
235
236 #[test]
237 fn refs_input_yaml_resolves_locally() {
238 let yaml_content = "test_ref:\n id: test_ref\n class: monograph\n type: book\n title: Test\n issued: '2024'\n";
239 let input = RefsInput::Yaml(yaml_content.to_string());
240 let result = input.resolve_local();
241 assert!(result.is_ok());
242 assert!(result.unwrap().contains_key("test_ref"));
243 }
244
245 #[test]
246 fn refs_input_path_reads_native_input_bibliography() {
247 let mut tmp = NamedTempFile::new().expect("Failed to create temp file");
248 let yaml_content = "info:\n title: Test Bibliography\nreferences:\n - id: test_ref\n class: monograph\n type: book\n title: Test\n issued: '2024'\n";
249 tmp.write_all(yaml_content.as_bytes())
250 .expect("Failed to write temp file");
251 tmp.flush().expect("Failed to flush temp file");
252
253 let input = RefsInput::Path(tmp.path().to_string_lossy().to_string());
254 let result = input
255 .resolve_local()
256 .expect("native bibliography should parse");
257 assert!(result.contains_key("test_ref"));
258 }
259
260 #[test]
261 fn refs_input_yaml_reads_native_input_bibliography() {
262 let yaml_content = "info:\n title: Test Bibliography\nreferences:\n - id: test_ref\n class: monograph\n type: book\n title: Test\n issued: '2024'\n";
263 let input = RefsInput::Yaml(yaml_content.to_string());
264 let result = input
265 .resolve_local()
266 .expect("native bibliography should parse");
267 assert!(result.contains_key("test_ref"));
268 }
269
270 #[test]
271 fn refs_input_json_resolves_locally() {
272 let json_obj = serde_json::json!({
273 "test_ref": {
274 "id": "test_ref",
275 "class": "monograph",
276 "type": "book",
277 "title": "Test",
278 "issued": "2024"
279 }
280 });
281 let input = RefsInput::Json(json_obj);
282 let result = input.resolve_local();
283 assert!(result.is_ok());
284 assert!(result.unwrap().contains_key("test_ref"));
285 }
286
287 #[test]
288 fn refs_input_path_reads_and_parses() {
289 let mut tmp = NamedTempFile::new().expect("Failed to create temp file");
290 let yaml_content = "test_ref:\n id: test_ref\n class: monograph\n type: book\n title: Test\n issued: '2024'\n";
291 tmp.write_all(yaml_content.as_bytes())
292 .expect("Failed to write temp file");
293 tmp.flush().expect("Failed to flush temp file");
294
295 let input = RefsInput::Path(tmp.path().to_string_lossy().to_string());
296 let result = input.resolve_local();
297 assert!(result.is_ok());
298 assert!(result.unwrap().contains_key("test_ref"));
299 }
300
301 #[test]
302 fn refs_input_path_missing_returns_error() {
303 let input = RefsInput::Path("/nonexistent/path/refs.yaml".to_string());
304 let result = input.resolve_local();
305 match result {
306 Err(crate::api::FormatDocumentError::RefsInputPath(msg)) => {
307 assert!(msg.contains("Failed to read"));
308 }
309 _ => panic!("Expected RefsInputPath error"),
310 }
311 }
312
313 #[test]
314 fn refs_input_invalid_yaml_returns_parse_error() {
315 let input = RefsInput::Yaml("{ invalid yaml: [".to_string());
316 let result = input.resolve_local();
317 match result {
318 Err(crate::api::FormatDocumentError::RefsInputParse(msg)) => {
319 assert!(msg.contains("Failed to parse"));
320 }
321 _ => panic!("Expected RefsInputParse error"),
322 }
323 }
324
325 #[test]
326 fn refs_input_deserialize_tagged_path() {
327 let json_str = r#"{"kind":"path","value":"/tmp/bib.yaml"}"#;
328 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
329 match input {
330 RefsInput::Path(p) => assert_eq!(p, "/tmp/bib.yaml"),
331 _ => panic!("Expected Path variant"),
332 }
333 }
334
335 #[test]
336 fn refs_input_deserialize_tagged_json() {
337 let json_str = r#"{"kind":"json","value":{"key":"value"}}"#;
338 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
339 match input {
340 RefsInput::Json(v) => assert_eq!(v.get("key").unwrap(), "value"),
341 _ => panic!("Expected Json variant"),
342 }
343 }
344
345 #[test]
346 fn refs_input_deserialize_bare_object_as_json() {
347 let json_str = r#"{"test_ref":{"id":"test_ref","class":"monograph","type":"book","title":"Test","issued":"2024"}}"#;
348 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
349 match input {
350 RefsInput::Json(v) => assert!(v.get("test_ref").is_some()),
351 _ => panic!("Expected Json variant"),
352 }
353 }
354
355 #[test]
356 fn refs_input_deserialize_legacy_kind_ref_id_as_json() {
357 let json_str = r#"{"kind":{"id":"kind","class":"monograph","type":"book","title":"Kind","issued":"2024"}}"#;
358 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
359 match input {
360 RefsInput::Json(v) => assert!(v.get("kind").is_some()),
361 _ => panic!("Expected Json variant"),
362 }
363 }
364
365 #[test]
366 fn refs_input_serialize_path() {
367 let input = RefsInput::Path("/tmp/bib.yaml".to_string());
368 let json_str = serde_json::to_string(&input).expect("serialize");
369 assert!(json_str.contains("\"kind\":\"path\""));
370 assert!(json_str.contains("\"/tmp/bib.yaml\""));
371 }
372}