1use 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
19pub 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
29pub 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
47pub 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
55pub 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
68pub fn renamed_beside(root: &Path, to: &Name) -> PathBuf {
72 root.with_file_name(to.as_str())
73}
74
75pub fn open_container(root: &Path) -> Result<Store<Any>, ContainerError> {
85 Store::open(Any::new(Native::at(root)), Clock::System)
86}
87
88pub 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#[derive(Debug, thiserror::Error)]
98pub enum ArchiveFailure {
99 #[error("its name names no diagram")]
100 Unnamed,
101 #[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
112pub 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
139pub 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
152pub 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 #[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 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 #[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 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 #[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}