Skip to main content

moddef_core/
document.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Document layer (spec §4): parse and serialize the three equivalent
4//! encodings — `.moddef.yaml`, `.moddef.json`, `.moddef` (proto binary).
5//! YAML and JSON follow proto3 JSON (protojson) semantics via the
6//! pbjson-generated serde impls; unknown fields and invalid enum values are
7//! rejected, matching the Go implementation and the fixtures under
8//! moddef/fixtures/invalid. YAML goes through a YAML→JSON value conversion
9//! (numeric keys stringified) — the same round-trip the Go loader uses.
10
11use std::fmt;
12use std::path::Path;
13
14use prost::Message;
15
16use crate::schema::ModDefDocument;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DocumentFormat {
20    Yaml,
21    Json,
22    Binary,
23}
24
25/// Document-layer failure: unreadable file, malformed input, protojson
26/// violations (unknown field, bad enum value), or a bad import uri.
27#[derive(Debug)]
28pub struct ParseError(String);
29
30impl ParseError {
31    pub fn new(msg: impl Into<String>) -> Self {
32        ParseError(msg.into())
33    }
34}
35
36impl fmt::Display for ParseError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(&self.0)
39    }
40}
41
42impl std::error::Error for ParseError {}
43
44/// Infer the format from a file path per the spec §4 extensions.
45pub fn detect_format(path: &str) -> Result<DocumentFormat, ParseError> {
46    if path.ends_with(".moddef.yaml") || path.ends_with(".moddef.yml") {
47        Ok(DocumentFormat::Yaml)
48    } else if path.ends_with(".moddef.json") {
49        Ok(DocumentFormat::Json)
50    } else if path.ends_with(".moddef") {
51        Ok(DocumentFormat::Binary)
52    } else {
53        Err(ParseError::new(format!(
54            "cannot detect ModDef format from path: {path}"
55        )))
56    }
57}
58
59/// Parse a document from bytes in the given format.
60pub fn parse_document(data: &[u8], format: DocumentFormat) -> Result<ModDefDocument, ParseError> {
61    match format {
62        DocumentFormat::Binary => ModDefDocument::decode(data)
63            .map_err(|e| ParseError::new(format!("failed to parse ModDef binary document: {e}"))),
64        DocumentFormat::Json => serde_json::from_slice(data)
65            .map_err(|e| ParseError::new(format!("failed to parse ModDef json document: {e}"))),
66        DocumentFormat::Yaml => {
67            let y: serde_yaml::Value = serde_yaml::from_slice(data).map_err(|e| {
68                ParseError::new(format!("failed to parse ModDef yaml document: {e}"))
69            })?;
70            let j = yaml_to_json(y)?;
71            serde_json::from_value(j)
72                .map_err(|e| ParseError::new(format!("failed to parse ModDef yaml document: {e}")))
73        }
74    }
75}
76
77/// Serialize a document. The binary form is deterministic (BTreeMap-backed
78/// maps), so it byte-matches Go-produced `.moddef` files.
79pub fn serialize_document(
80    doc: &ModDefDocument,
81    format: DocumentFormat,
82) -> Result<Vec<u8>, ParseError> {
83    match format {
84        DocumentFormat::Binary => Ok(doc.encode_to_vec()),
85        DocumentFormat::Json => {
86            let mut out = serde_json::to_vec_pretty(doc)
87                .map_err(|e| ParseError::new(format!("failed to serialize json: {e}")))?;
88            out.push(b'\n');
89            Ok(out)
90        }
91        DocumentFormat::Yaml => {
92            let j = serde_json::to_value(doc)
93                .map_err(|e| ParseError::new(format!("failed to serialize yaml: {e}")))?;
94            serde_yaml::to_string(&j)
95                .map(String::into_bytes)
96                .map_err(|e| ParseError::new(format!("failed to serialize yaml: {e}")))
97        }
98    }
99}
100
101/// Load and parse a `.moddef.yaml` / `.moddef.json` / `.moddef` file.
102pub fn load(path: impl AsRef<Path>) -> Result<ModDefDocument, ParseError> {
103    let path = path.as_ref();
104    let format = detect_format(&path.to_string_lossy())?;
105    let data = std::fs::read(path)
106        .map_err(|e| ParseError::new(format!("failed to read {}: {e}", path.display())))?;
107    parse_document(&data, format)
108}
109
110/// Serialize and write a document; the format comes from the extension.
111pub fn save(doc: &ModDefDocument, path: impl AsRef<Path>) -> Result<(), ParseError> {
112    let path = path.as_ref();
113    let format = detect_format(&path.to_string_lossy())?;
114    let data = serialize_document(doc, format)?;
115    std::fs::write(path, data)
116        .map_err(|e| ParseError::new(format!("failed to write {}: {e}", path.display())))
117}
118
119/// YAML value → JSON value with protojson-compatible keys: YAML allows
120/// numeric/bool mapping keys, JSON (and protojson int64-keyed maps) want
121/// strings — stringify them, like the Go yaml→JSON conversion does.
122fn yaml_to_json(v: serde_yaml::Value) -> Result<serde_json::Value, ParseError> {
123    use serde_json::Value as J;
124    use serde_yaml::Value as Y;
125    Ok(match v {
126        Y::Null => J::Null,
127        Y::Bool(b) => J::Bool(b),
128        Y::Number(n) => {
129            if let Some(i) = n.as_i64() {
130                J::from(i)
131            } else if let Some(u) = n.as_u64() {
132                J::from(u)
133            } else if let Some(f) = n.as_f64() {
134                serde_json::Number::from_f64(f)
135                    .map(J::Number)
136                    .ok_or_else(|| ParseError::new("non-finite number in yaml document"))?
137            } else {
138                return Err(ParseError::new("unrepresentable number in yaml document"));
139            }
140        }
141        Y::String(s) => J::String(s),
142        Y::Sequence(seq) => J::Array(
143            seq.into_iter()
144                .map(yaml_to_json)
145                .collect::<Result<_, _>>()?,
146        ),
147        Y::Mapping(m) => {
148            let mut obj = serde_json::Map::with_capacity(m.len());
149            for (k, v) in m {
150                let key = match k {
151                    Y::String(s) => s,
152                    Y::Number(n) => n.to_string(),
153                    Y::Bool(b) => b.to_string(),
154                    other => {
155                        return Err(ParseError::new(format!(
156                            "unsupported yaml mapping key: {other:?}"
157                        )))
158                    }
159                };
160                obj.insert(key, yaml_to_json(v)?);
161            }
162            J::Object(obj)
163        }
164        Y::Tagged(t) => yaml_to_json(t.value)?,
165    })
166}