1use crate::reference::{Bibliography, Reference};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
28pub enum RefsInput {
29 Path(String),
34 Yaml(String),
36 Json(serde_json::Value),
38 Biblatex(String),
40}
41
42impl<'de> Deserialize<'de> for RefsInput {
43 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
44 where
45 D: serde::Deserializer<'de>,
46 {
47 let v = serde_json::Value::deserialize(deserializer)?;
49
50 if let Some(object) = v.as_object() {
51 let kind_str = object.get("kind").and_then(|k| k.as_str());
56 let has_value = object.contains_key("value");
57 match (kind_str, has_value) {
58 (Some(k), true) => {
59 if !matches!(k, "path" | "yaml" | "json" | "biblatex") {
60 return Err(serde::de::Error::unknown_variant(
61 k,
62 &["path", "yaml", "json", "biblatex"],
63 ));
64 }
65 }
67 _ => return Ok(RefsInput::Json(v)),
69 }
70 } else {
71 return Err(serde::de::Error::custom(
72 "refs input must be a tagged object or legacy refs object",
73 ));
74 }
75
76 let kind = v
78 .get("kind")
79 .and_then(|k| k.as_str())
80 .ok_or_else(|| serde::de::Error::custom("refs input must have a 'kind' field"))?;
81
82 let value = v
83 .get("value")
84 .ok_or_else(|| serde::de::Error::missing_field("value"))?;
85
86 match kind {
87 "path" | "yaml" | "biblatex" => {
88 let s = value
89 .as_str()
90 .ok_or_else(|| {
91 serde::de::Error::custom(
92 "'value' must be a string for path/yaml/biblatex refs",
93 )
94 })?
95 .to_string();
96 match kind {
97 "path" => Ok(RefsInput::Path(s)),
98 "yaml" => Ok(RefsInput::Yaml(s)),
99 _ => Ok(RefsInput::Biblatex(s)),
100 }
101 }
102 "json" => Ok(RefsInput::Json(value.clone())),
103 k => Err(serde::de::Error::unknown_variant(
104 k,
105 &["path", "yaml", "json", "biblatex"],
106 )),
107 }
108 }
109}
110
111impl Serialize for RefsInput {
112 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
113 where
114 S: serde::Serializer,
115 {
116 use serde::ser::SerializeMap;
117 let mut map = serializer.serialize_map(Some(2))?;
118 match self {
119 RefsInput::Path(s) => {
120 map.serialize_entry("kind", "path")?;
121 map.serialize_entry("value", s)?;
122 }
123 RefsInput::Yaml(s) => {
124 map.serialize_entry("kind", "yaml")?;
125 map.serialize_entry("value", s)?;
126 }
127 RefsInput::Json(v) => {
128 map.serialize_entry("kind", "json")?;
129 map.serialize_entry("value", v)?;
130 }
131 RefsInput::Biblatex(s) => {
132 map.serialize_entry("kind", "biblatex")?;
133 map.serialize_entry("value", s)?;
134 }
135 }
136 map.end()
137 }
138}
139
140#[cfg(feature = "schema")]
141impl schemars::JsonSchema for RefsInput {
142 fn schema_name() -> std::borrow::Cow<'static, str> {
143 "RefsInput".into()
144 }
145
146 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
147 let reference_schema = generator.subschema_for::<crate::reference::Reference>();
148
149 schemars::json_schema!({
150 "oneOf": [
151 {
152 "type": "object",
153 "required": ["kind", "value"],
154 "properties": {
155 "kind": {
156 "type": "string",
157 "enum": ["path", "yaml", "biblatex"]
158 },
159 "value": {
160 "type": "string"
161 }
162 },
163 "additionalProperties": false
164 },
165 {
166 "type": "object",
167 "required": ["kind", "value"],
168 "properties": {
169 "kind": {
170 "type": "string",
171 "const": "json"
172 },
173 "value": {
174 "type": "object",
175 "additionalProperties": reference_schema
176 }
177 },
178 "additionalProperties": false
179 },
180 {
181 "type": "object",
182 "additionalProperties": reference_schema
183 }
184 ]
185 })
186 }
187}
188
189impl RefsInput {
190 pub fn resolve_local(&self) -> Result<Bibliography, crate::api::FormatDocumentError> {
200 match self {
201 RefsInput::Path(path) => {
202 let p = std::path::Path::new(path);
203 if p.extension()
204 .is_some_and(|ext| ext.eq_ignore_ascii_case("bib"))
205 {
206 let input = citum_refs::formats::biblatex::load_biblatex(p).map_err(|e| {
207 crate::api::FormatDocumentError::RefsInputParse(format!(
208 "Failed to parse BibLaTeX refs from '{}': {}",
209 path, e
210 ))
211 })?;
212 return Ok(bibliography_from_references(input.references));
213 }
214 let bytes = std::fs::read(path).map_err(|e| {
215 crate::api::FormatDocumentError::RefsInputPath(format!(
216 "Failed to read refs input from '{}': {}",
217 path, e
218 ))
219 })?;
220 let yaml_str = String::from_utf8(bytes).map_err(|_| {
221 crate::api::FormatDocumentError::RefsInputParse(format!(
222 "Refs input file '{}' is not valid UTF-8",
223 path
224 ))
225 })?;
226 parse_yaml_bibliography(&yaml_str).map_err(|e| {
227 crate::api::FormatDocumentError::RefsInputParse(format!(
228 "Failed to parse refs input from '{}': {}",
229 path, e
230 ))
231 })
232 }
233 RefsInput::Yaml(yaml_str) => parse_yaml_bibliography(yaml_str).map_err(|e| {
234 crate::api::FormatDocumentError::RefsInputParse(format!(
235 "Failed to parse inline YAML refs input: {}",
236 e
237 ))
238 }),
239 RefsInput::Json(json_val) => serde_json::from_value::<Bibliography>(json_val.clone())
240 .map_err(|e| {
241 crate::api::FormatDocumentError::RefsInputParse(format!(
242 "Failed to parse JSON refs input: {}",
243 e
244 ))
245 }),
246 RefsInput::Biblatex(src) => {
247 let input =
248 citum_refs::formats::biblatex::parse_biblatex_str(src).map_err(|e| {
249 crate::api::FormatDocumentError::RefsInputParse(format!(
250 "Failed to parse inline BibLaTeX refs input: {}",
251 e
252 ))
253 })?;
254 Ok(bibliography_from_references(input.references))
255 }
256 }
257 }
258}
259
260fn parse_yaml_bibliography(yaml_str: &str) -> Result<Bibliography, String> {
261 let native_err = match serde_yaml::from_str::<citum_schema::InputBibliography>(yaml_str) {
262 Ok(input) => return Ok(bibliography_from_references(input.references)),
263 Err(e) => e,
264 };
265
266 if let Ok(bibliography) = serde_yaml::from_str::<Bibliography>(yaml_str) {
267 return Ok(bibliography);
268 }
269
270 if let Ok(references) = serde_yaml::from_str::<Vec<Reference>>(yaml_str) {
271 return Ok(bibliography_from_references(references));
272 }
273
274 Err(format!(
275 "tried native `references:` bibliography, flat id-to-reference map, and reference sequence: {native_err}"
276 ))
277}
278
279fn bibliography_from_references(references: Vec<Reference>) -> Bibliography {
280 references
281 .into_iter()
282 .filter_map(|reference| {
283 let id = reference.id()?.to_string();
284 Some((id, reference))
285 })
286 .collect()
287}
288
289#[cfg(test)]
290#[allow(
291 clippy::unwrap_used,
292 clippy::expect_used,
293 clippy::panic,
294 reason = "test code uses assertions and panic"
295)]
296mod tests {
297 use super::*;
298 use std::io::Write;
299 use tempfile::NamedTempFile;
300
301 #[test]
302 fn refs_input_yaml_resolves_locally() {
303 let yaml_content = "test_ref:\n id: test_ref\n class: monograph\n type: book\n title: Test\n issued: '2024'\n";
304 let input = RefsInput::Yaml(yaml_content.to_string());
305 let result = input.resolve_local();
306 assert!(result.is_ok());
307 assert!(result.unwrap().contains_key("test_ref"));
308 }
309
310 #[test]
311 fn refs_input_path_reads_native_input_bibliography() {
312 let mut tmp = NamedTempFile::new().expect("Failed to create temp file");
313 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";
314 tmp.write_all(yaml_content.as_bytes())
315 .expect("Failed to write temp file");
316 tmp.flush().expect("Failed to flush temp file");
317
318 let input = RefsInput::Path(tmp.path().to_string_lossy().to_string());
319 let result = input
320 .resolve_local()
321 .expect("native bibliography should parse");
322 assert!(result.contains_key("test_ref"));
323 }
324
325 #[test]
326 fn refs_input_yaml_reads_native_input_bibliography() {
327 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";
328 let input = RefsInput::Yaml(yaml_content.to_string());
329 let result = input
330 .resolve_local()
331 .expect("native bibliography should parse");
332 assert!(result.contains_key("test_ref"));
333 }
334
335 #[test]
336 fn refs_input_json_resolves_locally() {
337 let json_obj = serde_json::json!({
338 "test_ref": {
339 "id": "test_ref",
340 "class": "monograph",
341 "type": "book",
342 "title": "Test",
343 "issued": "2024"
344 }
345 });
346 let input = RefsInput::Json(json_obj);
347 let result = input.resolve_local();
348 assert!(result.is_ok());
349 assert!(result.unwrap().contains_key("test_ref"));
350 }
351
352 #[test]
353 fn refs_input_path_reads_and_parses() {
354 let mut tmp = NamedTempFile::new().expect("Failed to create temp file");
355 let yaml_content = "test_ref:\n id: test_ref\n class: monograph\n type: book\n title: Test\n issued: '2024'\n";
356 tmp.write_all(yaml_content.as_bytes())
357 .expect("Failed to write temp file");
358 tmp.flush().expect("Failed to flush temp file");
359
360 let input = RefsInput::Path(tmp.path().to_string_lossy().to_string());
361 let result = input.resolve_local();
362 assert!(result.is_ok());
363 assert!(result.unwrap().contains_key("test_ref"));
364 }
365
366 #[test]
367 fn refs_input_path_missing_returns_error() {
368 let input = RefsInput::Path("/nonexistent/path/refs.yaml".to_string());
369 let result = input.resolve_local();
370 match result {
371 Err(crate::api::FormatDocumentError::RefsInputPath(msg)) => {
372 assert!(msg.contains("Failed to read"));
373 }
374 _ => panic!("Expected RefsInputPath error"),
375 }
376 }
377
378 #[test]
379 fn refs_input_path_invalid_utf8_returns_parse_error() {
380 let mut tmp = tempfile::Builder::new()
381 .suffix(".yaml")
382 .tempfile()
383 .expect("Failed to create temp .yaml file");
384 tmp.write_all(&[0xff, 0xfe, 0x00, 0x01])
385 .expect("Failed to write temp file");
386 tmp.flush().expect("Failed to flush temp file");
387
388 let input = RefsInput::Path(tmp.path().to_string_lossy().to_string());
389 let result = input.resolve_local();
390 match result {
391 Err(crate::api::FormatDocumentError::RefsInputParse(msg)) => {
392 assert!(msg.contains("not valid UTF-8"));
393 }
394 _ => panic!("Expected RefsInputParse error"),
395 }
396 }
397
398 #[test]
399 fn refs_input_invalid_yaml_returns_parse_error() {
400 let input = RefsInput::Yaml("{ invalid yaml: [".to_string());
401 let result = input.resolve_local();
402 match result {
403 Err(crate::api::FormatDocumentError::RefsInputParse(msg)) => {
404 assert!(msg.contains("Failed to parse"));
405 }
406 _ => panic!("Expected RefsInputParse error"),
407 }
408 }
409
410 #[test]
411 fn refs_input_deserialize_tagged_path() {
412 let json_str = r#"{"kind":"path","value":"/tmp/bib.yaml"}"#;
413 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
414 match input {
415 RefsInput::Path(p) => assert_eq!(p, "/tmp/bib.yaml"),
416 _ => panic!("Expected Path variant"),
417 }
418 }
419
420 #[test]
421 fn refs_input_deserialize_tagged_json() {
422 let json_str = r#"{"kind":"json","value":{"key":"value"}}"#;
423 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
424 match input {
425 RefsInput::Json(v) => assert_eq!(v.get("key").unwrap(), "value"),
426 _ => panic!("Expected Json variant"),
427 }
428 }
429
430 #[test]
431 fn refs_input_deserialize_bare_object_as_json() {
432 let json_str = r#"{"test_ref":{"id":"test_ref","class":"monograph","type":"book","title":"Test","issued":"2024"}}"#;
433 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
434 match input {
435 RefsInput::Json(v) => assert!(v.get("test_ref").is_some()),
436 _ => panic!("Expected Json variant"),
437 }
438 }
439
440 #[test]
441 fn refs_input_deserialize_legacy_kind_ref_id_as_json() {
442 let json_str = r#"{"kind":{"id":"kind","class":"monograph","type":"book","title":"Kind","issued":"2024"}}"#;
443 let input: RefsInput = serde_json::from_str(json_str).expect("deserialize");
444 match input {
445 RefsInput::Json(v) => assert!(v.get("kind").is_some()),
446 _ => panic!("Expected Json variant"),
447 }
448 }
449
450 #[test]
451 fn refs_input_serialize_path() {
452 let input = RefsInput::Path("/tmp/bib.yaml".to_string());
453 let json_str = serde_json::to_string(&input).expect("serialize");
454 assert!(json_str.contains("\"kind\":\"path\""));
455 assert!(json_str.contains("\"/tmp/bib.yaml\""));
456 }
457
458 #[test]
459 fn refs_input_deserialize_tagged_biblatex() {
460 let bib_src = "@book{hawking1988, title = {A Brief History of Time}, author = {Hawking, Stephen}, date = {1988}}";
461 let json_str = format!(
462 r#"{{"kind":"biblatex","value":{}}}"#,
463 serde_json::to_string(bib_src).unwrap()
464 );
465 let input: RefsInput = serde_json::from_str(&json_str).expect("deserialize biblatex");
466 match input {
467 RefsInput::Biblatex(s) => assert!(s.contains("hawking1988")),
468 _ => panic!("Expected Biblatex variant"),
469 }
470 }
471
472 #[test]
473 fn refs_input_biblatex_resolves_locally() {
474 let bib_src = "@book{hawking1988, title = {A Brief History of Time}, author = {Hawking, Stephen}, date = {1988}}";
475 let input = RefsInput::Biblatex(bib_src.to_string());
476 let result = input.resolve_local().expect("biblatex should parse");
477 assert!(result.contains_key("hawking1988"));
478 }
479
480 #[test]
481 fn refs_input_path_bib_extension_parses_biblatex() {
482 let bib_content = "@article{doe2024, title = {Test Article}, author = {Doe, Jane}, journaltitle = {Journal of Tests}, date = {2024}}";
483 let mut tmp = tempfile::Builder::new()
484 .suffix(".bib")
485 .tempfile()
486 .expect("Failed to create temp .bib file");
487 tmp.write_all(bib_content.as_bytes())
488 .expect("Failed to write temp file");
489 tmp.flush().expect("Failed to flush temp file");
490
491 let input = RefsInput::Path(tmp.path().to_string_lossy().to_string());
492 let result = input.resolve_local().expect(".bib path should parse");
493 assert!(result.contains_key("doe2024"));
494 }
495
496 #[test]
497 fn refs_input_serialize_biblatex() {
498 let input = RefsInput::Biblatex("@book{key, title = {T}}".to_string());
499 let json_str = serde_json::to_string(&input).expect("serialize");
500 assert!(json_str.contains("\"kind\":\"biblatex\""));
501 assert!(json_str.contains("@book{key"));
502 }
503
504 #[test]
505 fn refs_input_deserialize_unknown_kind_returns_error() {
506 let json_str = r#"{"kind":"csl-json","value":"..."}"#;
507 let result = serde_json::from_str::<RefsInput>(json_str);
508 assert!(result.is_err());
509 let msg = result.unwrap_err().to_string();
510 assert!(
511 msg.contains("csl-json"),
512 "error should name the unknown variant: {msg}"
513 );
514 }
515}