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 YAML file path,
14/// inline YAML, or inline JSON (current API). Enables citum-server and bindings to
15/// accept references from files (e.g., via pipe transport from LaTeX).
16#[derive(Debug, Clone)]
17pub enum RefsInput {
18    /// Local filesystem path to a YAML refs file.
19    Path(String),
20    /// Inline YAML refs string.
21    Yaml(String),
22    /// Inline JSON map of reference objects.
23    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        // Deserialize to a generic Value first so we can inspect the shape.
32        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        // Tagged union: {"kind": "path"|"yaml"|"json", "value": ...}
49        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    /// Resolve refs input locally from Path, Yaml, or Json variants.
157    ///
158    /// # Errors
159    ///
160    /// Returns error for refs input filesystem or parse failures.
161    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}