Skip to main content

blockworx/storage/
container.rs

1//! A document container: the directory a drawing and its history live in.
2//!
3//! Opening is deliberately **tolerant**. A container is meant to be something a
4//! person can make by hand — create a directory, drop a `.kdl` in as `root.kdl`
5//! — so a directory missing `assets/`, missing `history/`, or missing `root.kdl`
6//! altogether is opened rather than rejected. The missing parts are materialized
7//! when something is first written to them.
8
9use super::{Durability, Storage};
10use crate::document::Document;
11use crate::document::schema_convert::{asset_from_bytes, asset_to_bytes, finish_load};
12use crate::schema::model as schema;
13
14/// The current document, at the container root.
15pub const ROOT: &str = "root.kdl";
16
17/// Where image payloads live, one file per asset, named by its id.
18pub const ASSETS: &str = "assets";
19
20fn asset_path(id: &str) -> String {
21    format!("{ASSETS}/{id}")
22}
23
24pub struct Container<S: Storage> {
25    storage: S,
26    /// Names the container itself — the directory's own name. Used as the
27    /// display name whenever the document carries no `name` of its own.
28    fallback_name: String,
29}
30
31impl<S: Storage> Container<S> {
32    pub fn new(storage: S, fallback_name: impl Into<String>) -> Self {
33        Self {
34            storage,
35            fallback_name: fallback_name.into(),
36        }
37    }
38
39    pub fn storage(&self) -> &S {
40        &self.storage
41    }
42
43    /// The document at [`ROOT`], with the images its placements name read back
44    /// out of [`ASSETS`]. An empty container opens blank; a malformed
45    /// `root.kdl` is an error, since silently replacing a document we failed to
46    /// *read* would be a way to lose the drawing.
47    pub fn load(&self) -> miette::Result<Document> {
48        if !self.storage.exists(ROOT) {
49            return Ok(Document::default());
50        }
51        let bytes = self
52            .storage
53            .read(ROOT)
54            .map_err(|e| miette::miette!("reading {ROOT}: {e}"))?;
55        let src = String::from_utf8(bytes)
56            .map_err(|e| miette::miette!("{ROOT} is not valid UTF-8: {e}"))?;
57        let mut model = schema::Document::parse_kdl(&src, ROOT)?;
58
59        // A container normally carries no `asset` nodes at all — the placement
60        // already names the file. One that does (hand-written, or a loose
61        // document dropped in) keeps its inline payload, which is why the
62        // already-defined ids are skipped rather than overwritten.
63        let inline: Vec<String> = model.assets.iter().map(|a| a.id.clone()).collect();
64        let wanted: Vec<String> = model
65            .referenced_assets()
66            .into_iter()
67            .filter(|id| !inline.iter().any(|had| had == id))
68            .map(str::to_string)
69            .collect();
70        for id in wanted {
71            let path = asset_path(&id);
72            let bytes = self
73                .storage
74                .read(&path)
75                .map_err(|e| miette::miette!("reading {path}: {e}"))?;
76            model.assets.push(asset_from_bytes(&id, bytes)?);
77        }
78        finish_load(model)
79    }
80
81    /// Write the document, with its images as files in [`ASSETS`] rather than
82    /// inline in `root.kdl`.
83    ///
84    /// Assets are **write-once**: the id is a hash of the content, so a file
85    /// that is already there already holds the right bytes. Nothing is ever
86    /// deleted, even when the document stops referencing it — the history may
87    /// still point at it, and disk is cheaper than a lost image.
88    pub fn save(&self, doc: &Document) -> miette::Result<()> {
89        self.write_document(doc, None)
90    }
91
92    /// Write the document *and* record it as history entry `seq`.
93    pub fn save_and_record(
94        &self,
95        doc: &Document,
96        seq: u64,
97        meta: &super::history::Entry,
98    ) -> miette::Result<()> {
99        self.write_document(doc, Some((seq, meta)))
100    }
101
102    /// Ordered so the one synced write lands last: by the time `root.kdl` names
103    /// an asset or a history entry mentions it, that file is already there.
104    fn write_document(
105        &self,
106        doc: &Document,
107        record: Option<(u64, &super::history::Entry)>,
108    ) -> miette::Result<()> {
109        let mut model = schema::Document::from(doc);
110        for asset in &model.assets {
111            let path = asset_path(&asset.id);
112            if !self.storage.exists(&path) {
113                self.storage
114                    // Synced, unlike a history entry: `root.kdl` is synced and
115                    // *references* this file, so losing the write would leave a
116                    // document that cannot load. Only a new image pays for it.
117                    .write(&path, &asset_to_bytes(asset)?, Durability::Sync)
118                    .map_err(|e| miette::miette!("writing {path}: {e}"))?;
119            }
120        }
121        model.assets.clear();
122        let root = model.to_kdl();
123        if let Some((seq, meta)) = record {
124            super::history::append(&self.storage, seq, meta, &root)
125                .map_err(|e| miette::miette!("recording history entry {seq}: {e}"))?;
126        }
127        self.storage
128            .write(ROOT, root.as_bytes(), Durability::Sync)
129            .map_err(|e| miette::miette!("writing {ROOT}: {e}"))
130    }
131
132    /// What to call this document: its own `name` when it has one, else the
133    /// container directory's name.
134    pub fn display_name<'a>(&'a self, doc: &'a Document) -> &'a str {
135        doc.name.as_deref().unwrap_or(&self.fallback_name)
136    }
137}
138
139#[cfg(not(target_arch = "wasm32"))]
140impl Container<super::fs::FsStorage> {
141    /// Open the container rooted at `dir`. Nothing is required to exist — not
142    /// `assets/`, not `history/`, not `root.kdl`, not `dir` itself. Opening
143    /// creates nothing either; the directory is materialized by the first write,
144    /// so a mistyped path does not leave an empty container behind.
145    pub fn open(dir: &std::path::Path) -> Self {
146        let name = dir
147            .file_name()
148            .map_or_else(|| dir.display().to_string(), |n| n.to_string_lossy().into());
149        Self::new(super::fs::FsStorage::new(dir), name)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::storage::atomic::tests::TempDir;
157    use crate::storage::fs::FsStorage;
158
159    fn container(name: &str) -> (TempDir, Container<FsStorage>) {
160        let dir = TempDir::new(name);
161        let c = Container::new(FsStorage::new(dir.path()), "the-drawing");
162        (dir, c)
163    }
164
165    #[test]
166    fn a_document_round_trips_through_the_container() {
167        let (_dir, c) = container("container-roundtrip");
168        let doc = Document {
169            name: Some("CT Scanner".to_string()),
170            ..Document::default()
171        };
172        c.save(&doc).unwrap();
173        assert_eq!(c.load().unwrap(), doc);
174    }
175
176    /// The hand-made container: a bare directory holding nothing at all opens as
177    /// an empty document rather than failing.
178    #[test]
179    fn an_empty_directory_opens_empty() {
180        let (dir, c) = container("container-bare");
181        assert!(!c.storage().exists(ROOT));
182        assert_eq!(c.load().unwrap(), Document::default());
183        // Nothing was created just by opening and reading.
184        assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
185    }
186
187    /// The other hand-made case: a directory holding only a `root.kdl` someone
188    /// dropped in, with no `assets/` or `history/` beside it.
189    #[test]
190    fn a_directory_holding_only_a_root_kdl_opens() {
191        let (dir, c) = container("container-root-only");
192        std::fs::write(
193            dir.path().join(ROOT),
194            "top \"b0\"\nblock \"b0\" x=0 y=0 w=8 h=8",
195        )
196        .unwrap();
197        let doc = c.load().unwrap();
198        assert_eq!(doc.blocks.len(), 1);
199        assert!(doc.name.is_none());
200        assert!(!c.storage().exists("assets"));
201        assert!(!c.storage().exists("history"));
202    }
203
204    /// A document we could not read is an error, never a silently empty one —
205    /// saving over that would be a way to lose the drawing.
206    #[test]
207    fn a_malformed_root_is_an_error_not_an_empty_document() {
208        let (dir, c) = container("container-malformed");
209        std::fs::write(dir.path().join(ROOT), "top \"b0\" {").unwrap();
210        assert!(c.load().is_err());
211    }
212
213    #[test]
214    fn the_directory_name_is_the_fallback_display_name() {
215        let (_dir, c) = container("container-name");
216        let mut doc = Document::default();
217        assert_eq!(c.display_name(&doc), "the-drawing");
218        doc.name = Some("CT Scanner".to_string());
219        assert_eq!(c.display_name(&doc), "CT Scanner");
220    }
221
222    /// Opening is read-only: a container that is not there yet reads as empty
223    /// without being created, so a mistyped path leaves nothing behind. The
224    /// directory appears when something is written to it.
225    #[test]
226    fn open_creates_nothing_until_a_write() {
227        let dir = TempDir::new("container-open");
228        let root = dir.join("ct-scanner.bwx");
229        let c = Container::open(&root);
230        assert!(!root.exists());
231        assert_eq!(c.load().unwrap(), Document::default());
232        assert!(!root.exists());
233        assert_eq!(c.display_name(&Document::default()), "ct-scanner.bwx");
234
235        c.save(&Document::default()).unwrap();
236        assert!(root.join(ROOT).is_file());
237    }
238
239    fn with_image(marker: &str) -> Document {
240        use crate::document::{Image, ImageData};
241        use crate::store::IdMapExt as _;
242        let mut doc = Document::default();
243        let top = doc.top_id;
244        let block = doc.blocks.get_mut(&top).expect("the top block");
245        block.images.insert_value(Image::new(
246            ImageData::Svg(format!("<svg viewBox=\"0 0 1 1\"><!--{marker}--></svg>")),
247            egui::Rect::ZERO,
248        ));
249        doc
250    }
251
252    /// The split: payloads live in `assets/`, and `root.kdl` names them without
253    /// carrying them.
254    #[test]
255    fn images_are_files_beside_the_document_not_inside_it() {
256        let (dir, c) = container("container-assets");
257        let doc = with_image("split");
258        c.save(&doc).unwrap();
259
260        let root = std::fs::read_to_string(dir.path().join(ROOT)).unwrap();
261        assert!(!root.contains("asset \""), "payload inlined:\n{root}");
262        assert!(!root.contains("<svg"), "payload inlined:\n{root}");
263
264        let assets: Vec<_> = std::fs::read_dir(dir.path().join(ASSETS))
265            .unwrap()
266            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
267            .collect();
268        assert_eq!(assets.len(), 1, "{assets:?}");
269        assert_eq!(assets[0].rsplit_once('.').map(|(_, e)| e), Some("svg"));
270        // The placement names exactly that file.
271        assert!(root.contains(&format!("image \"{}\"", assets[0])), "{root}");
272
273        assert_eq!(c.load().unwrap(), doc, "the image comes back");
274    }
275
276    /// The reason assets are never deleted: a snapshot in `history/` may still
277    /// name one the current document has dropped.
278    #[test]
279    fn an_asset_the_document_stops_using_stays_on_disk() {
280        let (dir, c) = container("container-asset-kept");
281        c.save(&with_image("orphaned")).unwrap();
282        let orphan = std::fs::read_dir(dir.path().join(ASSETS))
283            .unwrap()
284            .next()
285            .unwrap()
286            .unwrap()
287            .path();
288        assert!(orphan.is_file());
289
290        // Save a document that references nothing.
291        c.save(&Document::default()).unwrap();
292        assert!(
293            orphan.is_file(),
294            "an unreferenced asset was deleted; history may still point at it"
295        );
296    }
297
298    /// Write-once: the id is a hash, so a file already there already holds the
299    /// right bytes and re-saving must not rewrite it.
300    #[test]
301    fn re_saving_does_not_rewrite_an_existing_asset() {
302        let (dir, c) = container("container-asset-write-once");
303        let doc = with_image("write-once");
304        c.save(&doc).unwrap();
305        let asset = std::fs::read_dir(dir.path().join(ASSETS))
306            .unwrap()
307            .next()
308            .unwrap()
309            .unwrap()
310            .path();
311        let first = std::fs::metadata(&asset).unwrap().modified().unwrap();
312
313        // Sentinel content proves the second save left the file alone; a
314        // rewrite would restore the real payload.
315        std::fs::write(&asset, b"untouched").unwrap();
316        c.save(&doc).unwrap();
317        assert_eq!(std::fs::read(&asset).unwrap(), b"untouched");
318        let _ = first;
319    }
320
321    /// A container missing a payload its document names is an error, not a
322    /// document that silently loses an image.
323    #[test]
324    fn a_missing_asset_file_is_an_error() {
325        let (dir, c) = container("container-asset-missing");
326        c.save(&with_image("vanishing")).unwrap();
327        let asset = std::fs::read_dir(dir.path().join(ASSETS))
328            .unwrap()
329            .next()
330            .unwrap()
331            .unwrap()
332            .path();
333        std::fs::remove_file(&asset).unwrap();
334        assert!(c.load().is_err());
335    }
336
337    /// A hand-written container may still inline a payload; the inline one wins
338    /// and no file is looked for.
339    #[test]
340    fn an_inline_asset_is_honoured_without_a_file() {
341        let (dir, c) = container("container-asset-inline");
342        std::fs::write(
343            dir.path().join(ROOT),
344            "top \"b0\"\n\
345             block \"b0\" x=0 y=0 w=8 h=8 {\n\
346             \x20   image \"logo.svg\" x=0 y=0 w=4 h=4\n\
347             }\n\
348             asset \"logo.svg\" {\n    svg r#\"<svg/>\"#\n}",
349        )
350        .unwrap();
351        assert!(!c.storage().exists(ASSETS));
352        let doc = c.load().expect("an inline payload needs no file");
353        assert_eq!(doc.blocks[&doc.top_id].images.len(), 1);
354    }
355}