Skip to main content

blockworx_store/
file.rs

1//! What a path names: whether it is a diagram, and where one is saved or
2//! renamed to. A diagram is a container, which is a directory.
3//!
4//! Native only — the browser has no filesystem to open a container from.
5//!
6//! Everything here is a plain function over paths, so the flow is testable
7//! without a dialog; the pickers that deliver a path live in the shell.
8
9use std::path::{Path, PathBuf};
10
11use blockworx_doc::{commit::Commit, rev::Rev};
12
13use crate::container::{ContainerError, MANIFEST};
14use crate::handle::{Clock, SeedFailure, Store};
15use crate::prefix::PrefixFailure;
16use crate::record::Identity;
17use crate::storage::{Any, CONTAINER_EXTENSION, Name, Native};
18
19/// Whether `path` names a container rather than a plain file. The extension
20/// is the fast answer; the manifest inside is the true one, so a container
21/// someone renamed is still opened as one.
22pub fn names_a_container(path: &Path) -> bool {
23    let named = path
24        .extension()
25        .is_some_and(|ext| ext.eq_ignore_ascii_case(CONTAINER_EXTENSION));
26    named || path.join(MANIFEST.as_str()).is_file()
27}
28
29/// Why a picked folder is not a diagram, or `None` where it is one.
30///
31/// The picker cannot filter folders by suffix, so this is where the choice
32/// is judged: a diagram is a `.bwx` directory (or one someone renamed that
33/// still holds a manifest). A lone `.json` document is not one and can never
34/// be — it carries neither the revs nor the assets — so a folder full of them
35/// is refused by name rather than opened into a session that would lose half
36/// of itself.
37pub fn refused_as_a_diagram(picked: &Path) -> Option<String> {
38    if names_a_container(picked) {
39        return None;
40    }
41    Some(format!(
42        "{} is not a diagram \u{2014} open a .{CONTAINER_EXTENSION} folder",
43        container_name(picked),
44    ))
45}
46
47/// What the chrome calls an open container: the directory's own name.
48pub fn container_name(root: &Path) -> String {
49    root.file_name().map_or_else(
50        || root.display().to_string(),
51        |n| n.to_string_lossy().into_owned(),
52    )
53}
54
55/// A picked save location as a container path. The suffix is the format's
56/// name, so a path without one gets it — appended rather than substituted,
57/// since a document called `notes.v2` should not become `notes.bwx`.
58pub fn as_container_path(picked: &Path) -> PathBuf {
59    if names_a_container(picked) {
60        return picked.to_path_buf();
61    }
62    let mut name = picked.as_os_str().to_os_string();
63    name.push(".");
64    name.push(CONTAINER_EXTENSION);
65    PathBuf::from(name)
66}
67
68/// Where the container at `root` stands once its document is renamed to
69/// `to`: the same directory, a new name. What the recent list is told,
70/// after the store has done the renaming.
71pub fn renamed_beside(root: &Path, to: &Name) -> PathBuf {
72    root.with_file_name(to.as_str())
73}
74
75/// Open the container at `root`.
76///
77/// A lock someone else holds, a manifest that does not verify, and a failed write
78/// are *not* failures here: the container opens read-only carrying its
79/// reason, which the chrome shows. Only a path that is no container at all,
80/// or one that cannot be read, comes back as an error.
81///
82/// # Errors
83/// As [`Store::open`].
84pub fn open_container(root: &Path) -> Result<Store<Any>, ContainerError> {
85    Store::open(Any::new(Native::at(root)), Clock::System)
86}
87
88/// Whether `path` names a packed container, the `.bwx.zip` a container
89/// travels as.
90pub fn names_an_archive(path: &Path) -> bool {
91    path.file_name()
92        .and_then(|file| file.to_str())
93        .is_some_and(|file| file.to_ascii_lowercase().ends_with(".zip"))
94}
95
96/// Why an archive did not open.
97#[derive(Debug, thiserror::Error)]
98pub enum ArchiveFailure {
99    #[error("its name names no diagram")]
100    Unnamed,
101    /// An archive brings a diagram in; it does not overwrite one.
102    #[error("{} already exists; open it, or move it aside to unpack the archive again", .0.display())]
103    Taken(PathBuf),
104    #[error("{0}")]
105    Read(std::io::Error),
106    #[error("{0}")]
107    Unpack(std::io::Error),
108    #[error("{0}")]
109    Open(ContainerError),
110}
111
112/// Lay the archive at `archive` down as the container it carries, beside it,
113/// and open that — `engine.bwx.zip` becomes `engine.bwx` in the same folder.
114/// A container the store cannot open is not one that arrived, so the
115/// half-laid directory is removed rather than left standing.
116///
117/// # Errors
118/// As [`ArchiveFailure`].
119pub fn open_archive(archive: &Path) -> Result<Store<Any>, ArchiveFailure> {
120    let name = archive
121        .file_name()
122        .and_then(|file| file.to_str())
123        .and_then(Name::of_archive)
124        .ok_or(ArchiveFailure::Unnamed)?;
125    let root = archive.with_file_name(name.as_str());
126    if root.exists() {
127        return Err(ArchiveFailure::Taken(root));
128    }
129    let bytes = std::fs::read(archive).map_err(ArchiveFailure::Read)?;
130    let opened = crate::storage::ready_now(crate::transfer::unpack(&Native::at(&root), &bytes))
131        .map_err(ArchiveFailure::Unpack)
132        .and_then(|()| open_container(&root).map_err(ArchiveFailure::Open));
133    if opened.is_err() {
134        let _ = std::fs::remove_dir_all(&root);
135    }
136    opened
137}
138
139/// Lay out a container at `root` holding `commits` — how a *scratch*
140/// session becomes durable, since a scratch session has no manifest to copy.
141///
142/// # Errors
143/// As [`Store::seeded`].
144pub fn create_container(
145    root: &Path,
146    commits: &[Commit],
147    author: &Identity,
148) -> Result<Store<Any>, SeedFailure> {
149    Store::seeded(Any::new(Native::at(root)), Clock::System, commits, author)
150}
151
152/// Save the container at `source` as it stood at `at`, into a new one at
153/// `root` — Save-as, and how a session that already has a manifest makes
154/// another. The lines are copied rather than re-authored, so everything the
155/// records carry comes with them.
156///
157/// # Errors
158/// As [`crate::prefix::save_through`].
159pub fn save_container_through(
160    source: &Path,
161    at: Rev,
162    root: &Path,
163    author: &Identity,
164) -> Result<Store<Any>, PrefixFailure> {
165    crate::prefix::save_through(
166        &Native::at(source),
167        at,
168        Any::new(Native::at(root)),
169        Clock::System,
170        author,
171    )
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::temp::TempDir;
178
179    /// An archive is laid down beside itself as the container it carries and
180    /// opened there — once: a second opening finds that diagram standing and
181    /// refuses rather than overwrite it.
182    #[test]
183    fn an_archive_opens_beside_itself_and_never_over_a_diagram() {
184        use crate::fixture::{author, block_create, commit};
185
186        let dir = TempDir::new("file-archive");
187        let source = dir.join("source.bwx");
188        drop(
189            create_container(
190                &source,
191                &[commit("Drew a block", vec![block_create(1, "Filter")])],
192                &author(),
193            )
194            .expect("a container"),
195        );
196        let packed = crate::storage::ready_now(crate::transfer::pack(&Native::at(&source)))
197            .expect("the archive");
198        let archive = dir.join("engine.bwx.zip");
199        std::fs::write(&archive, packed).expect("the archive on disk");
200        assert!(names_an_archive(&archive));
201        assert!(!names_an_archive(&source));
202
203        let opened = open_archive(&archive).expect("the archive opens");
204        assert!(
205            dir.join("engine.bwx").is_dir(),
206            "the container was not laid down beside the archive"
207        );
208        assert_eq!(opened.document().blocks().count(), 1);
209        drop(opened);
210
211        assert!(
212            matches!(open_archive(&archive), Err(ArchiveFailure::Taken(_))),
213            "a second opening went over the diagram the first laid down",
214        );
215    }
216
217    #[test]
218    fn a_container_is_named_by_its_extension_or_by_the_log_inside_it() {
219        let dir = TempDir::new("file-names");
220        assert!(names_a_container(Path::new("/tmp/doc.bwx")));
221        assert!(names_a_container(Path::new("/tmp/doc.BWX")));
222        assert!(!names_a_container(Path::new("/tmp/doc.json")));
223
224        // A container that was renamed is still a container: the log inside
225        // is what the extension is only a hint about.
226        let renamed = dir.join("renamed");
227        std::fs::create_dir_all(&renamed).expect("the directory");
228        assert!(!names_a_container(&renamed));
229        std::fs::write(renamed.join(MANIFEST.as_str()), "").expect("a manifest");
230        assert!(names_a_container(&renamed));
231    }
232
233    /// The folder picker takes any folder, so the choice is judged
234    /// after the fact — and a folder that is not a diagram is refused by
235    /// name, rather than opened into a session missing its log and its
236    /// assets.
237    #[test]
238    fn a_folder_that_is_not_a_diagram_is_refused_and_says_what_to_pick() {
239        let dir = TempDir::new("file-refusal");
240        let plain = dir.join("just-a-folder");
241        std::fs::create_dir_all(&plain).expect("the directory");
242        let refusal = refused_as_a_diagram(&plain).expect("a plain folder is not a diagram");
243        assert!(
244            refusal.contains("just-a-folder") && refusal.contains(CONTAINER_EXTENSION),
245            "the refusal does not say what was picked or what to pick: {refusal}",
246        );
247        assert!(
248            !refusal.contains(MANIFEST.as_str()),
249            "the refusal answers a question nobody asked: {refusal}",
250        );
251        assert_eq!(
252            refused_as_a_diagram(Path::new("/home/ada/engine.bwx")),
253            None,
254            "a diagram was refused",
255        );
256        // And one someone renamed, which the log inside still makes a
257        // diagram — the same rule `names_a_container` uses.
258        let renamed = dir.join("renamed");
259        std::fs::create_dir_all(&renamed).expect("the directory");
260        std::fs::write(renamed.join(MANIFEST.as_str()), "").expect("a manifest");
261        assert_eq!(refused_as_a_diagram(&renamed), None);
262    }
263
264    #[test]
265    fn a_save_location_gains_the_container_extension_without_losing_its_name() {
266        assert_eq!(
267            as_container_path(Path::new("/tmp/notes")),
268            PathBuf::from("/tmp/notes.bwx"),
269        );
270        assert_eq!(
271            as_container_path(Path::new("/tmp/notes.v2")),
272            PathBuf::from("/tmp/notes.v2.bwx"),
273            "the suffix is appended, so a dotted name keeps all of itself",
274        );
275        assert_eq!(
276            as_container_path(Path::new("/tmp/notes.bwx")),
277            PathBuf::from("/tmp/notes.bwx"),
278        );
279    }
280
281    /// A rename moves the container beside itself and nowhere else: the
282    /// box names a document, not a destination. Which names are names at
283    /// all is [`Name::of_document`]'s to say, and is tested there.
284    #[test]
285    fn a_rename_stays_in_the_directory_the_document_is_already_in() {
286        let root = Path::new("/home/ada/work/happy-sunshine-fox.bwx");
287        assert_eq!(
288            renamed_beside(
289                root,
290                &Name::of_document("motor-controller").expect("a name")
291            ),
292            PathBuf::from("/home/ada/work/motor-controller.bwx"),
293        );
294    }
295}