Skip to main content

blockworx_store/
document_file.rs

1//! The document file: JSON is the one document format, and
2//! [`blockworx_doc::document::Document`] is the single source of truth for it
3//! โ€” serde's derives on the model *are* the codec, both directions
4//! (`docs/log-vs-snapshot.md` ยง8). This module is only the two doors: the
5//! spanned parse diagnostic, and the pretty-printed write.
6//!
7//! [`ParseFailure`] carries the source text (for `miette` diagnostics), so
8//! it is large by design โ€” callers accept that rather than boxing.
9#![allow(clippy::result_large_err)]
10
11use blockworx_doc::document::Document;
12use miette::{Diagnostic, NamedSource, SourceSpan};
13use thiserror::Error;
14
15/// A JSON document this build will not read, located in the source. serde
16/// reports a line and column; the span is that position in bytes, so the
17/// rendered report underlines the offending token rather than quoting an
18/// offset the reader has to count to.
19#[derive(Debug, Error, Diagnostic)]
20#[error("{message}")]
21pub struct ParseFailure {
22    message: String,
23    #[label("here")]
24    span: SourceSpan,
25    #[source_code]
26    src: NamedSource<String>,
27}
28
29/// Parse a JSON document.
30///
31/// A version this build does not read is refused by the model itself
32/// ([`FormatVersion`](blockworx_doc::document::FormatVersion)), so it
33/// arrives here as an ordinary decode failure with the offending token
34/// underlined.
35///
36/// # Errors
37/// [`ParseFailure`], carrying the source so the failure points at the
38/// offending line.
39pub fn parse(src: &str, src_name: &str) -> Result<Document, ParseFailure> {
40    serde_json::from_str(src).map_err(|error| {
41        let offset = byte_offset(src, error.line(), error.column());
42        ParseFailure {
43            message: error.to_string(),
44            span: (offset, 1.min(src.len().saturating_sub(offset))).into(),
45            src: NamedSource::new(src_name, src.to_owned()),
46        }
47    })
48}
49
50/// Serialize `doc` as the document format: pretty-printed JSON, one field
51/// per line, so a diff of two folds reads as the edits between them.
52// `serde_json` fails only on a map key that is not a string or a
53// non-finite float, and the document model holds neither.
54#[expect(clippy::expect_used, clippy::missing_panics_doc)]
55pub fn to_json(doc: &Document) -> String {
56    serde_json::to_string_pretty(doc).expect("the document model serializes infallibly")
57}
58
59/// A 1-based line/column pair as a byte offset. serde reports `(0, 0)` for a
60/// failure with no position (an early EOF), which lands at the start.
61fn byte_offset(src: &str, line: usize, column: usize) -> usize {
62    let start: usize = src
63        .split_inclusive('\n')
64        .take(line.saturating_sub(1))
65        .map(str::len)
66        .sum();
67    (start + column.saturating_sub(1)).min(src.len())
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    /// The repository's document corpus. Absolute, because a test's working
75    /// directory is its own package and the corpus is the workspace's.
76    const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures");
77
78    /// Every document the repository ships as test data, through the
79    /// property the format is for: the file is what the model says, so
80    /// writing a parsed document and reading it back lands on the same
81    /// value.
82    ///
83    /// `BLOCKWORX_UPDATE_GOLDENS=1` rewrites each fixture in the spelling
84    /// this build writes, which is how the corpus is carried across a
85    /// deliberate format change.
86    #[test]
87    fn every_fixture_survives_a_write_and_a_read() {
88        let update = std::env::var_os("BLOCKWORX_UPDATE_GOLDENS").is_some();
89        let mut seen = 0;
90        for entry in std::fs::read_dir(FIXTURES).expect("fixtures/ is there") {
91            let path = entry.expect("a directory entry").path();
92            if path.extension().and_then(|e| e.to_str()) != Some("json") {
93                continue;
94            }
95            let name = path.display().to_string();
96            let src = std::fs::read_to_string(&path).expect("the fixture reads");
97            let doc = parse(&src, &name).unwrap_or_else(|e| panic!("{name} parses:\n{e:?}"));
98            let mut written = to_json(&doc);
99            written.push('\n');
100            if update {
101                std::fs::write(&path, &written).expect("the fixture writes");
102            }
103            let read =
104                parse(&written, &name).unwrap_or_else(|e| panic!("{name} re-parses:\n{e:?}"));
105            assert!(read == doc, "{name} did not survive the round trip");
106            seen += 1;
107        }
108        assert!(seen >= 13, "the corpus shrank to {seen} documents");
109    }
110
111    /// The bytes the fixture corpus cannot pin: what a document *looks
112    /// like*, which is the whole of the format's diff-readability claim.
113    /// Regenerate with `BLOCKWORX_UPDATE_GOLDENS=1 cargo test -p
114    /// blockworx-store document_file` and read the diff โ€” it is the format changing.
115    #[test]
116    fn the_document_format_is_byte_stable() {
117        const GOLDEN_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/goldens/document.json");
118        const GOLDEN: &str = include_str!("goldens/document.json");
119
120        let src = std::fs::read_to_string(format!("{FIXTURES}/root-scope.json"))
121            .expect("the fixture reads");
122        let mut text = to_json(&parse(&src, "root-scope.json").expect("it parses"));
123        text.push('\n');
124        if std::env::var_os("BLOCKWORX_UPDATE_GOLDENS").is_some() {
125            std::fs::write(GOLDEN_PATH, &text).expect("the golden is written");
126            return;
127        }
128        assert_eq!(
129            text, GOLDEN,
130            "the document format changed; if that is intended, regenerate with \
131             `BLOCKWORX_UPDATE_GOLDENS=1 cargo test -p blockworx-store document_file` and \
132             review the diff",
133        );
134    }
135
136    /// The miette diagnostic survives the schema's deletion: a file that
137    /// will not parse points at the line that stopped it, with the source
138    /// attached, rather than reporting a byte offset a reader must count
139    /// to (CLAUDE.md, developer-facing tooling).
140    #[test]
141    fn a_parse_failure_points_at_the_offending_line_with_its_source() {
142        let src = "{\n  \"version\": 3,\n  \"blocks\": [\n}\n";
143        let failure = parse(src, "broken.json").expect_err("it does not parse");
144        assert_eq!(
145            src[..failure.span.offset()].lines().count(),
146            3,
147            "the span lands on the line serde stopped at: {src:?}"
148        );
149        let attached = failure
150            .source_code()
151            .expect("the report carries its source")
152            .read_span(&failure.span, 1, 1)
153            .expect("the span reads back against it");
154        assert_eq!(attached.name(), Some("broken.json"));
155        assert!(
156            String::from_utf8_lossy(attached.data()).contains("\"blocks\""),
157            "the rendered line is the one that failed"
158        );
159    }
160
161    /// The newer-build guard, through the real door.
162    #[test]
163    fn a_document_from_a_newer_build_is_refused_with_a_span() {
164        let failure = parse(r#"{"version": 99}"#, "future.json").expect_err("it is refused");
165        assert!(
166            failure.to_string().contains("newer than this build reads"),
167            "{failure}"
168        );
169    }
170}