Skip to main content

citum_engine/api/
refs_input.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Refs input resolution type for interactive APIs.
7
8use crate::reference::{Bibliography, Reference};
9use serde::{Deserialize, Serialize};
10
11/// A refs input that can be resolved locally or by an external resolver.
12///
13/// This union type allows callers to supply reference data by local file path,
14/// inline YAML, inline JSON, or inline BibLaTeX. Enables citum-server and
15/// bindings to accept references from files (e.g., via pipe transport from
16/// LaTeX or Emacs).
17///
18/// Supported tagged-object shapes over JSON/RPC:
19///
20/// ```json
21/// {"kind": "path",     "value": "/abs/path/refs.yaml"}
22/// {"kind": "path",     "value": "/abs/path/refs.bib"}  // .bib detected by extension
23/// {"kind": "yaml",     "value": "references:\n  - id: …"}
24/// {"kind": "json",     "value": {"id": { … }}}
25/// {"kind": "biblatex", "value": "@book{key, title={…}, …}"}
26/// ```
27#[derive(Debug, Clone)]
28pub enum RefsInput {
29    /// Local filesystem path to a refs file.
30    ///
31    /// `.bib` extensions are parsed as BibLaTeX; all other extensions are
32    /// parsed as native Citum YAML (JSON parses as a YAML subset).
33    Path(String),
34    /// Inline YAML refs string.
35    Yaml(String),
36    /// Inline JSON map of reference objects.
37    Json(serde_json::Value),
38    /// Inline BibLaTeX (`.bib`) content.
39    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        // Deserialize to a generic Value first so we can inspect the shape.
48        let v = serde_json::Value::deserialize(deserializer)?;
49
50        if let Some(object) = v.as_object() {
51            // If the object has a string "kind" and a "value" field it is a
52            // tagged-union wrapper — validate the kind rather than silently
53            // treating an unrecognised kind as a legacy bare-map, which would
54            // produce a confusing downstream parse error.
55            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                    // Recognised kind — fall through to dispatch below.
66                }
67                // No string kind, or no value field: legacy bare refs map.
68                _ => 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        // Tagged union: {"kind": "path"|"yaml"|"json"|"biblatex", "value": ...}
77        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    /// Resolve refs input locally from Path, Yaml, Json, or Biblatex variants.
191    ///
192    /// For `Path` inputs, `.bib` files are parsed as BibLaTeX; all other
193    /// extensions are parsed as native Citum YAML (JSON parses as a YAML
194    /// subset).
195    ///
196    /// # Errors
197    ///
198    /// Returns error for refs input filesystem or parse failures.
199    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}