1use serde::{Deserialize, Serialize};
31
32use blockworx_doc::{document::Document, rev::Rev};
33
34use super::container::{Container, PROJECTION};
35use super::record::Digest;
36use super::storage::Storage;
37
38#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
44pub struct Stamp {
45 pub rev: Rev,
46 pub state: Digest,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub provenance: Option<Provenance>,
49}
50
51#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
55pub struct Provenance {
56 pub document: String,
58 pub rev: Rev,
61 pub author: String,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub tags: Vec<String>,
67}
68
69impl Provenance {
70 pub fn line(&self) -> String {
73 format!("Rev {} of {}", self.rev.get(), self.document)
74 }
75
76 pub fn detail(&self) -> String {
79 let by = format!("Exported by {}", self.author);
80 if self.tags.is_empty() {
81 return by;
82 }
83 format!(
84 "{by} \u{2014} \u{201c}{}\u{201d}",
85 self.tags.join("\u{201d}, \u{201c}")
86 )
87 }
88}
89
90#[derive(Clone, PartialEq, Eq, Debug)]
94pub struct Source {
95 pub document: String,
96 pub author: String,
97 pub tags: Vec<String>,
98}
99
100impl Stamp {
101 pub fn at(rev: Rev, state: Digest) -> Self {
103 Self {
104 rev,
105 state,
106 provenance: None,
107 }
108 }
109
110 #[must_use]
112 pub fn from(self, source: Source) -> Self {
113 let Source {
114 document,
115 author,
116 tags,
117 } = source;
118 Self {
119 provenance: Some(Provenance {
120 document,
121 rev: self.rev,
122 author,
123 tags,
124 }),
125 ..self
126 }
127 }
128
129 fn names_the_rev_of(&self, other: &Stamp) -> bool {
133 self.rev == other.rev && self.state == other.state
134 }
135}
136
137#[derive(Serialize)]
139struct Stamped<'a> {
140 stamp: Stamp,
141 #[serde(flatten)]
142 document: &'a Document,
143}
144
145#[derive(Deserialize)]
149struct Header {
150 stamp: Stamp,
151}
152
153#[derive(Clone, Debug)]
155pub enum Found {
156 Nothing,
158 Stamped(Stamp),
159 Unstamped,
161}
162
163#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167pub enum Freshness {
168 Fresh,
171 Stale,
173 Unrecognized,
178}
179
180pub const SETTLE: core::time::Duration = core::time::Duration::from_secs(2);
185
186#[derive(Clone, Copy, PartialEq, Eq, Debug)]
188pub enum Refresh {
189 Settled,
191 Wait(core::time::Duration),
193 Write,
194}
195
196#[must_use]
203pub fn refreshed(
204 freshness: Option<Freshness>,
205 may_write: crate::doc::Writability,
206 waited: core::time::Duration,
207) -> Refresh {
208 if freshness != Some(Freshness::Stale) || may_write == crate::doc::Writability::ReadOnly {
209 return Refresh::Settled;
210 }
211 match SETTLE.checked_sub(waited) {
212 None | Some(core::time::Duration::ZERO) => Refresh::Write,
213 Some(remaining) => Refresh::Wait(remaining),
214 }
215}
216
217impl Freshness {
218 pub fn of(found: &Found, head: &Stamp) -> Self {
219 match found {
220 Found::Nothing => Freshness::Stale,
221 Found::Stamped(stamp) if stamp.names_the_rev_of(head) => Freshness::Fresh,
222 Found::Stamped(stamp) if stamp.rev < head.rev => Freshness::Stale,
223 Found::Unstamped | Found::Stamped(_) => Freshness::Unrecognized,
224 }
225 }
226}
227
228#[expect(clippy::expect_used, clippy::missing_panics_doc)]
232pub fn text(stamp: Stamp, document: &Document) -> String {
233 let stamped = Stamped { stamp, document };
234 let mut text =
235 serde_json::to_string_pretty(&stamped).expect("the projection serializes infallibly");
236 text.push('\n');
237 text
238}
239
240pub fn export_text(document: &Document, stamp: Stamp, source: Source) -> String {
243 text(stamp.from(source), document)
244}
245
246pub fn stamp_in(text: &str) -> Found {
248 match serde_json::from_str::<Header>(text) {
249 Ok(header) => Found::Stamped(header.stamp),
250 Err(_) => Found::Unstamped,
251 }
252}
253
254pub fn exported_stamp_in(text: &str) -> Option<Stamp> {
257 match stamp_in(text) {
258 Found::Stamped(stamp) => Some(stamp),
259 Found::Nothing | Found::Unstamped => None,
260 }
261}
262
263pub fn found_in<S: Storage>(container: &Container<S>) -> Found {
265 match container.read(&PROJECTION) {
266 Ok(Some(bytes)) => {
267 String::from_utf8(bytes).map_or(Found::Unstamped, |text| stamp_in(&text))
268 }
269 Ok(None) | Err(_) => Found::Nothing,
270 }
271}
272
273pub fn write_in<S: Storage>(
279 container: &Container<S>,
280 stamp: Stamp,
281 document: &Document,
282) -> std::io::Result<()> {
283 container.write(&PROJECTION, text(stamp, document).as_bytes())
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn stamp(rev: u64, seed: &[u8]) -> Stamp {
291 Stamp::at(blockworx_doc::fixtures::rev(rev), Digest::of(seed))
292 }
293
294 #[test]
297 fn the_stamp_is_read_without_the_body_behind_it() {
298 let empty = Document::default();
299 let written = text(stamp(7, b"seven"), &empty);
300 assert!(
301 written.find("\"stamp\"") < written.find("\"version\""),
302 "the stamp is not the first field of the file:\n{written}",
303 );
304
305 let mutilated = written.replace(r#""version": 3"#, r#""version": "three""#);
306 assert!(
307 crate::document_file::parse(&mutilated, "document.json").is_err(),
308 "precondition: this body does not deserialize as a document",
309 );
310 assert!(matches!(
311 stamp_in(&mutilated),
312 Found::Stamped(found) if found == stamp(7, b"seven"),
313 ));
314 }
315
316 #[test]
321 fn the_projection_carries_no_provenance_and_an_export_does() {
322 let empty = Document::default();
323 let projection = text(stamp(0, b"empty"), &empty);
324 assert!(
325 !projection.contains("provenance"),
326 "document.json was written as an excerpt of another document:\n{projection}",
327 );
328 assert!(matches!(
329 stamp_in(&projection),
330 Found::Stamped(stamp) if stamp.provenance.is_none(),
331 ));
332
333 let exported = export_text(
334 &empty,
335 stamp(0, b"empty"),
336 Source {
337 document: "motor-controller".to_owned(),
338 author: "ada".to_owned(),
339 tags: vec!["Initial Draft".to_owned()],
340 },
341 );
342 let stamp = exported_stamp_in(&exported).expect("the export is stamped");
343 let from = stamp.provenance.clone().expect("and carries provenance");
344 assert_eq!(from.document, "motor-controller");
345 assert_eq!(from.author, "ada");
346 assert_eq!(from.tags, ["Initial Draft"]);
347 assert_eq!(
348 from.rev, stamp.rev,
349 "the provenance names a rev the stamp does not",
350 );
351 assert_eq!(from.line(), "Rev 0 of motor-controller");
352 }
353
354 #[test]
358 fn provenance_does_not_make_a_projection_unrecognized() {
359 let head = stamp(4, b"head");
360 let travelled = head.clone().from(Source {
361 document: "elsewhere".to_owned(),
362 author: "ada".to_owned(),
363 tags: Vec::new(),
364 });
365 assert_eq!(
366 Freshness::of(&Found::Stamped(travelled), &head),
367 Freshness::Fresh,
368 );
369 }
370
371 #[test]
374 fn a_written_projection_reads_back_as_a_plain_document() {
375 let empty = Document::default();
376 let written = text(stamp(0, b"empty"), &empty);
377 assert_eq!(
378 crate::document_file::parse(&written, "document.json").expect("it parses"),
379 empty,
380 );
381 }
382
383 #[test]
384 fn a_projection_at_the_head_rev_is_fresh_and_an_older_one_is_stale() {
385 let head = stamp(9, b"head");
386 assert_eq!(
387 Freshness::of(&Found::Stamped(head.clone()), &head),
388 Freshness::Fresh,
389 );
390 assert_eq!(
391 Freshness::of(&Found::Stamped(stamp(8, b"older")), &head),
392 Freshness::Stale,
393 );
394 assert_eq!(Freshness::of(&Found::Nothing, &head), Freshness::Stale);
395 }
396
397 #[test]
401 fn a_stamp_this_history_never_wrote_is_unrecognized() {
402 let head = stamp(9, b"head");
403 assert_eq!(
404 Freshness::of(&Found::Stamped(stamp(9, b"other bytes")), &head),
405 Freshness::Unrecognized,
406 );
407 assert_eq!(
408 Freshness::of(&Found::Stamped(stamp(10, b"the future")), &head),
409 Freshness::Unrecognized,
410 );
411 assert_eq!(
412 Freshness::of(&Found::Unstamped, &head),
413 Freshness::Unrecognized,
414 );
415 }
416
417 #[test]
421 fn a_stale_projection_is_rewritten_once_the_head_has_sat_still() {
422 use crate::doc::Writability::{ReadOnly, Writable};
423 let stale = Some(Freshness::Stale);
424 assert_eq!(refreshed(stale, Writable, SETTLE), Refresh::Write);
425 assert_eq!(refreshed(stale, Writable, SETTLE * 2), Refresh::Write);
426 assert_eq!(
427 refreshed(stale, Writable, SETTLE / 2),
428 Refresh::Wait(SETTLE / 2),
429 "the wait answers what is left of it, not the whole of it",
430 );
431 assert_eq!(
432 refreshed(stale, ReadOnly, SETTLE * 2),
433 Refresh::Settled,
434 "a session that may not write the container wrote its projection",
435 );
436 for settled in [None, Some(Freshness::Fresh), Some(Freshness::Unrecognized)] {
437 assert_eq!(
438 refreshed(settled, Writable, SETTLE * 2),
439 Refresh::Settled,
440 "{settled:?} was taken for a projection owed a rewrite",
441 );
442 }
443 }
444}