1use std::fs::{File, OpenOptions};
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22use super::assets;
23use super::lock::{Claim, LockGuard, LockHolder};
24use super::manifest::{BreakReport, Row};
25use super::record::{Digest, WallTime};
26use super::revs::{self, REVS};
27
28use blockworx_doc::{document::Document, rev::Rev};
29
30pub const MANIFEST: &str = "manifest.jsonl";
31pub const PROJECTION: &str = "document.json";
32pub const ASSETS: &str = "assets";
33pub const LOCK: &str = "lock";
34pub const GITATTRIBUTES: &str = ".gitattributes";
35
36const GITATTRIBUTES_TEMPLATE: &str = "\
44# A blockworx container. revs/ holds the document at every rev and
45# manifest.jsonl is the trail that names them; document.json is a
46# projection of the head, regenerated on save.
47#
48# The merge driver named here is not built in. Define it once, globally:
49# git config --global merge.conflict-always.name 'never auto-merge'
50# git config --global merge.conflict-always.driver false
51# Without it git falls back to the text merge, and a merged manifest will
52# be refused by the integrity chain at load — loudly, but later.
53manifest.jsonl merge=conflict-always
54document.json linguist-generated=true merge=binary
55revs/** binary
56assets/** binary
57lock export-ignore
58";
59
60#[derive(Debug)]
63pub enum ReadOnlyReason {
64 Locked(LockHolder),
66 Reading,
71 HistoryBroken(BreakReport),
74 WriteFailed(std::io::Error),
78}
79
80impl std::fmt::Display for ReadOnlyReason {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 ReadOnlyReason::Locked(held) => {
84 write!(f, "another blockworx ({held}) has this diagram open")
85 }
86 ReadOnlyReason::Reading => write!(f, "this container was opened to be read"),
87 ReadOnlyReason::HistoryBroken(report) => {
88 write!(f, "the manifest does not verify at {report}")
89 }
90 ReadOnlyReason::WriteFailed(error) => {
91 write!(f, "a row could not be appended: {error}")
92 }
93 }
94 }
95}
96
97pub enum Access {
99 Writable(Writer),
100 ReadOnly(ReadOnlyReason),
101}
102
103pub struct Writer {
104 manifest: File,
105 lock: LockGuard,
108}
109
110#[derive(Debug, thiserror::Error)]
113pub enum ContainerError {
114 #[error("{0} already holds a diagram")]
115 Exists(PathBuf),
116 #[error("{0} is not a blockworx diagram: it has no {MANIFEST}")]
117 NotAContainer(PathBuf),
118 #[error(transparent)]
119 Io(#[from] std::io::Error),
120}
121
122pub struct Container {
123 root: PathBuf,
124 access: Access,
125}
126
127impl Container {
128 pub fn create(root: &Path, now: WallTime) -> Result<Self, ContainerError> {
134 Self::create_holding(root, now, b"")
135 }
136
137 pub fn create_holding(root: &Path, now: WallTime, rows: &[u8]) -> Result<Self, ContainerError> {
145 if root.join(MANIFEST).exists() {
146 return Err(ContainerError::Exists(root.to_path_buf()));
147 }
148 std::fs::create_dir_all(root.join(ASSETS))?;
149 std::fs::create_dir_all(root.join(REVS))?;
150 std::fs::write(root.join(GITATTRIBUTES), GITATTRIBUTES_TEMPLATE)?;
151 let mut file = File::create(root.join(MANIFEST))?;
152 file.write_all(rows)?;
153 file.sync_all()?;
154 sync_dir(root)?;
158 Self::attach(root, now)
159 }
160
161 pub fn open(root: &Path, now: WallTime) -> Result<Self, ContainerError> {
167 Self::require_manifest(root)?;
168 Self::attach(root, now)
169 }
170
171 pub fn reading(root: &Path) -> Result<Self, ContainerError> {
182 Self::require_manifest(root)?;
183 Ok(Self {
184 root: root.to_path_buf(),
185 access: Access::ReadOnly(ReadOnlyReason::Reading),
186 })
187 }
188
189 fn require_manifest(root: &Path) -> Result<(), ContainerError> {
190 if root.join(MANIFEST).is_file() {
191 Ok(())
192 } else {
193 Err(ContainerError::NotAContainer(root.to_path_buf()))
194 }
195 }
196
197 fn attach(root: &Path, now: WallTime) -> Result<Self, ContainerError> {
198 let access = match super::lock::claim(root, now)? {
199 Claim::Held(held) => Access::ReadOnly(ReadOnlyReason::Locked(held)),
200 Claim::Taken(lock) => Access::Writable(Writer {
201 manifest: OpenOptions::new().append(true).open(root.join(MANIFEST))?,
202 lock,
203 }),
204 };
205 Ok(Self {
206 root: root.to_path_buf(),
207 access,
208 })
209 }
210
211 pub fn root(&self) -> &Path {
212 &self.root
213 }
214
215 pub fn access(&self) -> &Access {
216 &self.access
217 }
218
219 pub fn rename(&mut self, to: &Path) -> std::io::Result<()> {
231 if to.exists() {
232 return Err(std::io::Error::new(
233 std::io::ErrorKind::AlreadyExists,
234 format!("{} already exists", to.display()),
235 ));
236 }
237 std::fs::rename(&self.root, to)?;
238 self.root = to.to_path_buf();
242 if let Access::Writable(writer) = &mut self.access {
243 writer.lock.follow(to);
244 }
245 Ok(())
246 }
247
248 pub fn demote(&mut self, reason: ReadOnlyReason) {
251 self.access = Access::ReadOnly(reason);
252 }
253
254 pub fn read_manifest(&self) -> std::io::Result<String> {
257 std::fs::read_to_string(self.root.join(MANIFEST))
258 }
259
260 pub fn assets(&self) -> assets::Dir {
262 assets::Dir::at(&self.root)
263 }
264
265 pub fn revs(&self) -> revs::Dir {
267 revs::Dir::at(&self.root)
268 }
269
270 pub fn write_rev(&mut self, rev: Rev, document: &Document) -> Result<Digest, WriteRefusal> {
282 let Access::Writable(_) = &self.access else {
283 return Err(WriteRefusal::ReadOnly);
284 };
285 Ok(revs::write(
286 &mut self.revs(),
287 rev,
288 document,
289 &mut self.assets(),
290 )?)
291 }
292
293 pub fn read_rev(&self, rev: Rev) -> Result<Document, revs::RevFault> {
299 let document = revs::read(&self.revs(), rev)?;
300 Ok(revs::attached(document, &self.assets())?)
301 }
302
303 pub fn append(&mut self, row: &Row) -> Result<(), WriteRefusal> {
312 let Access::Writable(writer) = &mut self.access else {
313 return Err(WriteRefusal::ReadOnly);
314 };
315 let mut line = row.canonical_bytes();
316 line.push(b'\n');
317 writer.manifest.write_all(&line)?;
318 writer.manifest.sync_data()?;
319 Ok(())
320 }
321
322 pub fn truncate_manifest(&mut self, bytes: u64) -> Result<(), WriteRefusal> {
328 let Access::Writable(writer) = &mut self.access else {
329 return Err(WriteRefusal::ReadOnly);
330 };
331 writer.manifest.set_len(bytes)?;
332 writer.manifest.sync_all()?;
333 Ok(())
334 }
335}
336
337#[derive(Debug, thiserror::Error)]
338pub enum WriteRefusal {
339 #[error("this diagram is open read-only")]
340 ReadOnly,
341 #[error(transparent)]
342 Io(#[from] std::io::Error),
343}
344
345#[derive(Clone, Copy, PartialEq, Eq, Debug)]
347pub enum Discarded {
348 Removed,
349 Kept,
352}
353
354const LAID_DOWN: [&str; 4] = [MANIFEST, PROJECTION, LOCK, GITATTRIBUTES];
357
358const LAID_DOWN_DIRS: [&str; 2] = [ASSETS, REVS];
361
362pub fn discard_pristine(root: &Path) -> std::io::Result<Discarded> {
375 if std::fs::metadata(root.join(MANIFEST))?.len() != 0 {
376 return Ok(Discarded::Kept);
377 }
378 let mut files = Vec::new();
379 let mut dirs = Vec::new();
380 for entry in std::fs::read_dir(root)? {
381 let entry = entry?;
382 let name = entry.file_name();
383 let named = |ours: &[&str]| name.to_str().is_some_and(|name| ours.contains(&name));
384 let kind = entry.file_type()?;
385 if kind.is_dir() && named(&LAID_DOWN_DIRS) {
386 if std::fs::read_dir(entry.path())?.next().is_some() {
387 return Ok(Discarded::Kept);
388 }
389 dirs.push(entry.path());
390 } else if kind.is_file() && named(&LAID_DOWN) {
391 files.push(entry.path());
392 } else {
393 return Ok(Discarded::Kept);
394 }
395 }
396 for file in files {
397 std::fs::remove_file(file)?;
398 }
399 for dir in dirs {
400 std::fs::remove_dir(dir)?;
401 }
402 std::fs::remove_dir(root)?;
403 Ok(Discarded::Removed)
404}
405
406#[cfg(unix)]
409fn sync_dir(dir: &Path) -> std::io::Result<()> {
410 File::open(dir)?.sync_all()
411}
412
413#[cfg(not(unix))]
415fn sync_dir(_dir: &Path) -> std::io::Result<()> {
416 Ok(())
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::store::tests::fixture;
423
424 #[test]
425 fn a_created_container_holds_the_whole_template() {
426 let dir = fixture::dir("container-layout");
427 let root = dir.join("doc.bwx");
428 let container =
429 Container::create(&root, WallTime::EPOCH).expect("the container is laid out");
430
431 assert!(matches!(container.access(), Access::Writable(_)));
432 assert_eq!(container.read_manifest().expect("an empty manifest"), "");
433 assert!(root.join(ASSETS).is_dir());
434 assert!(root.join(REVS).is_dir(), "a new container has no revs/");
435 assert!(
436 root.join(LOCK).is_file(),
437 "a writable handle holds the lock"
438 );
439 let attributes =
440 std::fs::read_to_string(root.join(GITATTRIBUTES)).expect("the git template");
441 assert!(attributes.contains("manifest.jsonl merge=conflict-always"));
442 assert!(attributes.contains("document.json linguist-generated=true"));
443 assert!(attributes.contains("revs/** binary"));
444 }
445
446 #[test]
447 fn creating_over_an_existing_container_is_refused() {
448 let dir = fixture::dir("container-exists");
449 let root = dir.join("doc.bwx");
450 let first = Container::create(&root, WallTime::EPOCH).expect("the first is laid out");
451 drop(first);
452
453 assert!(matches!(
454 Container::create(&root, WallTime::EPOCH),
455 Err(ContainerError::Exists(_)),
456 ));
457 assert!(matches!(
458 Container::open(&dir.join("nothing-here"), WallTime::EPOCH),
459 Err(ContainerError::NotAContainer(_)),
460 ));
461 }
462
463 #[test]
466 fn dropping_a_container_releases_its_lock() {
467 let dir = fixture::dir("container-lock-lifetime");
468 let root = dir.join("doc.bwx");
469 let container = Container::create(&root, WallTime::EPOCH).expect("the container");
470 assert!(matches!(
471 Container::open(&root, WallTime::EPOCH)
472 .expect("a second handle opens")
473 .access(),
474 Access::ReadOnly(ReadOnlyReason::Locked(_)),
475 ));
476
477 drop(container);
478 assert!(matches!(
479 Container::open(&root, WallTime::EPOCH)
480 .expect("a third handle opens")
481 .access(),
482 Access::Writable(_),
483 ));
484 }
485
486 #[test]
491 fn a_renamed_container_keeps_its_lock_and_its_open_manifest() {
492 let dir = fixture::dir("container-rename");
493 let root = dir.join("doc.bwx");
494 let renamed = dir.join("engine.bwx");
495 let mut container = Container::create(&root, WallTime::EPOCH).expect("the container");
496 let row = fixture::rows(1).pop().expect("one row");
497 container.append(&row).expect("the first row lands");
498
499 container.rename(&renamed).expect("the container moves");
500
501 assert_eq!(container.root(), renamed);
502 assert!(!root.exists(), "the old name still stands");
503 assert!(
504 renamed.join(LOCK).is_file(),
505 "the lock did not travel with the container",
506 );
507 container.append(&row).expect("an append after the rename");
508 assert_eq!(
509 std::fs::read_to_string(renamed.join(MANIFEST))
510 .expect("the renamed manifest")
511 .lines()
512 .count(),
513 2,
514 "the session kept appending to the file it had open",
515 );
516 assert!(
517 matches!(
518 Container::open(&renamed, WallTime::EPOCH)
519 .expect("a second handle")
520 .access(),
521 Access::ReadOnly(ReadOnlyReason::Locked(_)),
522 ),
523 "the lock was dropped somewhere in the move",
524 );
525
526 drop(container);
527 assert!(
528 !renamed.join(LOCK).exists(),
529 "releasing the lock removed the wrong path",
530 );
531 }
532
533 #[test]
534 fn a_rename_onto_something_that_exists_is_refused() {
535 let dir = fixture::dir("container-rename-onto");
536 let root = dir.join("doc.bwx");
537 let taken = dir.join("taken.bwx");
538 let mut container = Container::create(&root, WallTime::EPOCH).expect("the container");
539 Container::create(&taken, WallTime::EPOCH).expect("the container in the way");
540
541 let refusal = container
542 .rename(&taken)
543 .expect_err("renaming over a container must be refused");
544 assert_eq!(refusal.kind(), std::io::ErrorKind::AlreadyExists);
545 assert_eq!(container.root(), root, "the store moved anyway");
546 assert!(
547 taken.join(MANIFEST).is_file(),
548 "the container in the way is gone"
549 );
550 }
551
552 #[test]
556 fn only_a_container_with_nothing_in_it_is_discarded() {
557 let dir = fixture::dir("container-discard");
558
559 let empty = dir.join("empty.bwx");
560 drop(Container::create(&empty, WallTime::EPOCH).expect("the empty container"));
561 assert_eq!(
562 discard_pristine(&empty).expect("the sweep answers"),
563 Discarded::Removed,
564 );
565 assert!(!empty.exists(), "a pristine container was left behind");
566
567 let written = dir.join("written.bwx");
568 let mut container = Container::create(&written, WallTime::EPOCH).expect("the container");
569 let row = fixture::rows(1).pop().expect("one row");
570 container.append(&row).expect("the row lands");
571 drop(container);
572 assert_eq!(
573 discard_pristine(&written).expect("the sweep answers"),
574 Discarded::Kept,
575 "a container with a row in it was removed",
576 );
577 assert!(written.join(MANIFEST).is_file());
578
579 let theirs = dir.join("theirs.bwx");
580 drop(Container::create(&theirs, WallTime::EPOCH).expect("the container"));
581 std::fs::write(theirs.join("notes.txt"), b"mine").expect("a file of the user's own");
582 assert_eq!(
583 discard_pristine(&theirs).expect("the sweep answers"),
584 Discarded::Kept,
585 "a directory holding something we did not write was removed",
586 );
587 assert!(theirs.join(MANIFEST).is_file(), "and it was half-removed");
588 }
589
590 #[test]
591 fn a_read_only_handle_refuses_to_write() {
592 let dir = fixture::dir("container-read-only");
593 let root = dir.join("doc.bwx");
594 let _held = Container::create(&root, WallTime::EPOCH).expect("the container");
595 let mut second = Container::open(&root, WallTime::EPOCH).expect("a second handle");
596
597 let row = fixture::rows(1).pop().expect("one row");
598 assert!(matches!(second.append(&row), Err(WriteRefusal::ReadOnly)));
599 assert!(matches!(
600 second.truncate_manifest(0),
601 Err(WriteRefusal::ReadOnly)
602 ));
603 }
604}