From 857ba3972b6f96181979f2442db5c7592e4ed5d0 Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:20:04 +0530 Subject: [PATCH 1/4] Don't copy a library on every save when its format keeps no title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An .ase file reads its name from the file name, so COM¹ saved again looked like another library and got a new numbered file each time. --- crates/engine/src/cmd/swatchlib.rs | 11 +++++++---- crates/engine/src/tests/swatchlib.rs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 9ecec6c67..39ce50a29 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -218,10 +218,13 @@ impl Libraries { let device = crate::file_access::is_device_name; let id = format!("user/{file}").to_lowercase(); let fallback = |other: &str| library_file_name(other, ext).to_lowercase() != file.to_lowercase(); - if self - .extra - .iter() - .any(|e| e.info.id.to_lowercase() == id && e.lib.name() != name && (device(name) || device(e.lib.name()) || fallback(e.lib.name()))) + // A format that keeps no title (`.ase`) reads its name from the file name, so the same + // library saved again looks like another one, and only the file name tells them apart. + let titled = L::read(bytes, stem(&file)).is_ok_and(|lib| lib.name() == name); + if titled + && self.extra.iter().any(|e| { + e.info.id.to_lowercase() == id && e.lib.name() != name && (device(name) || device(e.lib.name()) || fallback(e.lib.name())) + }) { // Saved again, it goes back to the file it was given the first time. let own = self.extra.iter().find(|e| e.info.category == "user" && e.lib.name() == name && extension(&e.info.id) == extension(&file)); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 23980c5d5..22d79d432 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -792,6 +792,20 @@ fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() { } } +/// `.ase` keeps no title, so a library named like a device is saved over its own file again, not +/// beside it. +#[test] +fn a_device_named_library_without_a_title_is_not_copied_on_every_save() { + let dir = temp_dir("device-ase"); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let p = json!({"user": true, "name": "COM¹", "format": "ase"}); + let first = run(&mut s, "swatch.library.save", p.clone())["library"].clone(); + assert_eq!(run(&mut s, "swatch.library.save", p)["library"], first); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3602: a library titled with a Windows device name is saved under a name that is not one. #[test] fn a_library_file_name_is_never_a_windows_device_name() { From a17efec894f3e313aa55bba9d9d2e9560f3b6ffc Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:42:32 +0530 Subject: [PATCH 2/4] Remember the name a library file was saved under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An .ase file can't say whose it is, so without this COM¹ saved after COM¹- wrote over it. The app now remembers while it runs. After a restart COM¹ gets a new file, which costs a copy and loses nothing. --- crates/engine/src/cmd/swatchlib.rs | 23 ++++++++++-------- crates/engine/src/tests/swatchlib.rs | 36 +++++++++++++++++++--------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 39ce50a29..5ff6f3e7d 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -6,7 +6,7 @@ //! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]). //! `swatch.library.copyToUser` copies a loaded library into the user library folder. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -104,6 +104,9 @@ pub struct Libraries { /// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the /// loaded id and the User Defined id [`Libraries::get`] finds for it. copied: Vec<(String, String)>, + /// The name each User Defined file was saved under ([`Libraries::write`]), lower case id first: + /// a format that keeps no title (`.ase`) reads its name from the file name instead. + saved_as: HashMap, } #[derive(Clone, Debug)] @@ -152,6 +155,7 @@ impl Libraries { /// Set the user library folder and list its libraries. pub fn set_user_dir(&mut self, dir: Option) { self.user_dir = dir; + self.saved_as.clear(); self.rescan(); } @@ -218,16 +222,14 @@ impl Libraries { let device = crate::file_access::is_device_name; let id = format!("user/{file}").to_lowercase(); let fallback = |other: &str| library_file_name(other, ext).to_lowercase() != file.to_lowercase(); - // A format that keeps no title (`.ase`) reads its name from the file name, so the same - // library saved again looks like another one, and only the file name tells them apart. - let titled = L::read(bytes, stem(&file)).is_ok_and(|lib| lib.name() == name); - if titled - && self.extra.iter().any(|e| { - e.info.id.to_lowercase() == id && e.lib.name() != name && (device(name) || device(e.lib.name()) || fallback(e.lib.name())) - }) - { + let owner = |e: &Extra| self.saved_as.get(&e.info.id.to_lowercase()).map_or(e.lib.name(), String::as_str).to_string(); + let held = self.extra.iter().any(|e| { + let other = owner(e); + e.info.id.to_lowercase() == id && other != name && (device(name) || device(&other) || fallback(&other)) + }); + if held { // Saved again, it goes back to the file it was given the first time. - let own = self.extra.iter().find(|e| e.info.category == "user" && e.lib.name() == name && extension(&e.info.id) == extension(&file)); + let own = self.extra.iter().find(|e| e.info.category == "user" && owner(e) == name && extension(&e.info.id) == extension(&file)); file = match own.and_then(|e| e.info.id.strip_prefix("user/")) { Some(own) => own.to_string(), None => { @@ -239,6 +241,7 @@ impl Libraries { let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); // The app's own folder, under a name made safe: no root applies. crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?; + self.saved_as.insert(format!("user/{file}").to_lowercase(), name.to_string()); self.rescan(); out["path"] = json!(path); out["library"] = json!(format!("user/{file}")); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 22d79d432..bcf479811 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -792,18 +792,32 @@ fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() { } } -/// `.ase` keeps no title, so a library named like a device is saved over its own file again, not -/// beside it. +/// `.ase` keeps no title, so a library's name comes back from its file name. Whichever is saved +/// first, `COM¹` and `COM¹-` keep a file each, and saving either again replaces only itself. After +/// a restart `COM¹` can't be told from `COM¹-`, and gets a new file, not the other's. #[test] -fn a_device_named_library_without_a_title_is_not_copied_on_every_save() { - let dir = temp_dir("device-ase"); - let mut s = session(); - s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let p = json!({"user": true, "name": "COM¹", "format": "ase"}); - let first = run(&mut s, "swatch.library.save", p.clone())["library"].clone(); - assert_eq!(run(&mut s, "swatch.library.save", p)["library"], first); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - let _ = std::fs::remove_dir_all(&dir); +fn device_named_libraries_without_a_title_keep_their_own_files() { + for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹")] { + let dir = temp_dir("device-ase"); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let save = |s: &mut Session, name: &str| { + run(s, "swatch.library.save", json!({"user": true, "name": name, "format": "ase"}))["library"].as_str().unwrap().to_string() + }; + let a = save(&mut s, first); + assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}"); + let b = save(&mut s, second); + assert_ne!(a, b); + assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}"); + assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + // The next time the app starts. + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let again = save(&mut s, "COM¹"); + assert!(again != a && again != b, "{again}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3); + let _ = std::fs::remove_dir_all(&dir); + } } /// #3602: a library titled with a Windows device name is saved under a name that is not one. From 850fd620f92e9f2c0a619daf6b97a5ab7bce667a Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:45:59 +0530 Subject: [PATCH 3/4] Don't replace an .ase file whose owner isn't known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a restart an .ase file called COM¹- could have been saved as COM¹ or as COM¹-, and the same goes for its numbered files. Such a file is kept until it is saved here, so a library named like that gets a new file, once per run. Other names go on replacing their own file. --- crates/engine/src/cmd/swatchlib.rs | 24 ++++++++++++++++++++---- crates/engine/src/tests/swatchlib.rs | 20 ++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 5ff6f3e7d..4c2fc180e 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -222,14 +222,23 @@ impl Libraries { let device = crate::file_access::is_device_name; let id = format!("user/{file}").to_lowercase(); let fallback = |other: &str| library_file_name(other, ext).to_lowercase() != file.to_lowercase(); - let owner = |e: &Extra| self.saved_as.get(&e.info.id.to_lowercase()).map_or(e.lib.name(), String::as_str).to_string(); + // A format with no title reads its name from the file name, so a file a device name could + // have made has no known owner until it is saved here. + let untitled = L::read(bytes, "\0").is_ok_and(|lib| lib.name() == "\0"); + let owner = |e: &Extra| match self.saved_as.get(&e.info.id.to_lowercase()) { + Some(saved) => Some(saved.clone()), + None if untitled && device_file_name(e.lib.name()) => None, + None => Some(e.lib.name().to_string()), + }; let held = self.extra.iter().any(|e| { - let other = owner(e); - e.info.id.to_lowercase() == id && other != name && (device(name) || device(&other) || fallback(&other)) + e.info.id.to_lowercase() == id && owner(e).is_none_or(|other| other != name && (device(name) || device(&other) || fallback(&other))) }); if held { // Saved again, it goes back to the file it was given the first time. - let own = self.extra.iter().find(|e| e.info.category == "user" && owner(e) == name && extension(&e.info.id) == extension(&file)); + let own = self + .extra + .iter() + .find(|e| e.info.category == "user" && owner(e).as_deref() == Some(name) && extension(&e.info.id) == extension(&file)); file = match own.and_then(|e| e.info.id.strip_prefix("user/")) { Some(own) => own.to_string(), None => { @@ -379,6 +388,13 @@ pub fn library_file_name(name: &str, ext: &str) -> String { } } +/// Is `stem` a file name [`library_file_name`] gives a device name (`COM¹-`, `COM¹-.x`), or one of +/// those with the number [`free_name`] adds (`COM¹- 2`)? +fn device_file_name(stem: &str) -> bool { + let base = stem.rsplit_once(' ').filter(|(_, n)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).map_or(stem, |(base, _)| base); + base.split('.').next().and_then(|first| first.strip_suffix('-')).is_some_and(crate::file_access::is_device_name) +} + /// The library files with extensions `exts` in folder `dir`, sorted by name. #[cfg(not(target_arch = "wasm32"))] fn library_files(dir: &str, exts: &[&str]) -> Vec { diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index bcf479811..17353f86d 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -794,7 +794,7 @@ fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() { /// `.ase` keeps no title, so a library's name comes back from its file name. Whichever is saved /// first, `COM¹` and `COM¹-` keep a file each, and saving either again replaces only itself. After -/// a restart `COM¹` can't be told from `COM¹-`, and gets a new file, not the other's. +/// a restart neither can be told from the other, so each gets a new file and replaces neither. #[test] fn device_named_libraries_without_a_title_keep_their_own_files() { for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹")] { @@ -813,9 +813,21 @@ fn device_named_libraries_without_a_title_keep_their_own_files() { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); // The next time the app starts. s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let again = save(&mut s, "COM¹"); - assert!(again != a && again != b, "{again}"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3); + for (name, count) in [(first, 3), (second, 4)] { + let again = save(&mut s, name); + assert!(again != a && again != b, "{name}: {again}"); + assert_eq!(save(&mut s, name), again, "saving {name} again replaces {name}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), count); + } + // The numbered file is as unknown as the first. + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let numbered = b.strip_prefix("user/").unwrap().strip_suffix(".ase").unwrap().to_string(); + assert_ne!(save(&mut s, &numbered), b); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5); + // Any other name goes on replacing its own file after a restart. + let other = save(&mut s, "Greys"); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + assert_eq!(save(&mut s, "Greys"), other); let _ = std::fs::remove_dir_all(&dir); } } From 4e11389bf0ca54b1c58116b080db8a37641f86f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Nov 2026 17:14:52 +0000 Subject: [PATCH 4/4] Don't write over a library file that changed since it was saved The name remembered for a user library file stays only while the file still holds the bytes that were written. A file a device name could make, which can no longer be read, is kept too, and the save goes to a new file. Co-authored-by: Adithya Shankaran --- crates/engine/src/cmd/swatchlib.rs | 51 ++++++++++++---- crates/engine/src/tests/swatchlib.rs | 88 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 4c2fc180e..eaa5b8c54 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -104,9 +104,19 @@ pub struct Libraries { /// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the /// loaded id and the User Defined id [`Libraries::get`] finds for it. copied: Vec<(String, String)>, - /// The name each User Defined file was saved under ([`Libraries::write`]), lower case id first: - /// a format that keeps no title (`.ase`) reads its name from the file name instead. - saved_as: HashMap, + /// The name each User Defined file was saved under ([`Libraries::write`]), lower case id first, + /// with a hash of the bytes written. A format that keeps no title (`.ase`) reads its name from + /// the file name instead. [`Libraries::rescan`] drops an entry whose file no longer holds those + /// bytes, and keeps one whose file is missing so a file put back can be checked. + saved_as: HashMap, +} + +/// A User Defined file this session wrote: the library's name, and [`vectorcraft_doc::links::hash_bytes`] +/// of what was written, so a file replaced since then is no longer that library's. +#[derive(Clone, Debug)] +struct SavedAs { + name: String, + hash: String, } #[derive(Clone, Debug)] @@ -162,14 +172,23 @@ impl Libraries { /// Re-read the library files of the user library folder (unreadable ones are skipped). The /// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]). Each /// file is read inside [`crate::guard::catch_panic`], and a file whose reading panics is - /// skipped too. + /// skipped too. A file this session saved, whose bytes are no longer the ones written, is + /// forgotten ([`Libraries::write`]); a missing file is left remembered until it is back. pub fn rescan(&mut self) { self.extra.retain(|e| e.info.category != "user"); let Some(dir) = self.user_dir.clone() else { return }; for path in library_files(&dir, L::EXTS) { let file = file_name(&path); - let read = || crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok()); - let Some(lib) = crate::guard::catch_panic(read).ok().flatten() else { continue }; + let read = || crate::file_access::unconfined(|| read_file(&path)).ok(); + let Some(bytes) = crate::guard::catch_panic(read).ok().flatten() else { continue }; + let id = format!("user/{file}").to_lowercase(); + // Compared before parsing, so a file that no longer reads as a library is forgotten too. + let hash = vectorcraft_doc::links::hash_bytes(&bytes); + if self.saved_as.get(&id).is_some_and(|saved| saved.hash != hash) { + self.saved_as.remove(&id); + } + let parsed = || L::read(&bytes, stem(&file)).ok(); + let Some(lib) = crate::guard::catch_panic(parsed).ok().flatten() else { continue }; let info = LibraryInfo { id: format!("user/{file}"), name: library_tab_name(&file, lib.name()), category: "user" }; self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); } @@ -218,6 +237,9 @@ impl Libraries { let mut file = library_file_name(name, ext); // `COM¹` is saved as `COM¹-`, which can be another library's own name, and a library put // under a numbered name holds a file another name can ask for: such a file is kept. + // `saved_as` is that memory for this run, and only while the file still holds what was + // written. A file that could not be read has no name to trust: one a device name could + // make is kept too. Any other unreadable file is replaced, as it was before. self.rescan(); let device = crate::file_access::is_device_name; let id = format!("user/{file}").to_lowercase(); @@ -226,13 +248,19 @@ impl Libraries { // have made has no known owner until it is saved here. let untitled = L::read(bytes, "\0").is_ok_and(|lib| lib.name() == "\0"); let owner = |e: &Extra| match self.saved_as.get(&e.info.id.to_lowercase()) { - Some(saved) => Some(saved.clone()), + Some(saved) => Some(saved.name.clone()), None if untitled && device_file_name(e.lib.name()) => None, None => Some(e.lib.name().to_string()), }; - let held = self.extra.iter().any(|e| { - e.info.id.to_lowercase() == id && owner(e).is_none_or(|other| other != name && (device(name) || device(&other) || fallback(&other))) - }); + let file_key = file.to_lowercase(); + let on_disk = library_files(&dir, L::EXTS).iter().any(|p| file_name(p).to_lowercase() == file_key); + let listed = self.extra.iter().any(|e| e.info.category == "user" && e.info.id.to_lowercase() == id); + let unread = on_disk && !listed && (device(name) || device_file_name(stem(&file))); + let held = unread + || self.extra.iter().any(|e| { + e.info.id.to_lowercase() == id + && owner(e).is_none_or(|other| other != name && (device(name) || device(&other) || fallback(&other))) + }); if held { // Saved again, it goes back to the file it was given the first time. let own = self @@ -250,7 +278,8 @@ impl Libraries { let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); // The app's own folder, under a name made safe: no root applies. crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?; - self.saved_as.insert(format!("user/{file}").to_lowercase(), name.to_string()); + self.saved_as + .insert(format!("user/{file}").to_lowercase(), SavedAs { name: name.to_string(), hash: vectorcraft_doc::links::hash_bytes(bytes) }); self.rescan(); out["path"] = json!(path); out["library"] = json!(format!("user/{file}")); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 17353f86d..3ecb5e68e 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -832,6 +832,94 @@ fn device_named_libraries_without_a_title_keep_their_own_files() { } } +/// A file this session saved for `COM¹`, then replaced, is not written over. The next save goes to +/// a new file and stays there. That holds for `.ase` and `.gpl`, for bytes that still read as a +/// library and for bytes that don't, including the file the library moved to. A file removed and +/// put back is still its library's when the bytes are the ones written, and is kept when they are +/// not. A name that is not a device name still replaces a file that cannot be read. +#[test] +fn a_replaced_device_library_file_is_kept() { + for format in ["ase", "gpl"] { + let other: Vec = if format == "ase" { + vectorcraft_testkit::ase::ase(&[vectorcraft_testkit::ase::Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]) + } else { + b"GIMP Palette\nName: Other\n0 0 0 Black\n".to_vec() + }; + let save = |s: &mut Session| run(s, "swatch.library.save", json!({"user": true, "name": "COM¹", "format": format})); + let open = |name: &str| { + let dir = temp_dir(name); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + (dir, s) + }; + + // Replaced with another library. + let (dir, mut s) = open(&format!("replaced-lib-{format}")); + let first = save(&mut s); + let first_id = first["library"].as_str().unwrap().to_string(); + let first_path = first["path"].as_str().unwrap().to_string(); + assert_ne!(std::fs::read(&first_path).unwrap(), other); + std::fs::write(&first_path, &other).unwrap(); + let moved = save(&mut s); + let moved_id = moved["library"].as_str().unwrap().to_string(); + assert_ne!(moved_id, first_id); + assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: the replaced file is left as it is"); + assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again replaces the new file"); + let _ = std::fs::remove_dir_all(&dir); + + // Replaced with bytes that are not a library, first the file it had, then the one it moved to. + let (dir, mut s) = open(&format!("replaced-unread-{format}")); + let first = save(&mut s); + let first_path = first["path"].as_str().unwrap().to_string(); + std::fs::write(&first_path, b"not a library").unwrap(); + let moved = save(&mut s); + let moved_id = moved["library"].as_str().unwrap().to_string(); + let first_id = first["library"].as_str().unwrap(); + assert_ne!(moved_id, first_id); + assert_eq!(std::fs::read(&first_path).unwrap(), b"not a library"); + assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again stays on the new file"); + let moved_path = moved["path"].as_str().unwrap().to_string(); + std::fs::write(&moved_path, b"still not").unwrap(); + let again = save(&mut s); + let again_id = again["library"].as_str().unwrap().to_string(); + assert!(again_id != moved_id && again_id != first_id); + assert_eq!(std::fs::read(&moved_path).unwrap(), b"still not"); + assert_eq!(save(&mut s)["library"].as_str(), Some(again_id.as_str())); + let _ = std::fs::remove_dir_all(&dir); + + // Removed, the folder listed again, then put back: the hash is checked when it reappears. + let (dir, mut s) = open(&format!("replaced-reappear-{format}")); + let first = save(&mut s); + let first_id = first["library"].as_str().unwrap().to_string(); + let first_path = first["path"].as_str().unwrap().to_string(); + let written = std::fs::read(&first_path).unwrap(); + std::fs::remove_file(&first_path).unwrap(); + run(&mut s, "swatch.library.list", json!({})); + std::fs::write(&first_path, &written).unwrap(); + assert_eq!(save(&mut s)["library"].as_str(), Some(first_id.as_str()), "{format}: the same bytes are still this library"); + std::fs::remove_file(&first_path).unwrap(); + run(&mut s, "swatch.library.list", json!({})); + std::fs::write(&first_path, &other).unwrap(); + let moved = save(&mut s); + let moved_id = moved["library"].as_str().unwrap().to_string(); + assert_ne!(moved_id, first_id); + assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: different bytes put back are kept"); + assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str())); + let _ = std::fs::remove_dir_all(&dir); + } + + let dir = temp_dir("replaced-ordinary"); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let first = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"})); + let path = first["path"].as_str().unwrap().to_string(); + std::fs::write(&path, b"not a library").unwrap(); + let again = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"})); + assert_eq!(again["library"], first["library"], "a name that is not a device name replaces its own unreadable file"); + assert!(std::fs::read(&path).unwrap().starts_with(b"GIMP Palette")); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3602: a library titled with a Windows device name is saved under a name that is not one. #[test] fn a_library_file_name_is_never_a_windows_device_name() {