1use std::path::{Path, PathBuf};
19
20use crate::preset::{PresetMeta, parse_preset_file, parse_preset_meta, write_preset_file};
21use crate::safe_filename;
22use crate::state::{DeserializedState, deserialize_state, serialize_state};
23
24pub use crate::preset::PRESET_FILE_EXT;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PresetScope {
32 Factory,
33 User,
34 Pack,
35}
36
37#[derive(Debug, Clone)]
42pub struct PresetRef {
43 pub uuid: String,
47 pub uri: String,
49 pub name: String,
51 pub category: Option<String>,
55 pub author: Option<String>,
56 pub comment: Option<String>,
57 pub tags: Vec<String>,
58 pub default: bool,
60 pub scope: PresetScope,
61 pub path: PathBuf,
63}
64
65#[must_use]
92pub fn user_preset_root(
93 vendor: &str,
94 plugin_name: &str,
95 user_dir: Option<&str>,
96) -> Option<PathBuf> {
97 let override_subpath = user_dir.and_then(sanitize_preset_user_dir);
98 let has_override = override_subpath.is_some();
99 let subpath = override_subpath.unwrap_or_else(|| {
100 PathBuf::from("truce")
101 .join(safe_filename(vendor))
102 .join(safe_filename(plugin_name))
103 });
104
105 #[cfg(target_os = "macos")]
106 {
107 let home = std::env::var_os("HOME")?;
108 let _ = has_override;
109 Some(
110 PathBuf::from(home)
111 .join("Library/Audio/Presets")
112 .join(subpath),
113 )
114 }
115 #[cfg(target_os = "windows")]
116 {
117 let appdata = std::env::var_os("APPDATA")?;
118 let mut root = PathBuf::from(appdata).join(subpath);
119 if !has_override {
120 root.push("presets");
121 }
122 Some(root)
123 }
124 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
125 {
126 let data_home = std::env::var_os("XDG_DATA_HOME")
127 .map(PathBuf::from)
128 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
129 let mut root = data_home;
130 if has_override {
131 root.push("truce");
132 }
133 root.push(subpath);
134 if !has_override {
135 root.push("presets");
136 }
137 Some(root)
138 }
139}
140
141#[must_use]
156pub fn sanitize_preset_user_dir(raw: &str) -> Option<PathBuf> {
157 let mut out = PathBuf::new();
158 for segment in raw.split(['/', '\\']) {
159 let segment = segment.trim();
160 if segment.is_empty() || segment == "." {
161 continue;
162 }
163 if segment == ".." {
164 return None;
165 }
166 let safe = safe_filename(segment);
167 if !safe.is_empty() {
168 out.push(safe);
169 }
170 }
171 (!out.as_os_str().is_empty()).then_some(out)
172}
173
174#[must_use]
182pub fn preset_uri(vendor: &str, plugin_name: &str, uuid: &str) -> String {
183 format!(
184 "truce-preset://{}/{}/{uuid}",
185 safe_filename(vendor),
186 safe_filename(plugin_name)
187 )
188}
189
190#[must_use]
194pub fn parse_preset_uri(uri: &str) -> Option<(&str, &str, &str)> {
195 let rest = uri.strip_prefix("truce-preset://")?;
196 let mut segments = rest.splitn(3, '/');
197 let vendor = segments.next().filter(|s| !s.is_empty())?;
198 let plugin = segments.next().filter(|s| !s.is_empty())?;
199 let uuid = segments.next().filter(|s| !s.is_empty())?;
200 Some((vendor, plugin, uuid))
201}
202
203#[must_use]
207pub fn mint_uuid() -> String {
208 use std::fmt::Write as _;
209 use std::hash::{BuildHasher, Hasher};
210 let mix = |salt: u64| {
211 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
212 h.write_u64(salt);
213 let nanos = std::time::SystemTime::now()
214 .duration_since(std::time::UNIX_EPOCH)
215 .map_or(0, |d| u64::from(d.subsec_nanos()));
216 h.finish() ^ nanos.rotate_left(17)
217 };
218 let mut bytes = [0u8; 16];
219 bytes[..8].copy_from_slice(&mix(0x9e37_79b9).to_le_bytes());
220 bytes[8..].copy_from_slice(&mix(0x85eb_ca6b).to_le_bytes());
221 bytes[6] = (bytes[6] & 0x0f) | 0x40;
223 bytes[8] = (bytes[8] & 0x3f) | 0x80;
224
225 let mut out = String::with_capacity(36);
226 for (i, b) in bytes.iter().enumerate() {
227 if matches!(i, 4 | 6 | 8 | 10) {
228 out.push('-');
229 }
230 let _ = write!(out, "{b:02x}");
231 }
232 out
233}
234
235#[must_use]
244pub fn enumerate_scope(
245 root: &Path,
246 scope: PresetScope,
247 vendor: &str,
248 plugin_name: &str,
249) -> Vec<PresetRef> {
250 let mut out = Vec::new();
251 walk(root, root, scope, vendor, plugin_name, &mut out, 0);
252 out
253}
254
255const MAX_WALK_DEPTH: usize = 6;
260
261fn walk(
262 root: &Path,
263 dir: &Path,
264 scope: PresetScope,
265 vendor: &str,
266 plugin_name: &str,
267 out: &mut Vec<PresetRef>,
268 depth: usize,
269) {
270 if depth > MAX_WALK_DEPTH {
271 return;
272 }
273 let Ok(entries) = std::fs::read_dir(dir) else {
274 return;
275 };
276 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
277 entries.sort_by_key(std::fs::DirEntry::file_name);
279 for entry in entries {
280 let path = entry.path();
281 if path.is_dir() {
282 walk(root, &path, scope, vendor, plugin_name, out, depth + 1);
283 } else if path.extension().and_then(|e| e.to_str()) == Some(PRESET_FILE_EXT)
284 && let Some(preset) = read_preset_ref(Some(root), &path, scope, vendor, plugin_name)
285 {
286 out.push(preset);
287 }
288 }
289}
290
291#[must_use]
296pub fn read_preset_ref(
297 root: Option<&Path>,
298 path: &Path,
299 scope: PresetScope,
300 vendor: &str,
301 plugin_name: &str,
302) -> Option<PresetRef> {
303 let bytes = std::fs::read(path).ok()?;
304 let meta = parse_preset_meta(&bytes)?;
305
306 let category = if meta.category.is_empty() {
310 path.parent()
311 .filter(|parent| Some(*parent) != root)
312 .and_then(|parent| parent.file_name())
313 .and_then(|n| n.to_str())
314 .map(str::to_string)
315 } else {
316 Some(meta.category)
317 };
318
319 let none_if_empty = |s: String| if s.is_empty() { None } else { Some(s) };
320 Some(PresetRef {
321 uri: preset_uri(vendor, plugin_name, &meta.uuid),
322 uuid: meta.uuid,
323 name: meta.name,
324 category,
325 author: none_if_empty(meta.author),
326 comment: none_if_empty(meta.comment),
327 tags: meta.tags,
328 default: meta.default,
329 scope,
330 path: path.to_path_buf(),
331 })
332}
333
334#[must_use]
339pub fn load_preset_file(path: &Path, plugin_id_hash: u64) -> Option<DeserializedState> {
340 let bytes = std::fs::read(path).ok()?;
341 let (_, blob) = parse_preset_file(&bytes)?;
342 deserialize_state(&blob, plugin_id_hash)
343}
344
345#[derive(Debug)]
351pub enum PresetError {
352 NotFound,
354 ReadOnlyScope,
359 NoUserDirectory,
362 InvalidState,
365 InvalidName,
367 Io(std::io::Error),
368}
369
370impl std::fmt::Display for PresetError {
371 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 match self {
373 Self::NotFound => f.write_str("preset not found"),
374 Self::ReadOnlyScope => f.write_str("factory / pack presets are read-only"),
375 Self::NoUserDirectory => f.write_str("user preset directory could not be resolved"),
376 Self::InvalidState => f.write_str("preset state is malformed or from another plugin"),
377 Self::InvalidName => f.write_str("preset name is empty"),
378 Self::Io(e) => write!(f, "preset io: {e}"),
379 }
380 }
381}
382
383impl std::error::Error for PresetError {}
384
385impl From<std::io::Error> for PresetError {
386 fn from(e: std::io::Error) -> Self {
387 Self::Io(e)
388 }
389}
390
391pub struct PresetStore {
400 vendor: String,
401 plugin_name: String,
402 plugin_id_hash: u64,
403 factory_root: Option<PathBuf>,
404 user_root: Option<PathBuf>,
405}
406
407impl PresetStore {
408 #[must_use]
416 pub fn new(
417 vendor: &str,
418 plugin_name: &str,
419 plugin_id_hash: u64,
420 user_dir: Option<&str>,
421 ) -> Self {
422 Self {
423 vendor: vendor.to_string(),
424 plugin_name: plugin_name.to_string(),
425 plugin_id_hash,
426 factory_root: None,
427 user_root: user_preset_root(vendor, plugin_name, user_dir),
428 }
429 }
430
431 #[must_use]
432 pub fn with_factory_root(mut self, root: impl Into<PathBuf>) -> Self {
433 self.factory_root = Some(root.into());
434 self
435 }
436
437 #[must_use]
440 pub fn with_user_root(mut self, root: impl Into<PathBuf>) -> Self {
441 self.user_root = Some(root.into());
442 self
443 }
444
445 #[must_use]
446 pub fn user_root(&self) -> Option<&Path> {
447 self.user_root.as_deref()
448 }
449
450 #[must_use]
460 pub fn enumerate(&self) -> Vec<PresetRef> {
461 let mut user: Vec<PresetRef> = Vec::new();
462 let mut packs: Vec<PresetRef> = Vec::new();
463 if let Some(root) = &self.user_root {
464 let packs_root = root.join("packs");
465 for mut preset in
466 enumerate_scope(root, PresetScope::User, &self.vendor, &self.plugin_name)
467 {
468 if preset.uuid.is_empty() {
469 self.stamp_uuid(&mut preset);
470 }
471 if preset.path.starts_with(&packs_root) {
472 preset.scope = PresetScope::Pack;
473 packs.push(preset);
474 } else {
475 user.push(preset);
476 }
477 }
478 }
479 let factory = self.factory_root.as_ref().map_or_else(Vec::new, |root| {
480 enumerate_scope(root, PresetScope::Factory, &self.vendor, &self.plugin_name)
481 });
482
483 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
486 let mut out: Vec<PresetRef> = Vec::new();
487 for preset in user.into_iter().chain(packs).chain(factory) {
488 if !preset.uuid.is_empty() && !seen.insert(preset.uuid.clone()) {
489 continue;
490 }
491 out.push(preset);
492 }
493 out.sort_by(|a, b| {
494 scope_rank(a.scope)
495 .cmp(&scope_rank(b.scope))
496 .then_with(|| a.category.cmp(&b.category))
497 .then_with(|| a.name.cmp(&b.name))
498 });
499 out
500 }
501
502 fn stamp_uuid(&self, preset: &mut PresetRef) {
506 let Ok(bytes) = std::fs::read(&preset.path) else {
507 return;
508 };
509 let Some((mut meta, blob)) = parse_preset_file(&bytes) else {
510 return;
511 };
512 meta.uuid = mint_uuid();
513 if std::fs::write(&preset.path, write_preset_file(&meta, &blob)).is_ok() {
514 preset.uri = preset_uri(&self.vendor, &self.plugin_name, &meta.uuid);
515 preset.uuid = meta.uuid;
516 }
517 }
518
519 #[must_use]
525 pub fn find(&self, sel: &str) -> Option<PresetRef> {
526 let uuid = if let Some((vendor, plugin, uuid)) = parse_preset_uri(sel) {
527 if vendor != safe_filename(&self.vendor) || plugin != safe_filename(&self.plugin_name) {
528 return None;
529 }
530 uuid
531 } else {
532 sel
533 };
534 if uuid.is_empty() {
535 return None;
536 }
537 let presets = self.enumerate();
538 presets
539 .iter()
540 .find(|p| p.uuid == uuid)
541 .or_else(|| presets.iter().find(|p| p.name == sel))
542 .cloned()
543 }
544
545 pub fn load(&self, uri_or_uuid: &str) -> Result<DeserializedState, PresetError> {
554 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
555 load_preset_file(&preset.path, self.plugin_id_hash).ok_or(PresetError::InvalidState)
556 }
557
558 pub fn save(
573 &self,
574 mut meta: PresetMeta,
575 params: &[(u32, f64)],
576 extra: &[u8],
577 ) -> Result<PresetRef, PresetError> {
578 let user_root = self
579 .user_root
580 .as_ref()
581 .ok_or(PresetError::NoUserDirectory)?;
582 let file_stem = safe_filename(&meta.name);
583 if file_stem.is_empty() {
584 return Err(PresetError::InvalidName);
585 }
586
587 let existing = self.enumerate().into_iter().find(|p| {
588 p.scope == PresetScope::User
589 && p.name == meta.name
590 && p.category.as_deref().unwrap_or_default()
591 == if meta.category.is_empty() {
592 ""
593 } else {
594 meta.category.as_str()
595 }
596 });
597 let path = if let Some(existing) = &existing {
598 meta.uuid.clone_from(&existing.uuid);
599 existing.path.clone()
600 } else {
601 if meta.uuid.is_empty() {
602 meta.uuid = mint_uuid();
603 }
604 let dir = if meta.category.is_empty() {
605 user_root.clone()
606 } else {
607 user_root.join(safe_filename(&meta.category))
608 };
609 dir.join(format!("{file_stem}.{PRESET_FILE_EXT}"))
610 };
611
612 let ids: Vec<u32> = params.iter().map(|(id, _)| *id).collect();
613 let values: Vec<f64> = params.iter().map(|(_, v)| *v).collect();
614 let blob = serialize_state(self.plugin_id_hash, &ids, &values, extra);
615
616 if let Some(parent) = path.parent() {
617 std::fs::create_dir_all(parent)?;
618 }
619 std::fs::write(&path, write_preset_file(&meta, &blob))?;
620 read_preset_ref(
621 self.user_root.as_deref(),
622 &path,
623 PresetScope::User,
624 &self.vendor,
625 &self.plugin_name,
626 )
627 .ok_or(PresetError::InvalidState)
628 }
629
630 pub fn rename(&self, uri_or_uuid: &str, new_name: &str) -> Result<(), PresetError> {
640 let preset = self.user_preset(uri_or_uuid)?;
641 rewrite_meta(&preset.path, |meta| new_name.clone_into(&mut meta.name))
642 }
643
644 pub fn recategorise(&self, uri_or_uuid: &str, new_category: &str) -> Result<(), PresetError> {
654 let user_root = self
655 .user_root
656 .as_ref()
657 .ok_or(PresetError::NoUserDirectory)?;
658 let preset = self.user_preset(uri_or_uuid)?;
659
660 rewrite_meta(&preset.path, |meta| {
661 new_category.clone_into(&mut meta.category);
662 })?;
663
664 let dir = if new_category.is_empty() {
665 user_root.clone()
666 } else {
667 user_root.join(safe_filename(new_category))
668 };
669 let file_name = preset
670 .path
671 .file_name()
672 .ok_or(PresetError::InvalidState)?
673 .to_os_string();
674 let dest = dir.join(file_name);
675 if dest != preset.path {
676 std::fs::create_dir_all(&dir)?;
677 std::fs::rename(&preset.path, &dest)?;
678 }
679 Ok(())
680 }
681
682 pub fn delete(&self, uri_or_uuid: &str) -> Result<(), PresetError> {
689 let preset = self.user_preset(uri_or_uuid)?;
690 std::fs::remove_file(&preset.path)?;
691 Ok(())
692 }
693
694 fn user_preset(&self, uri_or_uuid: &str) -> Result<PresetRef, PresetError> {
695 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
696 if preset.scope != PresetScope::User {
697 return Err(PresetError::ReadOnlyScope);
698 }
699 Ok(preset)
700 }
701}
702
703fn rewrite_meta(path: &Path, edit: impl FnOnce(&mut PresetMeta)) -> Result<(), PresetError> {
704 let bytes = std::fs::read(path)?;
705 let (mut meta, blob) = parse_preset_file(&bytes).ok_or(PresetError::InvalidState)?;
706 edit(&mut meta);
707 std::fs::write(path, write_preset_file(&meta, &blob))?;
708 Ok(())
709}
710
711fn scope_rank(scope: PresetScope) -> u8 {
712 match scope {
713 PresetScope::Factory => 0,
714 PresetScope::User => 1,
715 PresetScope::Pack => 2,
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 fn write_sample(dir: &Path, rel: &str, meta: &PresetMeta, blob: &[u8]) {
724 let path = dir.join(rel);
725 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
726 std::fs::write(path, write_preset_file(meta, blob)).unwrap();
727 }
728
729 fn temp_dir(name: &str) -> PathBuf {
730 let dir = std::env::temp_dir().join(format!("truce-presets-{name}"));
731 let _ = std::fs::remove_dir_all(&dir);
732 std::fs::create_dir_all(&dir).unwrap();
733 dir
734 }
735
736 fn store(name: &str) -> (PresetStore, PathBuf, PathBuf) {
737 let user = temp_dir(&format!("{name}-user"));
738 let factory = temp_dir(&format!("{name}-factory"));
739 let store = PresetStore::new("Acme", "Synth", 42, None)
740 .with_user_root(&user)
741 .with_factory_root(&factory);
742 (store, user, factory)
743 }
744
745 fn meta(uuid: &str, name: &str, category: &str) -> PresetMeta {
746 PresetMeta {
747 uuid: uuid.into(),
748 name: name.into(),
749 category: category.into(),
750 ..PresetMeta::default()
751 }
752 }
753
754 #[test]
755 fn enumerates_with_directory_category_fallback() {
756 let tmp = temp_dir("enum");
757 write_sample(&tmp, "pad/a.trucepreset", &meta("u1", "A", "Lead"), &[]);
758 write_sample(&tmp, "pad/b.trucepreset", &meta("u2", "B", ""), &[]);
759 write_sample(&tmp, "c.trucepreset", &meta("u3", "C", ""), &[]);
760 std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
762
763 let refs = enumerate_scope(&tmp, PresetScope::Factory, "Acme", "Synth");
764 assert_eq!(refs.len(), 3);
765 let by_uuid = |u: &str| refs.iter().find(|r| r.uuid == u).unwrap();
766 assert_eq!(by_uuid("u1").category.as_deref(), Some("Lead"));
767 assert_eq!(by_uuid("u2").category.as_deref(), Some("pad"));
768 assert_eq!(by_uuid("u3").category, None);
769 assert_eq!(by_uuid("u1").uri, "truce-preset://Acme/Synth/u1");
770 assert_eq!(by_uuid("u1").scope, PresetScope::Factory);
771
772 let _ = std::fs::remove_dir_all(&tmp);
773 }
774
775 #[test]
776 fn missing_root_is_empty() {
777 let refs = enumerate_scope(
778 Path::new("/nonexistent/truce-presets"),
779 PresetScope::User,
780 "V",
781 "P",
782 );
783 assert!(refs.is_empty());
784 }
785
786 #[test]
787 fn load_validates_plugin_hash() {
788 let tmp = temp_dir("load");
789 let hash = crate::state::hash_plugin_id("com.acme.synth");
790 let blob = serialize_state(hash, &[0, 1], &[0.25, 8200.0], b"xs");
791 let path = tmp.join("loadable.trucepreset");
792 std::fs::write(&path, write_preset_file(&meta("u9", "Loadable", ""), &blob)).unwrap();
793
794 let state = load_preset_file(&path, hash).unwrap();
795 assert_eq!(state.params, vec![(0, 0.25), (1, 8200.0)]);
796 assert_eq!(state.extra.as_deref(), Some(&b"xs"[..]));
797 assert!(load_preset_file(&path, hash ^ 1).is_none());
798
799 let _ = std::fs::remove_dir_all(&tmp);
800 }
801
802 #[test]
803 fn sanitize_user_dir_rules() {
804 let ok = |raw: &str, want: &str| {
805 assert_eq!(
806 sanitize_preset_user_dir(raw),
807 Some(PathBuf::from(want)),
808 "{raw}"
809 );
810 };
811 ok("Acme/MySynth", "Acme/MySynth");
812 ok("Acme\\MySynth", "Acme/MySynth"); ok("/Acme/MySynth/", "Acme/MySynth"); ok("Acme/./MySynth", "Acme/MySynth"); ok("C:/Acme", "C/Acme"); ok(" Acme / My Synth ", "Acme/My Synth"); assert_eq!(sanitize_preset_user_dir("../escape"), None);
819 assert_eq!(sanitize_preset_user_dir("a/../b"), None);
820 assert_eq!(sanitize_preset_user_dir(""), None);
821 assert_eq!(sanitize_preset_user_dir("///"), None);
822 assert_eq!(sanitize_preset_user_dir("..."), None);
824 }
825
826 #[test]
827 fn user_root_honours_override() {
828 let default = user_preset_root("Acme", "My Synth", None).unwrap();
829 let overridden = user_preset_root("Acme", "My Synth", Some("AcmeAudio/Synth")).unwrap();
830 let unusable = user_preset_root("Acme", "My Synth", Some("../nope")).unwrap();
831
832 assert!(
833 default.ends_with("truce/Acme/My Synth")
834 || default.ends_with("truce/Acme/My Synth/presets")
835 );
836 assert!(overridden.to_string_lossy().contains("AcmeAudio"));
837 assert!(overridden.ends_with("AcmeAudio/Synth"));
838 assert_eq!(unusable, default);
840 }
841
842 #[test]
843 fn uri_round_trips() {
844 let uri = preset_uri("Acme Co", "My Synth", "u-1");
845 assert_eq!(parse_preset_uri(&uri), Some(("Acme Co", "My Synth", "u-1")));
846 assert_eq!(parse_preset_uri("nope://x/y/z"), None);
847 assert_eq!(parse_preset_uri("truce-preset://only/two"), None);
848 }
849
850 #[test]
851 fn mint_uuid_is_v4_shaped_and_distinct() {
852 let a = mint_uuid();
853 let b = mint_uuid();
854 assert_eq!(a.len(), 36);
855 assert_eq!(&a[14..15], "4");
856 assert_ne!(a, b);
857 }
858
859 #[test]
860 fn user_overrides_factory_and_packs_classify() {
861 let (store, user, factory) = store("dedup");
862 let blob = serialize_state(42, &[0], &[1.0], &[]);
863 write_sample(&factory, "lead/a.trucepreset", &meta("u1", "A", ""), &blob);
864 write_sample(&factory, "lead/b.trucepreset", &meta("u2", "B", ""), &blob);
865 write_sample(
867 &user,
868 "my/a2.trucepreset",
869 &meta("u1", "A edited", ""),
870 &blob,
871 );
872 write_sample(
873 &user,
874 "packs/edm/lead/p.trucepreset",
875 &meta("u4", "P", ""),
876 &blob,
877 );
878
879 let refs = store.enumerate();
880 assert_eq!(refs.len(), 3);
881 let a = refs.iter().find(|r| r.uuid == "u1").unwrap();
882 assert_eq!(a.scope, PresetScope::User);
883 assert_eq!(a.name, "A edited");
884 assert_eq!(
885 refs.iter().find(|r| r.uuid == "u4").unwrap().scope,
886 PresetScope::Pack
887 );
888
889 let _ = std::fs::remove_dir_all(&user);
890 let _ = std::fs::remove_dir_all(&factory);
891 }
892
893 #[test]
894 fn stamps_missing_uuid_on_user_files() {
895 let (store, user, factory) = store("stamp");
896 let blob = serialize_state(42, &[], &[], &[]);
897 write_sample(&user, "x.trucepreset", &meta("", "Handmade", ""), &blob);
898
899 let refs = store.enumerate();
900 let stamped = &refs[0];
901 assert_eq!(stamped.uuid.len(), 36);
902 let again = store.enumerate();
904 assert_eq!(again[0].uuid, stamped.uuid);
905
906 let _ = std::fs::remove_dir_all(&user);
907 let _ = std::fs::remove_dir_all(&factory);
908 }
909
910 #[test]
911 fn save_load_rename_recategorise_delete() {
912 let (store, user, factory) = store("crud");
913
914 let saved = store
915 .save(meta("", "My Lead", "lead"), &[(0, 0.5), (1, 440.0)], b"x")
916 .unwrap();
917 assert_eq!(saved.scope, PresetScope::User);
918 assert!(saved.path.starts_with(user.join("lead")));
919 assert_eq!(saved.uuid.len(), 36);
920
921 let state = store.load(&saved.uri).unwrap();
922 assert_eq!(state.params, vec![(0, 0.5), (1, 440.0)]);
923
924 assert_eq!(store.find(&saved.uri).unwrap().uuid, saved.uuid);
926 assert_eq!(store.find(&saved.uuid).unwrap().uuid, saved.uuid);
927 assert_eq!(store.find("My Lead").unwrap().uuid, saved.uuid);
928 assert!(store.find("nonexistent").is_none());
929
930 let resaved = store
932 .save(meta("", "My Lead", "lead"), &[(0, 0.9)], &[])
933 .unwrap();
934 assert_eq!(resaved.uuid, saved.uuid);
935 assert_eq!(store.enumerate().len(), 1);
936
937 store.rename(&saved.uuid, "Better Lead").unwrap();
938 let renamed = store.find(&saved.uuid).unwrap();
939 assert_eq!(renamed.name, "Better Lead");
940 assert_eq!(renamed.uri, saved.uri);
941
942 store.recategorise(&saved.uuid, "bass").unwrap();
943 let moved = store.find(&saved.uuid).unwrap();
944 assert_eq!(moved.category.as_deref(), Some("bass"));
945 assert!(moved.path.starts_with(user.join("bass")));
946
947 store.delete(&saved.uuid).unwrap();
948 assert!(store.find(&saved.uuid).is_none());
949 assert!(matches!(
950 store.delete(&saved.uuid),
951 Err(PresetError::NotFound)
952 ));
953
954 let _ = std::fs::remove_dir_all(&user);
955 let _ = std::fs::remove_dir_all(&factory);
956 }
957
958 #[test]
959 fn factory_presets_are_read_only() {
960 let (store, user, factory) = store("readonly");
961 let blob = serialize_state(42, &[], &[], &[]);
962 write_sample(&factory, "a.trucepreset", &meta("u1", "A", ""), &blob);
963
964 assert!(matches!(
965 store.rename("u1", "B"),
966 Err(PresetError::ReadOnlyScope)
967 ));
968 assert!(matches!(
969 store.delete("u1"),
970 Err(PresetError::ReadOnlyScope)
971 ));
972
973 let _ = std::fs::remove_dir_all(&user);
974 let _ = std::fs::remove_dir_all(&factory);
975 }
976}