Skip to main content

infotheory/spec/document/
io.rs

1//! Disk/document I/O policy for top-level spec documents.
2
3use super::{DOCUMENT_MAGIC, SpecDocument, SpecError, SpecResult};
4use std::path::Path;
5
6/// Load a spec document from disk.
7///
8/// Parsing is format-directed:
9/// - `.json` files are parsed as JSON documents only.
10/// - `.itsd` files (or payloads with the `itsd` magic envelope) are decoded as
11///   binary documents.
12/// - Otherwise, JSON parsing is attempted and JSON errors are reported
13///   directly without a binary fallthrough.
14pub fn load_spec_document(path: &str) -> SpecResult<SpecDocument> {
15    let full = Path::new(path);
16    let base_dir = full.parent().unwrap_or_else(|| Path::new("."));
17    let raw = std::fs::read(full)?;
18
19    let extension = full.extension().and_then(|value| value.to_str());
20    if extension.is_some_and(|value| value.eq_ignore_ascii_case("json")) {
21        let value = serde_json::from_slice::<serde_json::Value>(&raw).map_err(|err| {
22            SpecError::new(format!(
23                "invalid JSON spec document '{}': {err}",
24                full.display()
25            ))
26        })?;
27        return SpecDocument::parse_json_value(&value, base_dir);
28    }
29
30    if extension.is_some_and(|value| value.eq_ignore_ascii_case("itsd"))
31        || raw.starts_with(DOCUMENT_MAGIC)
32    {
33        return SpecDocument::from_binary(&raw, base_dir);
34    }
35
36    let value = serde_json::from_slice::<serde_json::Value>(&raw).map_err(|err| {
37        SpecError::new(format!(
38            "failed to parse spec document '{}': expected JSON or binary '{}' envelope; JSON parse error: {err}",
39            full.display(),
40            String::from_utf8_lossy(DOCUMENT_MAGIC)
41        ))
42    })?;
43    SpecDocument::parse_json_value(&value, base_dir)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    #[cfg(feature = "backend-ctw")]
50    use crate::api::RateBackend;
51    #[cfg(feature = "backend-ctw")]
52    use crate::spec::CanonicalJson;
53    use std::path::PathBuf;
54    use std::time::{SystemTime, UNIX_EPOCH};
55
56    fn temp_path(prefix: &str, ext: &str) -> PathBuf {
57        let nanos = SystemTime::now()
58            .duration_since(UNIX_EPOCH)
59            .expect("clock before epoch")
60            .as_nanos();
61        std::env::temp_dir().join(format!("infotheory-spec-io-{prefix}-{nanos}.{ext}"))
62    }
63
64    #[cfg(feature = "backend-ctw")]
65    #[test]
66    fn load_spec_document_detects_json_extension() {
67        let path = temp_path("json", "json");
68        let expected = SpecDocument::RateBackend(RateBackend::Ctw { depth: 8 });
69        std::fs::write(&path, expected.to_canonical_json().expect("json"))
70            .expect("write json spec");
71
72        let parsed = load_spec_document(path.to_string_lossy().as_ref()).expect("load json spec");
73        assert_eq!(
74            parsed.to_canonical_json().expect("parsed json"),
75            expected.to_canonical_json().expect("expected json")
76        );
77
78        let _ = std::fs::remove_file(path);
79    }
80
81    #[cfg(feature = "backend-ctw")]
82    #[test]
83    fn load_spec_document_detects_binary_extension_and_magic() {
84        let path = temp_path("binary", "itsd");
85        let expected = SpecDocument::RateBackend(RateBackend::Ctw { depth: 12 });
86        std::fs::write(&path, expected.to_binary()).expect("write binary spec");
87
88        let parsed =
89            load_spec_document(path.to_string_lossy().as_ref()).expect("load binary extension");
90        assert_eq!(
91            parsed.to_canonical_json().expect("parsed json"),
92            expected.to_canonical_json().expect("expected json")
93        );
94
95        let magic_path = temp_path("magic", "bin");
96        std::fs::write(&magic_path, expected.to_binary()).expect("write magic-detected binary");
97        let magic_parsed =
98            load_spec_document(magic_path.to_string_lossy().as_ref()).expect("load binary magic");
99        assert_eq!(
100            magic_parsed.to_canonical_json().expect("parsed json"),
101            expected.to_canonical_json().expect("expected json")
102        );
103
104        let _ = std::fs::remove_file(path);
105        let _ = std::fs::remove_file(magic_path);
106    }
107
108    #[test]
109    fn load_spec_document_reports_non_json_non_binary_payloads_directly() {
110        let path = temp_path("invalid", "txt");
111        std::fs::write(&path, b"not json and not binary").expect("write invalid payload");
112
113        let err = match load_spec_document(path.to_string_lossy().as_ref()) {
114            Ok(_) => panic!("invalid payload must fail"),
115            Err(err) => err,
116        };
117        let msg = err.to_string();
118        assert!(msg.contains("expected JSON or binary 'itsd' envelope"));
119        assert!(msg.contains(path.to_string_lossy().as_ref()));
120
121        let _ = std::fs::remove_file(path);
122    }
123}