| 1 | use super::*; |
| 2 | use crate::{Error, discover}; |
| 3 | use onestore::Transaction; |
| 4 | use std::{ |
| 5 | collections::{BTreeMap, BTreeSet}, |
| 6 | sync::Mutex, |
| 7 | }; |
| 8 | |
| 9 | fn mapping(name: &str, shape: u16, art: &str, mapped: u64) -> TagMapping { |
| 10 | TagMapping { |
| 11 | name: name.into(), |
| 12 | shape, |
| 13 | art: art_name(art.as_bytes(), "png"), |
| 14 | mapped, |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | #[test] |
| 19 | fn merging_keeps_every_tag_and_each_tags_later_mapping() { |
| 20 | let ours = vec![ |
| 21 | mapping("Launch", 13, "rocket", 10), |
| 22 | mapping("Bug", 17, "bug", 5), |
| 23 | ]; |
| 24 | let theirs = vec![ |
| 25 | mapping("Launch", 13, "flame", 20), |
| 26 | mapping("Launch", 36, "circle", 1), |
| 27 | mapping("Bug", 17, "beetle", 5), |
| 28 | ]; |
| 29 | let mut here = ours.clone(); |
| 30 | merge(&mut here, theirs.clone()); |
| 31 | let mut there = theirs; |
| 32 | merge(&mut there, ours); |
| 33 | let sorted = |mut mappings: Vec<TagMapping>| { |
| 34 | mappings.sort_by(|a, b| (&a.name, a.shape).cmp(&(&b.name, b.shape))); |
| 35 | mappings |
| 36 | }; |
| 37 | assert_eq!(sorted(here.clone()), sorted(there)); |
| 38 | let art = |name: &str, shape| { |
| 39 | here.iter() |
| 40 | .find(|mapping| mapping.name == name && mapping.shape == shape) |
| 41 | .map(|mapping| mapping.art.clone()) |
| 42 | }; |
| 43 | assert_eq!(here.len(), 3); |
| 44 | assert_eq!(art("Launch", 13), Some(art_name(b"flame", "png"))); |
| 45 | assert_eq!(art("Launch", 36), Some(art_name(b"circle", "png"))); |
| 46 | // Mapped at once, the greater name wins on either side. |
| 47 | let tied = [art_name(b"bug", "png"), art_name(b"beetle", "png")]; |
| 48 | assert_eq!(art("Bug", 17).as_ref(), tied.iter().max()); |
| 49 | let before = here.clone(); |
| 50 | merge(&mut here, before.clone()); |
| 51 | assert_eq!(here, before); |
| 52 | } |
| 53 | |
| 54 | /// A notebook folder in memory, as a share would hold it. |
| 55 | #[derive(Default)] |
| 56 | struct Folder { |
| 57 | files: Mutex<BTreeMap<String, Vec<u8>>>, |
| 58 | folders: Mutex<BTreeSet<String>>, |
| 59 | hidden: Mutex<BTreeSet<String>>, |
| 60 | /// Pictures renamed into place. |
| 61 | kept: Mutex<Vec<String>>, |
| 62 | /// A mapping another writer, having read before this one wrote, puts in place just after. |
| 63 | race: Mutex<Option<Vec<u8>>>, |
| 64 | } |
| 65 | |
| 66 | fn missing() -> Error { |
| 67 | io::Error::from(io::ErrorKind::NotFound).into() |
| 68 | } |
| 69 | |
| 70 | impl Folder { |
| 71 | fn parent_exists(&self, path: &str) -> bool { |
| 72 | path.rsplit_once('/') |
| 73 | .is_none_or(|(parent, _)| self.folders.lock().unwrap().contains(parent)) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | impl Storage for Folder { |
| 78 | fn discover(&self, _: &mut discover::Cache, _: discover::Limits) -> Result<discover::Folder> { |
| 79 | unimplemented!() |
| 80 | } |
| 81 | |
| 82 | fn location(&self) -> String { |
| 83 | "memory".into() |
| 84 | } |
| 85 | |
| 86 | fn entries(&self, _: &str) -> io::Result<Vec<discover::Entry>> { |
| 87 | unimplemented!() |
| 88 | } |
| 89 | |
| 90 | fn stamp(&self, _: &str) -> io::Result<onestore::Stamp> { |
| 91 | unimplemented!() |
| 92 | } |
| 93 | |
| 94 | fn confirm( |
| 95 | &self, |
| 96 | _: &str, |
| 97 | _: &onestore::Stamp, |
| 98 | ) -> std::result::Result<(), onestore::CommitError> { |
| 99 | unimplemented!() |
| 100 | } |
| 101 | |
| 102 | fn exists(&self, path: &str) -> bool { |
| 103 | self.files.lock().unwrap().contains_key(path) || self.folders.lock().unwrap().contains(path) |
| 104 | } |
| 105 | |
| 106 | fn read(&self, _: &str) -> Result<Vec<u8>> { |
| 107 | unimplemented!() |
| 108 | } |
| 109 | |
| 110 | fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> { |
| 111 | let bytes = self |
| 112 | .files |
| 113 | .lock() |
| 114 | .unwrap() |
| 115 | .get(path) |
| 116 | .cloned() |
| 117 | .ok_or_else(missing)?; |
| 118 | assert!(bytes.len() <= limit); |
| 119 | Ok(bytes) |
| 120 | } |
| 121 | |
| 122 | fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { |
| 123 | if !self.parent_exists(path) { |
| 124 | return Err(missing()); |
| 125 | } |
| 126 | let mut files = self.files.lock().unwrap(); |
| 127 | if files.contains_key(path) { |
| 128 | return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); |
| 129 | } |
| 130 | files.insert(path.into(), bytes.to_vec()); |
| 131 | Ok(()) |
| 132 | } |
| 133 | |
| 134 | fn create_directory(&self, path: &str) -> Result<()> { |
| 135 | if !self.folders.lock().unwrap().insert(path.into()) { |
| 136 | return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); |
| 137 | } |
| 138 | Ok(()) |
| 139 | } |
| 140 | |
| 141 | fn hide(&self, path: &str) -> Result<()> { |
| 142 | assert!(self.exists(path)); |
| 143 | self.hidden.lock().unwrap().insert(path.into()); |
| 144 | Ok(()) |
| 145 | } |
| 146 | |
| 147 | fn rename(&self, from: &str, to: &str) -> Result<()> { |
| 148 | let mut files = self.files.lock().unwrap(); |
| 149 | if files.contains_key(to) { |
| 150 | return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); |
| 151 | } |
| 152 | let bytes = files.remove(from).ok_or_else(missing)?; |
| 153 | files.insert(to.into(), bytes); |
| 154 | self.kept.lock().unwrap().push(to.into()); |
| 155 | Ok(()) |
| 156 | } |
| 157 | |
| 158 | fn rename_root(&self, _: &str, _: &[String]) -> Result<String> { |
| 159 | unreachable!() |
| 160 | } |
| 161 | |
| 162 | fn replace(&self, from: &str, to: &str) -> Result<()> { |
| 163 | let mut files = self.files.lock().unwrap(); |
| 164 | let bytes = files.remove(from).ok_or_else(missing)?; |
| 165 | files.insert(to.into(), bytes); |
| 166 | if let Some(theirs) = self.race.lock().unwrap().take() { |
| 167 | files.insert(to.into(), theirs); |
| 168 | } |
| 169 | Ok(()) |
| 170 | } |
| 171 | |
| 172 | fn delete(&self, path: &str) -> Result<()> { |
| 173 | self.files |
| 174 | .lock() |
| 175 | .unwrap() |
| 176 | .remove(path) |
| 177 | .ok_or_else(missing)?; |
| 178 | Ok(()) |
| 179 | } |
| 180 | |
| 181 | fn place(&self, _: &str, _: [u8; 16], _: &str) -> Result<()> { |
| 182 | unimplemented!() |
| 183 | } |
| 184 | |
| 185 | fn commit(&self, _: &str, _: &Transaction) -> Result<()> { |
| 186 | unimplemented!() |
| 187 | } |
| 188 | |
| 189 | fn supersede(&self, _: &str, _: &onestore::Stamp, _: &str) -> Result<()> { |
| 190 | unimplemented!() |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn mapping_hides_the_folder_and_keeps_each_picture_once() { |
| 196 | let folder = Folder::default(); |
| 197 | assert!(mappings(&folder).unwrap().is_empty()); |
| 198 | let mapped = map(&folder, "Launch", 13, b"rocket", "png").unwrap(); |
| 199 | assert_eq!(mapped.len(), 1); |
| 200 | assert_eq!( |
| 201 | *folder.hidden.lock().unwrap(), |
| 202 | BTreeSet::from([FOLDER.to_owned()]) |
| 203 | ); |
| 204 | let rocket = art_name(b"rocket", "png"); |
| 205 | assert_eq!(art(&folder, &rocket).unwrap(), b"rocket"); |
| 206 | // A second tag with the same picture, and a drawing. |
| 207 | map(&folder, "Ship it", 127, b"rocket", "png").unwrap(); |
| 208 | let mapped = map(&folder, "Idea", 21, b"<svg/>", "svg").unwrap(); |
| 209 | assert_eq!( |
| 210 | mapped |
| 211 | .iter() |
| 212 | .map(|mapping| (mapping.name.as_str(), mapping.shape)) |
| 213 | .collect::<Vec<_>>(), |
| 214 | [("Launch", 13), ("Ship it", 127), ("Idea", 21)] |
| 215 | ); |
| 216 | assert_eq!( |
| 217 | *folder.kept.lock().unwrap(), |
| 218 | [ |
| 219 | format!("{ART}/{rocket}"), |
| 220 | format!("{ART}/{}", art_name(b"<svg/>", "svg")) |
| 221 | ] |
| 222 | ); |
| 223 | // Only the pictures and the mapping are left: no written file stays behind. |
| 224 | let files: Vec<_> = folder.files.lock().unwrap().keys().cloned().collect(); |
| 225 | assert_eq!(files.len(), 3); |
| 226 | assert!(files.iter().all(|file| !file.ends_with(".tmp"))); |
| 227 | // The same tag mapped again takes the new picture. |
| 228 | let mapped = map(&folder, "Launch", 13, b"flame", "png").unwrap(); |
| 229 | assert_eq!(mapped[0].art, art_name(b"flame", "png")); |
| 230 | assert_eq!(mappings(&folder).unwrap(), mapped); |
| 231 | } |
| 232 | |
| 233 | /// Another writer read the mapping before this one replaced it and replaced it just after, |
| 234 | /// without this one's tag: this one reads it back and merges again. |
| 235 | #[test] |
| 236 | fn a_writer_overtaken_merges_again() { |
| 237 | let folder = Folder::default(); |
| 238 | map(&folder, "Theirs before", 13, b"one", "png").unwrap(); |
| 239 | let theirs = vec![ |
| 240 | mappings(&folder).unwrap()[0].clone(), |
| 241 | mapping("Theirs", 17, "two", crate::now()), |
| 242 | ]; |
| 243 | *folder.race.lock().unwrap() = Some(serde_json::to_vec(&theirs).unwrap()); |
| 244 | let mapped = map(&folder, "Ours", 21, b"three", "png").unwrap(); |
| 245 | let names: BTreeSet<_> = mapped.iter().map(|mapping| mapping.name.as_str()).collect(); |
| 246 | assert_eq!(names, BTreeSet::from(["Theirs before", "Theirs", "Ours"])); |
| 247 | assert!(folder.race.lock().unwrap().is_none()); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn unreadable_mappings_and_pictures_are_passed_over() { |
| 252 | let folder = Folder::default(); |
| 253 | map(&folder, "Launch", 13, b"rocket", "png").unwrap(); |
| 254 | let rocket = art_name(b"rocket", "png"); |
| 255 | let entry = |art: &str| serde_json::json!({"name": "Odd", "shape": 1, "art": art, "mapped": 1}); |
| 256 | let stored = serde_json::json!([ |
| 257 | mappings(&folder).unwrap()[0], |
| 258 | entry("../../Open Notebook.onetoc2"), |
| 259 | entry(&rocket.replace(".png", ".exe")), |
| 260 | {"name": "No art"}, |
| 261 | 7, |
| 262 | ]); |
| 263 | folder |
| 264 | .files |
| 265 | .lock() |
| 266 | .unwrap() |
| 267 | .insert(MAPPING.into(), stored.to_string().into_bytes()); |
| 268 | assert_eq!(mappings(&folder).unwrap().len(), 1); |
| 269 | folder |
| 270 | .files |
| 271 | .lock() |
| 272 | .unwrap() |
| 273 | .insert(MAPPING.into(), b"{not json".to_vec()); |
| 274 | assert!(mappings(&folder).unwrap().is_empty()); |
| 275 | let kind = |result: Result<Vec<u8>>| match result { |
| 276 | Err(Error::Io(error)) => error.kind(), |
| 277 | other => panic!("{other:?}"), |
| 278 | }; |
| 279 | assert_eq!( |
| 280 | kind(art(&folder, "../tags.json")), |
| 281 | io::ErrorKind::InvalidInput |
| 282 | ); |
| 283 | folder |
| 284 | .files |
| 285 | .lock() |
| 286 | .unwrap() |
| 287 | .insert(format!("{ART}/{rocket}"), b"rock".to_vec()); |
| 288 | assert_eq!(kind(art(&folder, &rocket)), io::ErrorKind::InvalidData); |
| 289 | assert!(map(&folder, "Tag", 13, b"x", "gif").is_err()); |
| 290 | } |
| 291 | |
| 292 | /// On this computer the folder takes the hidden attribute Windows or macOS keeps, and the |
| 293 | /// notebook's catalog never lists it. |
| 294 | #[test] |
| 295 | fn a_notebook_folder_keeps_the_art_hidden() { |
| 296 | let temporary = tempfile::tempdir().unwrap(); |
| 297 | let root = temporary.path().join("Notebook"); |
| 298 | let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap(); |
| 299 | let mut notebook = |
| 300 | crate::session::Notebook::create(&root, temporary.path().join("cache"), 0x00f0_c090, &page) |
| 301 | .unwrap(); |
| 302 | let mapped = notebook |
| 303 | .map_tag_art("Launch", 13, b"rocket", "png") |
| 304 | .unwrap(); |
| 305 | assert_eq!(notebook.tag_art().unwrap(), mapped); |
| 306 | assert_eq!(notebook.tag_art_file(&mapped[0].art).unwrap(), b"rocket"); |
| 307 | let folder = root.join(FOLDER); |
| 308 | assert!(folder.join("tags.json").is_file()); |
| 309 | #[cfg(target_vendor = "apple")] |
| 310 | { |
| 311 | use nix::sys::stat::{FileFlag, stat}; |
| 312 | let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags); |
| 313 | assert!(flags.contains(FileFlag::UF_HIDDEN)); |
| 314 | } |
| 315 | #[cfg(windows)] |
| 316 | { |
| 317 | use std::os::windows::fs::MetadataExt; |
| 318 | // FILE_ATTRIBUTE_HIDDEN. |
| 319 | assert_ne!(folder.metadata().unwrap().file_attributes() & 2, 0); |
| 320 | } |
| 321 | notebook.refresh().unwrap(); |
| 322 | assert!(notebook.catalog().groups.is_empty()); |
| 323 | assert_eq!(notebook.catalog().sections.len(), 1); |
| 324 | } |
| 325 | |
| 326 | /// Themes go in the hidden folder beside the tag art, and a writer another overtook merges |
| 327 | /// again until its change holds; entries it can't read are passed over, built-ins refused. |
| 328 | #[test] |
| 329 | fn themes_merge_into_the_hidden_folder() { |
| 330 | use themes::{Assignment, Scope, Themes}; |
| 331 | let folder = Folder::default(); |
| 332 | let assign = |scope, theme: &str, assigned| Themes { |
| 333 | assignments: vec![Assignment { |
| 334 | scope, |
| 335 | theme: Some(theme.into()), |
| 336 | assigned, |
| 337 | }], |
| 338 | ..Default::default() |
| 339 | }; |
| 340 | let theirs = assign(Scope::Notebook, "modern", 1); |
| 341 | *folder.race.lock().unwrap() = Some(serde_json::to_vec(&theirs).unwrap()); |
| 342 | let kept = themes::write(&folder, assign(Scope::section([7; 16]), "editorial", 2)).unwrap(); |
| 343 | assert!(folder.hidden.lock().unwrap().contains(".snowbound")); |
| 344 | assert_eq!(kept.assignments.len(), 2); |
| 345 | assert_eq!(kept, themes::read(&folder).unwrap()); |
| 346 | assert_eq!(kept.effective(Some([7; 16]), None).unwrap().id, "editorial"); |
| 347 | folder.files.lock().unwrap().insert( |
| 348 | ".snowbound/themes.json".into(), |
| 349 | br#"{"themes": [{"id": 3}], "assignments": [{"scope": "notebook", "theme": "manuscript", "assigned": 4}, "junk"]}"#.to_vec(), |
| 350 | ); |
| 351 | let read = themes::read(&folder).unwrap(); |
| 352 | assert!(read.themes.is_empty()); |
| 353 | assert_eq!(read.effective(None, None).unwrap().id, "manuscript"); |
| 354 | let built_in = Themes { |
| 355 | themes: vec![themes::built_in().remove(0)], |
| 356 | ..Default::default() |
| 357 | }; |
| 358 | assert!(themes::write(&folder, built_in).is_err()); |
| 359 | } |
| 360 | |
| 361 | /// The presence room's secret is made once, in the hidden folder, and every reader after |
| 362 | /// takes it; a writer that finds one made meanwhile takes that one. |
| 363 | #[test] |
| 364 | fn the_presence_room_is_made_once() { |
| 365 | let folder = Folder::default(); |
| 366 | let secret = room(&folder).unwrap(); |
| 367 | assert_eq!(room(&folder).unwrap(), secret); |
| 368 | assert!(folder.hidden.lock().unwrap().contains(FOLDER)); |
| 369 | let files: Vec<_> = folder.files.lock().unwrap().keys().cloned().collect(); |
| 370 | assert_eq!(files, [ROOM]); |
| 371 | let theirs = br#"{"room":"000102030405060708090a0b0c0d0e0f"}"#; |
| 372 | let other = Folder::default(); |
| 373 | other.folders.lock().unwrap().insert(FOLDER.into()); |
| 374 | other |
| 375 | .files |
| 376 | .lock() |
| 377 | .unwrap() |
| 378 | .insert(ROOM.into(), theirs.to_vec()); |
| 379 | assert_eq!(room(&other).unwrap(), std::array::from_fn(|at| at as u8)); |
| 380 | } |