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]
522 pub fn find(&self, uri_or_uuid: &str) -> Option<PresetRef> {
523 let uuid = if let Some((vendor, plugin, uuid)) = parse_preset_uri(uri_or_uuid) {
524 if vendor != safe_filename(&self.vendor) || plugin != safe_filename(&self.plugin_name) {
525 return None;
526 }
527 uuid
528 } else {
529 uri_or_uuid
530 };
531 if uuid.is_empty() {
532 return None;
533 }
534 self.enumerate().into_iter().find(|p| p.uuid == uuid)
535 }
536
537 pub fn load(&self, uri_or_uuid: &str) -> Result<DeserializedState, PresetError> {
546 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
547 load_preset_file(&preset.path, self.plugin_id_hash).ok_or(PresetError::InvalidState)
548 }
549
550 pub fn save(
565 &self,
566 mut meta: PresetMeta,
567 params: &[(u32, f64)],
568 extra: &[u8],
569 ) -> Result<PresetRef, PresetError> {
570 let user_root = self
571 .user_root
572 .as_ref()
573 .ok_or(PresetError::NoUserDirectory)?;
574 let file_stem = safe_filename(&meta.name);
575 if file_stem.is_empty() {
576 return Err(PresetError::InvalidName);
577 }
578
579 let existing = self.enumerate().into_iter().find(|p| {
580 p.scope == PresetScope::User
581 && p.name == meta.name
582 && p.category.as_deref().unwrap_or_default()
583 == if meta.category.is_empty() {
584 ""
585 } else {
586 meta.category.as_str()
587 }
588 });
589 let path = if let Some(existing) = &existing {
590 meta.uuid.clone_from(&existing.uuid);
591 existing.path.clone()
592 } else {
593 if meta.uuid.is_empty() {
594 meta.uuid = mint_uuid();
595 }
596 let dir = if meta.category.is_empty() {
597 user_root.clone()
598 } else {
599 user_root.join(safe_filename(&meta.category))
600 };
601 dir.join(format!("{file_stem}.{PRESET_FILE_EXT}"))
602 };
603
604 let ids: Vec<u32> = params.iter().map(|(id, _)| *id).collect();
605 let values: Vec<f64> = params.iter().map(|(_, v)| *v).collect();
606 let blob = serialize_state(self.plugin_id_hash, &ids, &values, extra);
607
608 if let Some(parent) = path.parent() {
609 std::fs::create_dir_all(parent)?;
610 }
611 std::fs::write(&path, write_preset_file(&meta, &blob))?;
612 read_preset_ref(
613 self.user_root.as_deref(),
614 &path,
615 PresetScope::User,
616 &self.vendor,
617 &self.plugin_name,
618 )
619 .ok_or(PresetError::InvalidState)
620 }
621
622 pub fn rename(&self, uri_or_uuid: &str, new_name: &str) -> Result<(), PresetError> {
632 let preset = self.user_preset(uri_or_uuid)?;
633 rewrite_meta(&preset.path, |meta| new_name.clone_into(&mut meta.name))
634 }
635
636 pub fn recategorise(&self, uri_or_uuid: &str, new_category: &str) -> Result<(), PresetError> {
646 let user_root = self
647 .user_root
648 .as_ref()
649 .ok_or(PresetError::NoUserDirectory)?;
650 let preset = self.user_preset(uri_or_uuid)?;
651
652 rewrite_meta(&preset.path, |meta| {
653 new_category.clone_into(&mut meta.category);
654 })?;
655
656 let dir = if new_category.is_empty() {
657 user_root.clone()
658 } else {
659 user_root.join(safe_filename(new_category))
660 };
661 let file_name = preset
662 .path
663 .file_name()
664 .ok_or(PresetError::InvalidState)?
665 .to_os_string();
666 let dest = dir.join(file_name);
667 if dest != preset.path {
668 std::fs::create_dir_all(&dir)?;
669 std::fs::rename(&preset.path, &dest)?;
670 }
671 Ok(())
672 }
673
674 pub fn delete(&self, uri_or_uuid: &str) -> Result<(), PresetError> {
681 let preset = self.user_preset(uri_or_uuid)?;
682 std::fs::remove_file(&preset.path)?;
683 Ok(())
684 }
685
686 fn user_preset(&self, uri_or_uuid: &str) -> Result<PresetRef, PresetError> {
687 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
688 if preset.scope != PresetScope::User {
689 return Err(PresetError::ReadOnlyScope);
690 }
691 Ok(preset)
692 }
693}
694
695fn rewrite_meta(path: &Path, edit: impl FnOnce(&mut PresetMeta)) -> Result<(), PresetError> {
696 let bytes = std::fs::read(path)?;
697 let (mut meta, blob) = parse_preset_file(&bytes).ok_or(PresetError::InvalidState)?;
698 edit(&mut meta);
699 std::fs::write(path, write_preset_file(&meta, &blob))?;
700 Ok(())
701}
702
703fn scope_rank(scope: PresetScope) -> u8 {
704 match scope {
705 PresetScope::Factory => 0,
706 PresetScope::User => 1,
707 PresetScope::Pack => 2,
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 fn write_sample(dir: &Path, rel: &str, meta: &PresetMeta, blob: &[u8]) {
716 let path = dir.join(rel);
717 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
718 std::fs::write(path, write_preset_file(meta, blob)).unwrap();
719 }
720
721 fn temp_dir(name: &str) -> PathBuf {
722 let dir = std::env::temp_dir().join(format!("truce-presets-{name}"));
723 let _ = std::fs::remove_dir_all(&dir);
724 std::fs::create_dir_all(&dir).unwrap();
725 dir
726 }
727
728 fn store(name: &str) -> (PresetStore, PathBuf, PathBuf) {
729 let user = temp_dir(&format!("{name}-user"));
730 let factory = temp_dir(&format!("{name}-factory"));
731 let store = PresetStore::new("Acme", "Synth", 42, None)
732 .with_user_root(&user)
733 .with_factory_root(&factory);
734 (store, user, factory)
735 }
736
737 fn meta(uuid: &str, name: &str, category: &str) -> PresetMeta {
738 PresetMeta {
739 uuid: uuid.into(),
740 name: name.into(),
741 category: category.into(),
742 ..PresetMeta::default()
743 }
744 }
745
746 #[test]
747 fn enumerates_with_directory_category_fallback() {
748 let tmp = temp_dir("enum");
749 write_sample(&tmp, "pad/a.trucepreset", &meta("u1", "A", "Lead"), &[]);
750 write_sample(&tmp, "pad/b.trucepreset", &meta("u2", "B", ""), &[]);
751 write_sample(&tmp, "c.trucepreset", &meta("u3", "C", ""), &[]);
752 std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
754
755 let refs = enumerate_scope(&tmp, PresetScope::Factory, "Acme", "Synth");
756 assert_eq!(refs.len(), 3);
757 let by_uuid = |u: &str| refs.iter().find(|r| r.uuid == u).unwrap();
758 assert_eq!(by_uuid("u1").category.as_deref(), Some("Lead"));
759 assert_eq!(by_uuid("u2").category.as_deref(), Some("pad"));
760 assert_eq!(by_uuid("u3").category, None);
761 assert_eq!(by_uuid("u1").uri, "truce-preset://Acme/Synth/u1");
762 assert_eq!(by_uuid("u1").scope, PresetScope::Factory);
763
764 let _ = std::fs::remove_dir_all(&tmp);
765 }
766
767 #[test]
768 fn missing_root_is_empty() {
769 let refs = enumerate_scope(
770 Path::new("/nonexistent/truce-presets"),
771 PresetScope::User,
772 "V",
773 "P",
774 );
775 assert!(refs.is_empty());
776 }
777
778 #[test]
779 fn load_validates_plugin_hash() {
780 let tmp = temp_dir("load");
781 let hash = crate::state::hash_plugin_id("com.acme.synth");
782 let blob = serialize_state(hash, &[0, 1], &[0.25, 8200.0], b"xs");
783 let path = tmp.join("loadable.trucepreset");
784 std::fs::write(&path, write_preset_file(&meta("u9", "Loadable", ""), &blob)).unwrap();
785
786 let state = load_preset_file(&path, hash).unwrap();
787 assert_eq!(state.params, vec![(0, 0.25), (1, 8200.0)]);
788 assert_eq!(state.extra.as_deref(), Some(&b"xs"[..]));
789 assert!(load_preset_file(&path, hash ^ 1).is_none());
790
791 let _ = std::fs::remove_dir_all(&tmp);
792 }
793
794 #[test]
795 fn sanitize_user_dir_rules() {
796 let ok = |raw: &str, want: &str| {
797 assert_eq!(
798 sanitize_preset_user_dir(raw),
799 Some(PathBuf::from(want)),
800 "{raw}"
801 );
802 };
803 ok("Acme/MySynth", "Acme/MySynth");
804 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);
811 assert_eq!(sanitize_preset_user_dir("a/../b"), None);
812 assert_eq!(sanitize_preset_user_dir(""), None);
813 assert_eq!(sanitize_preset_user_dir("///"), None);
814 assert_eq!(sanitize_preset_user_dir("..."), None);
816 }
817
818 #[test]
819 fn user_root_honours_override() {
820 let default = user_preset_root("Acme", "My Synth", None).unwrap();
821 let overridden = user_preset_root("Acme", "My Synth", Some("AcmeAudio/Synth")).unwrap();
822 let unusable = user_preset_root("Acme", "My Synth", Some("../nope")).unwrap();
823
824 assert!(
825 default.ends_with("truce/Acme/My Synth")
826 || default.ends_with("truce/Acme/My Synth/presets")
827 );
828 assert!(overridden.to_string_lossy().contains("AcmeAudio"));
829 assert!(overridden.ends_with("AcmeAudio/Synth"));
830 assert_eq!(unusable, default);
832 }
833
834 #[test]
835 fn uri_round_trips() {
836 let uri = preset_uri("Acme Co", "My Synth", "u-1");
837 assert_eq!(parse_preset_uri(&uri), Some(("Acme Co", "My Synth", "u-1")));
838 assert_eq!(parse_preset_uri("nope://x/y/z"), None);
839 assert_eq!(parse_preset_uri("truce-preset://only/two"), None);
840 }
841
842 #[test]
843 fn mint_uuid_is_v4_shaped_and_distinct() {
844 let a = mint_uuid();
845 let b = mint_uuid();
846 assert_eq!(a.len(), 36);
847 assert_eq!(&a[14..15], "4");
848 assert_ne!(a, b);
849 }
850
851 #[test]
852 fn user_overrides_factory_and_packs_classify() {
853 let (store, user, factory) = store("dedup");
854 let blob = serialize_state(42, &[0], &[1.0], &[]);
855 write_sample(&factory, "lead/a.trucepreset", &meta("u1", "A", ""), &blob);
856 write_sample(&factory, "lead/b.trucepreset", &meta("u2", "B", ""), &blob);
857 write_sample(
859 &user,
860 "my/a2.trucepreset",
861 &meta("u1", "A edited", ""),
862 &blob,
863 );
864 write_sample(
865 &user,
866 "packs/edm/lead/p.trucepreset",
867 &meta("u4", "P", ""),
868 &blob,
869 );
870
871 let refs = store.enumerate();
872 assert_eq!(refs.len(), 3);
873 let a = refs.iter().find(|r| r.uuid == "u1").unwrap();
874 assert_eq!(a.scope, PresetScope::User);
875 assert_eq!(a.name, "A edited");
876 assert_eq!(
877 refs.iter().find(|r| r.uuid == "u4").unwrap().scope,
878 PresetScope::Pack
879 );
880
881 let _ = std::fs::remove_dir_all(&user);
882 let _ = std::fs::remove_dir_all(&factory);
883 }
884
885 #[test]
886 fn stamps_missing_uuid_on_user_files() {
887 let (store, user, factory) = store("stamp");
888 let blob = serialize_state(42, &[], &[], &[]);
889 write_sample(&user, "x.trucepreset", &meta("", "Handmade", ""), &blob);
890
891 let refs = store.enumerate();
892 let stamped = &refs[0];
893 assert_eq!(stamped.uuid.len(), 36);
894 let again = store.enumerate();
896 assert_eq!(again[0].uuid, stamped.uuid);
897
898 let _ = std::fs::remove_dir_all(&user);
899 let _ = std::fs::remove_dir_all(&factory);
900 }
901
902 #[test]
903 fn save_load_rename_recategorise_delete() {
904 let (store, user, factory) = store("crud");
905
906 let saved = store
907 .save(meta("", "My Lead", "lead"), &[(0, 0.5), (1, 440.0)], b"x")
908 .unwrap();
909 assert_eq!(saved.scope, PresetScope::User);
910 assert!(saved.path.starts_with(user.join("lead")));
911 assert_eq!(saved.uuid.len(), 36);
912
913 let state = store.load(&saved.uri).unwrap();
914 assert_eq!(state.params, vec![(0, 0.5), (1, 440.0)]);
915
916 let resaved = store
918 .save(meta("", "My Lead", "lead"), &[(0, 0.9)], &[])
919 .unwrap();
920 assert_eq!(resaved.uuid, saved.uuid);
921 assert_eq!(store.enumerate().len(), 1);
922
923 store.rename(&saved.uuid, "Better Lead").unwrap();
924 let renamed = store.find(&saved.uuid).unwrap();
925 assert_eq!(renamed.name, "Better Lead");
926 assert_eq!(renamed.uri, saved.uri);
927
928 store.recategorise(&saved.uuid, "bass").unwrap();
929 let moved = store.find(&saved.uuid).unwrap();
930 assert_eq!(moved.category.as_deref(), Some("bass"));
931 assert!(moved.path.starts_with(user.join("bass")));
932
933 store.delete(&saved.uuid).unwrap();
934 assert!(store.find(&saved.uuid).is_none());
935 assert!(matches!(
936 store.delete(&saved.uuid),
937 Err(PresetError::NotFound)
938 ));
939
940 let _ = std::fs::remove_dir_all(&user);
941 let _ = std::fs::remove_dir_all(&factory);
942 }
943
944 #[test]
945 fn factory_presets_are_read_only() {
946 let (store, user, factory) = store("readonly");
947 let blob = serialize_state(42, &[], &[], &[]);
948 write_sample(&factory, "a.trucepreset", &meta("u1", "A", ""), &blob);
949
950 assert!(matches!(
951 store.rename("u1", "B"),
952 Err(PresetError::ReadOnlyScope)
953 ));
954 assert!(matches!(
955 store.delete("u1"),
956 Err(PresetError::ReadOnlyScope)
957 ));
958
959 let _ = std::fs::remove_dir_all(&user);
960 let _ = std::fs::remove_dir_all(&factory);
961 }
962}