openbim_ifcx_binding_core/
document.rs

1//! One IFCX file, read and written losslessly.
2
3use openbim_ifcx::IfcxFile;
4
5use crate::error::BindingError;
6use crate::report;
7
8/// One IFCX file held by a host.
9///
10/// The host keeps this handle rather than a plain object, so writing it back
11/// is lossless: key order, unknown fields, `null` deletions and every number
12/// stay exactly as read, which a JavaScript object would not guarantee.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Document {
15    file: IfcxFile,
16}
17
18impl Document {
19    /// Reads an IFCX file from its UTF-8 JSON bytes.
20    pub fn parse(bytes: &[u8]) -> Result<Self, BindingError> {
21        IfcxFile::from_json_slice(bytes)
22            .map(|file| Self { file })
23            .map_err(|e| BindingError::Read(e.to_string()))
24    }
25
26    /// The file as JSON text: compact, or indented by two spaces.
27    pub fn write(&self, pretty: bool) -> Result<String, BindingError> {
28        let written = if pretty {
29            self.file.to_json_string_pretty()
30        } else {
31            self.file.to_json_string()
32        };
33        written.map_err(|e| BindingError::Write(e.to_string()))
34    }
35
36    /// The `header` object as JSON text.
37    pub fn header_json(&self) -> Result<String, BindingError> {
38        serde_json::to_string(&self.file.header).map_err(|e| BindingError::Write(e.to_string()))
39    }
40
41    /// Number of entries in `data`. Several may share a path.
42    pub fn node_count(&self) -> usize {
43        self.file.data.len()
44    }
45
46    /// Checks every attribute against this file's own `schemas` and returns
47    /// the report as JSON text (see [`LayerSet::validate_json`] for the
48    /// shape). Imports are not resolved, so an attribute whose schema lives
49    /// only in an imported file is reported as `missing-schema`.
50    ///
51    /// [`LayerSet::validate_json`]: crate::LayerSet::validate_json
52    pub fn validate_json(&self) -> String {
53        report::to_json(&self.file.validate().err().unwrap_or_default())
54    }
55
56    /// The file this document holds.
57    pub fn file(&self) -> &IfcxFile {
58        &self.file
59    }
60
61    /// Takes the file out of the document.
62    pub fn into_file(self) -> IfcxFile {
63        self.file
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use serde_json::Value;
70
71    use super::Document;
72    use crate::fixtures::fixture;
73
74    #[test]
75    fn every_fixture_round_trips_through_write() {
76        for bytes in [
77            fixture!("minimal.ifcx"),
78            fixture!("wall-with-type.ifcx"),
79            fixture!("unknown-fields.ifcx"),
80            fixture!("geometry-model.ifcx"),
81        ] {
82            let document = Document::parse(bytes).unwrap();
83            let again = Document::parse(document.write(false).unwrap().as_bytes()).unwrap();
84            assert_eq!(again, document);
85            let pretty = Document::parse(document.write(true).unwrap().as_bytes()).unwrap();
86            assert_eq!(pretty, document);
87        }
88    }
89
90    #[test]
91    fn unknown_fields_and_exact_numbers_survive() {
92        let bytes = fixture!("unknown-fields.ifcx");
93        let written = Document::parse(bytes).unwrap().write(false).unwrap();
94        let original: Value = serde_json::from_slice(bytes).unwrap();
95        let written: Value = serde_json::from_str(&written).unwrap();
96        assert_eq!(written, original);
97    }
98
99    #[test]
100    fn header_is_plain_json() {
101        let document = Document::parse(fixture!("minimal.ifcx")).unwrap();
102        let header: Value = serde_json::from_str(&document.header_json().unwrap()).unwrap();
103        assert_eq!(header["ifcxVersion"], "ifcx_alpha");
104        assert_eq!(document.node_count(), 0);
105    }
106
107    #[test]
108    fn invalid_input_is_a_read_error_with_a_location() {
109        let error = Document::parse(b"{\n  \"header\": 1\n}").unwrap_err();
110        assert_eq!(error.code(), "read");
111        assert!(error.to_string().contains("line 2"), "{error}");
112        assert_eq!(Document::parse(b"not json").unwrap_err().code(), "read");
113        assert_eq!(Document::parse(&[0xff, 0xfe]).unwrap_err().code(), "read");
114    }
115
116    #[test]
117    fn validation_reports_every_failure_as_data() {
118        let valid = Document::parse(fixture!("valid-attributes.ifcx")).unwrap();
119        let report: Value = serde_json::from_str(&valid.validate_json()).unwrap();
120        assert_eq!(report["valid"], true);
121        assert_eq!(report["failures"].as_array().unwrap().len(), 0);
122
123        let invalid = Document::parse(fixture!("invalid-attributes.ifcx")).unwrap();
124        let report: Value = serde_json::from_str(&invalid.validate_json()).unwrap();
125        assert_eq!(report["valid"], false);
126        let failures = report["failures"].as_array().unwrap();
127        let expected = invalid.file().validate().unwrap_err().failures;
128        assert_eq!(failures.len(), expected.len());
129        for (json, failure) in failures.iter().zip(&expected) {
130            assert_eq!(json["node"], failure.node.as_str());
131            assert_eq!(json["attribute"], failure.attribute.as_str());
132            assert_eq!(json["pointer"], failure.pointer.as_str());
133            assert_eq!(json["message"], failure.kind.to_string());
134            assert_ne!(json["kind"], "other", "{failure}");
135        }
136    }
137}