blockworx_store/stamp.rs
1//! The stamp a document carries when it leaves home.
2//!
3//! An export — a `.json` written out, the metadata on a PDF — says which rev
4//! of which document it is a copy of, so a file that has travelled can be
5//! read back to where it came from:
6//!
7//! ```text
8//! {
9//! "stamp": { "rev": 12, "state": "<blake3 of revs/000012.json.gz>",
10//! "provenance": { "document": "motor-controller", … } },
11//! "version": 3,
12//! …the document…
13//! }
14//! ```
15//!
16//! The stamp is the first field so a reader takes it off the front; the
17//! document's own fields follow flattened beside it, so the file is a
18//! document like any other and the command line can open one (ignoring the
19//! stamp) with no second shape to support.
20//!
21//! [`Provenance`] is advisory throughout — it is never folded and never
22//! written into the importing document's log. A container's revs carry no
23//! stamp: inside a container the manifest says where every rev came from.
24
25use serde::{Deserialize, Serialize};
26
27use blockworx_doc::{document::Document, rev::Rev};
28
29use super::record::Digest;
30
31/// Which rev a projection was written from. Both halves matter: the rev
32/// orders it against the history, and `state` — the blake3 of that rev's own
33/// file, the one state digest this format has — says the document at that rev
34/// was *this* one. [`Stamp::provenance`] joins them on an artifact that left
35/// home, and only there.
36#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
37pub struct Stamp {
38 pub rev: Rev,
39 pub state: Digest,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub provenance: Option<Provenance>,
42}
43
44/// Where an export came from. Advisory throughout: it is never folded, never
45/// written into the importing document's log, and a file that carries none is
46/// read exactly as one that does.
47#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
48pub struct Provenance {
49 /// The source document's name.
50 pub document: String,
51 /// The rev the export was taken at — [`Stamp::rev`], since the export
52 /// *is* the projection of that fold.
53 pub rev: Rev,
54 /// The exporting session's identity.
55 pub author: String,
56 /// What the source called that rev — its tags, alphabetical, and empty
57 /// where it called it nothing.
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub tags: Vec<String>,
60}
61
62impl Provenance {
63 /// The one line that names the artifact's origin — the title block's
64 /// "From" row.
65 pub fn line(&self) -> String {
66 format!("Rev {} of {}", self.rev.get(), self.document)
67 }
68
69 /// What the line has no room for: who exported it, and the name the rev
70 /// carried.
71 pub fn detail(&self) -> String {
72 let by = format!("Exported by {}", self.author);
73 if self.tags.is_empty() {
74 return by;
75 }
76 format!(
77 "{by} \u{2014} \u{201c}{}\u{201d}",
78 self.tags.join("\u{201d}, \u{201c}")
79 )
80 }
81}
82
83/// What an exporting session knows about itself. The rev is deliberately
84/// absent: it is the stamp's own, so [`Stamp::from`] is the single place the
85/// two are joined and they cannot disagree.
86#[derive(Clone, PartialEq, Eq, Debug)]
87pub struct Source {
88 pub document: String,
89 pub author: String,
90 pub tags: Vec<String>,
91}
92
93impl Stamp {
94 /// A rev and the digest of the bytes it was written as.
95 pub fn at(rev: Rev, state: Digest) -> Self {
96 Self {
97 rev,
98 state,
99 provenance: None,
100 }
101 }
102
103 /// Mark this stamp as an artifact taken out of `source`.
104 #[must_use]
105 pub fn from(self, source: Source) -> Self {
106 let Source {
107 document,
108 author,
109 tags,
110 } = source;
111 Self {
112 provenance: Some(Provenance {
113 document,
114 rev: self.rev,
115 author,
116 tags,
117 }),
118 ..self
119 }
120 }
121}
122
123/// The file, as written: the stamp, then the document flattened beside it.
124#[derive(Serialize)]
125struct Stamped<'a> {
126 stamp: Stamp,
127 #[serde(flatten)]
128 document: &'a Document,
129}
130
131/// Enough of the file to read its stamp. Deserializing this builds a stamp
132/// and nothing else — serde skips every other field rather than modelling
133/// it, so asking where a file came from never parses the drawing in it.
134#[derive(Deserialize)]
135struct Header {
136 stamp: Stamp,
137}
138
139/// `document` flattened under a stamp saying where it came from.
140// `serde_json` fails only on a non-string map key or a non-finite float,
141// and neither the stamp nor the document model holds one.
142#[expect(clippy::expect_used, clippy::missing_panics_doc)]
143pub fn export_text(document: &Document, stamp: Stamp, source: Source) -> String {
144 let stamped = Stamped {
145 stamp: stamp.from(source),
146 document,
147 };
148 let mut text =
149 serde_json::to_string_pretty(&stamped).expect("the document serializes infallibly");
150 text.push('\n');
151 text
152}
153
154/// Whether `text` is a flattened document carrying a stamp — what a document
155/// that left home has and a hand-written one does not.
156pub fn exported_stamp_in(text: &str) -> Option<Stamp> {
157 serde_json::from_str::<Header>(text)
158 .ok()
159 .map(|header| header.stamp)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn stamp(rev: u64, seed: &[u8]) -> Stamp {
167 Stamp::at(blockworx_doc::fixtures::rev(rev), Digest::of(seed))
168 }
169
170 fn exported(document: &Document) -> String {
171 export_text(
172 document,
173 stamp(0, b"empty"),
174 Source {
175 document: "motor-controller".to_owned(),
176 author: "ada".to_owned(),
177 tags: vec!["Initial Draft".to_owned()],
178 },
179 )
180 }
181
182 /// The stamp comes off a file whose body this build could never model,
183 /// which is the proof that reading it does not model one.
184 #[test]
185 fn the_stamp_is_read_without_the_body_behind_it() {
186 let written = exported(&Document::default());
187 assert!(
188 written.find("\"stamp\"") < written.find("\"version\""),
189 "the stamp is not the first field of the file:\n{written}",
190 );
191
192 let mutilated = written.replace(r#""version": 3"#, r#""version": "three""#);
193 assert!(
194 crate::document_file::parse(&mutilated, "export.json").is_err(),
195 "precondition: this body does not deserialize as a document",
196 );
197 assert!(
198 exported_stamp_in(&mutilated).is_some(),
199 "the stamp is read off a body this build cannot parse",
200 );
201 }
202
203 /// What an export says about where it came from.
204 #[test]
205 fn an_export_carries_the_document_rev_author_and_tags_it_was_taken_at() {
206 let stamp = exported_stamp_in(&exported(&Document::default())).expect("it is stamped");
207 let from = stamp.provenance.clone().expect("and carries provenance");
208 assert_eq!(from.document, "motor-controller");
209 assert_eq!(from.author, "ada");
210 assert_eq!(from.tags, ["Initial Draft"]);
211 assert_eq!(
212 from.rev, stamp.rev,
213 "the provenance names a rev the stamp does not",
214 );
215 assert_eq!(from.line(), "Rev 0 of motor-controller");
216 }
217
218 /// A document written by hand, or one a container wrote, carries none —
219 /// which is how an importer tells an excerpt from a document.
220 #[test]
221 fn a_document_that_never_left_home_is_unstamped() {
222 let plain = crate::document_file::to_json(&Document::default());
223 assert!(exported_stamp_in(&plain).is_none());
224 }
225
226 /// An export is a document like any other on the way back in: the stamp
227 /// is an extra field an importer ignores.
228 #[test]
229 fn an_export_reads_back_as_a_plain_document() {
230 let empty = Document::default();
231 assert_eq!(
232 crate::document_file::parse(&exported(&empty), "export.json").expect("it parses"),
233 empty,
234 );
235 }
236}