blockworx/storage/
container.rs1use 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
14pub const ROOT: &str = "root.kdl";
16
17pub 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 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 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 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 pub fn save(&self, doc: &Document) -> miette::Result<()> {
89 self.write_document(doc, None)
90 }
91
92 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 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 .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 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 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 #[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 assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
185 }
186
187 #[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 #[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 #[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 #[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 assert!(root.contains(&format!("image \"{}\"", assets[0])), "{root}");
272
273 assert_eq!(c.load().unwrap(), doc, "the image comes back");
274 }
275
276 #[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 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 #[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 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 #[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 #[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}