1use std::path::{Path, PathBuf};
19
20use crate::preset::{PresetMeta, write_preset_file};
21use crate::safe_filename;
22use crate::state::{
23 DeserializedState, StateParse, deserialize_state, parse_state, serialize_state,
24};
25
26pub use crate::preset::{PRESET_FILE_EXT, parse_preset_file};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum PresetScope {
34 Factory,
35 User,
36 Pack,
37}
38
39#[derive(Debug, Clone)]
44pub struct PresetRef {
45 pub uuid: String,
49 pub uri: String,
51 pub name: String,
53 pub category: Option<String>,
57 pub author: Option<String>,
58 pub comment: Option<String>,
59 pub tags: Vec<String>,
60 pub default: bool,
62 pub scope: PresetScope,
63 pub path: PathBuf,
65}
66
67#[must_use]
94pub fn user_preset_root(
95 vendor: &str,
96 plugin_name: &str,
97 user_dir: Option<&str>,
98) -> Option<PathBuf> {
99 let override_subpath = user_dir.and_then(sanitize_preset_user_dir);
100 let has_override = override_subpath.is_some();
101 let subpath = override_subpath.unwrap_or_else(|| {
102 PathBuf::from("truce")
103 .join(safe_filename(vendor))
104 .join(safe_filename(plugin_name))
105 });
106
107 #[cfg(target_os = "macos")]
108 {
109 let home = std::env::var_os("HOME")?;
110 let _ = has_override;
111 Some(
112 PathBuf::from(home)
113 .join("Library/Audio/Presets")
114 .join(subpath),
115 )
116 }
117 #[cfg(target_os = "windows")]
118 {
119 let appdata = std::env::var_os("APPDATA")?;
120 let mut root = PathBuf::from(appdata).join(subpath);
121 if !has_override {
122 root.push("presets");
123 }
124 Some(root)
125 }
126 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
127 {
128 let data_home = std::env::var_os("XDG_DATA_HOME")
129 .map(PathBuf::from)
130 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
131 let mut root = data_home;
132 if has_override {
133 root.push("truce");
134 }
135 root.push(subpath);
136 if !has_override {
137 root.push("presets");
138 }
139 Some(root)
140 }
141}
142
143#[must_use]
158pub fn sanitize_preset_user_dir(raw: &str) -> Option<PathBuf> {
159 let mut out = PathBuf::new();
160 for segment in raw.split(['/', '\\']) {
161 let segment = segment.trim();
162 if segment.is_empty() || segment == "." {
163 continue;
164 }
165 if segment == ".." {
166 return None;
167 }
168 let safe = safe_filename(segment);
169 if !safe.is_empty() {
170 out.push(safe);
171 }
172 }
173 (!out.as_os_str().is_empty()).then_some(out)
174}
175
176#[must_use]
184pub fn preset_uri(vendor: &str, plugin_name: &str, uuid: &str) -> String {
185 format!(
186 "truce-preset://{}/{}/{uuid}",
187 safe_filename(vendor),
188 safe_filename(plugin_name)
189 )
190}
191
192#[must_use]
196pub fn parse_preset_uri(uri: &str) -> Option<(&str, &str, &str)> {
197 let rest = uri.strip_prefix("truce-preset://")?;
198 let mut segments = rest.splitn(3, '/');
199 let vendor = segments.next().filter(|s| !s.is_empty())?;
200 let plugin = segments.next().filter(|s| !s.is_empty())?;
201 let uuid = segments.next().filter(|s| !s.is_empty())?;
202 Some((vendor, plugin, uuid))
203}
204
205#[must_use]
209pub fn mint_uuid() -> String {
210 use std::fmt::Write as _;
211 use std::hash::{BuildHasher, Hasher};
212 let mix = |salt: u64| {
213 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
214 h.write_u64(salt);
215 let nanos = std::time::SystemTime::now()
216 .duration_since(std::time::UNIX_EPOCH)
217 .map_or(0, |d| u64::from(d.subsec_nanos()));
218 h.finish() ^ nanos.rotate_left(17)
219 };
220 let mut bytes = [0u8; 16];
221 bytes[..8].copy_from_slice(&mix(0x9e37_79b9).to_le_bytes());
222 bytes[8..].copy_from_slice(&mix(0x85eb_ca6b).to_le_bytes());
223 bytes[6] = (bytes[6] & 0x0f) | 0x40;
225 bytes[8] = (bytes[8] & 0x3f) | 0x80;
226
227 let mut out = String::with_capacity(36);
228 for (i, b) in bytes.iter().enumerate() {
229 if matches!(i, 4 | 6 | 8 | 10) {
230 out.push('-');
231 }
232 let _ = write!(out, "{b:02x}");
233 }
234 out
235}
236
237#[must_use]
246pub fn enumerate_scope(
247 root: &Path,
248 scope: PresetScope,
249 vendor: &str,
250 plugin_name: &str,
251 plugin_id_hash: u64,
252) -> Vec<PresetRef> {
253 let ctx = WalkScope {
254 scope,
255 vendor,
256 plugin_name,
257 plugin_id_hash,
258 };
259 let mut out = Vec::new();
260 walk(root, root, &ctx, &mut out, 0);
261 out
262}
263
264struct WalkScope<'a> {
266 scope: PresetScope,
267 vendor: &'a str,
268 plugin_name: &'a str,
269 plugin_id_hash: u64,
270}
271
272const MAX_WALK_DEPTH: usize = 6;
277
278fn walk(root: &Path, dir: &Path, ctx: &WalkScope<'_>, out: &mut Vec<PresetRef>, depth: usize) {
279 if depth > MAX_WALK_DEPTH {
280 return;
281 }
282 let Ok(entries) = std::fs::read_dir(dir) else {
283 return;
284 };
285 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
286 entries.sort_by_key(std::fs::DirEntry::file_name);
288 for entry in entries {
289 let path = entry.path();
290 if path.is_dir() {
291 walk(root, &path, ctx, out, depth + 1);
292 } else if path.extension().and_then(|e| e.to_str()) == Some(PRESET_FILE_EXT)
293 && let Some(preset) = read_preset_ref(
294 Some(root),
295 &path,
296 ctx.scope,
297 ctx.vendor,
298 ctx.plugin_name,
299 ctx.plugin_id_hash,
300 )
301 {
302 out.push(preset);
303 }
304 }
305}
306
307#[must_use]
320pub fn read_preset_ref(
321 root: Option<&Path>,
322 path: &Path,
323 scope: PresetScope,
324 vendor: &str,
325 plugin_name: &str,
326 plugin_id_hash: u64,
327) -> Option<PresetRef> {
328 let bytes = std::fs::read(path).ok()?;
329 let (meta, blob) = parse_preset_file(&bytes)?;
330 if !matches!(parse_state(&blob, plugin_id_hash), StateParse::Ok(_)) {
331 return None;
332 }
333
334 let category = if meta.category.is_empty() {
338 path.parent()
339 .filter(|parent| Some(*parent) != root)
340 .and_then(|parent| parent.file_name())
341 .and_then(|n| n.to_str())
342 .map(str::to_string)
343 } else {
344 Some(meta.category)
345 };
346
347 let none_if_empty = |s: String| if s.is_empty() { None } else { Some(s) };
348 Some(PresetRef {
349 uri: preset_uri(vendor, plugin_name, &meta.uuid),
350 uuid: meta.uuid,
351 name: meta.name,
352 category,
353 author: none_if_empty(meta.author),
354 comment: none_if_empty(meta.comment),
355 tags: meta.tags,
356 default: meta.default,
357 scope,
358 path: path.to_path_buf(),
359 })
360}
361
362#[must_use]
367pub fn load_preset_file(path: &Path, plugin_id_hash: u64) -> Option<DeserializedState> {
368 let bytes = std::fs::read(path).ok()?;
369 let (_, blob) = parse_preset_file(&bytes)?;
370 deserialize_state(&blob, plugin_id_hash)
371}
372
373#[derive(Debug)]
379pub enum PresetError {
380 NotFound,
382 ReadOnlyScope,
387 NoUserDirectory,
390 InvalidState,
393 InvalidName,
395 Io(std::io::Error),
396}
397
398impl std::fmt::Display for PresetError {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 match self {
401 Self::NotFound => f.write_str("preset not found"),
402 Self::ReadOnlyScope => f.write_str("factory / pack presets are read-only"),
403 Self::NoUserDirectory => f.write_str("user preset directory could not be resolved"),
404 Self::InvalidState => f.write_str("preset state is malformed or from another plugin"),
405 Self::InvalidName => f.write_str("preset name is empty"),
406 Self::Io(e) => write!(f, "preset io: {e}"),
407 }
408 }
409}
410
411impl std::error::Error for PresetError {}
412
413impl From<std::io::Error> for PresetError {
414 fn from(e: std::io::Error) -> Self {
415 Self::Io(e)
416 }
417}
418
419pub struct PresetStore {
428 vendor: String,
429 plugin_name: String,
430 plugin_id_hash: u64,
431 factory_root: Option<PathBuf>,
432 user_root: Option<PathBuf>,
433}
434
435impl PresetStore {
436 #[must_use]
444 pub fn new(
445 vendor: &str,
446 plugin_name: &str,
447 plugin_id_hash: u64,
448 user_dir: Option<&str>,
449 ) -> Self {
450 Self {
451 vendor: vendor.to_string(),
452 plugin_name: plugin_name.to_string(),
453 plugin_id_hash,
454 factory_root: None,
455 user_root: user_preset_root(vendor, plugin_name, user_dir),
456 }
457 }
458
459 #[must_use]
460 pub fn with_factory_root(mut self, root: impl Into<PathBuf>) -> Self {
461 self.factory_root = Some(root.into());
462 self
463 }
464
465 #[must_use]
468 pub fn with_user_root(mut self, root: impl Into<PathBuf>) -> Self {
469 self.user_root = Some(root.into());
470 self
471 }
472
473 #[must_use]
474 pub fn user_root(&self) -> Option<&Path> {
475 self.user_root.as_deref()
476 }
477
478 #[must_use]
488 pub fn enumerate(&self) -> Vec<PresetRef> {
489 let mut user: Vec<PresetRef> = Vec::new();
490 let mut packs: Vec<PresetRef> = Vec::new();
491 if let Some(root) = &self.user_root {
492 let packs_root = root.join("packs");
493 for mut preset in enumerate_scope(
494 root,
495 PresetScope::User,
496 &self.vendor,
497 &self.plugin_name,
498 self.plugin_id_hash,
499 ) {
500 if preset.uuid.is_empty() {
501 self.stamp_uuid(&mut preset);
502 }
503 if preset.path.starts_with(&packs_root) {
504 preset.scope = PresetScope::Pack;
505 packs.push(preset);
506 } else {
507 user.push(preset);
508 }
509 }
510 }
511 let factory = self.factory_root.as_ref().map_or_else(Vec::new, |root| {
512 enumerate_scope(
513 root,
514 PresetScope::Factory,
515 &self.vendor,
516 &self.plugin_name,
517 self.plugin_id_hash,
518 )
519 });
520
521 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
524 let mut out: Vec<PresetRef> = Vec::new();
525 for preset in user.into_iter().chain(packs).chain(factory) {
526 if !preset.uuid.is_empty() && !seen.insert(preset.uuid.clone()) {
527 continue;
528 }
529 out.push(preset);
530 }
531 out.sort_by(|a, b| {
532 scope_rank(a.scope)
533 .cmp(&scope_rank(b.scope))
534 .then_with(|| a.category.cmp(&b.category))
535 .then_with(|| a.name.cmp(&b.name))
536 });
537 out
538 }
539
540 fn stamp_uuid(&self, preset: &mut PresetRef) {
544 let Ok(bytes) = std::fs::read(&preset.path) else {
545 return;
546 };
547 let Some((mut meta, blob)) = parse_preset_file(&bytes) else {
548 return;
549 };
550 meta.uuid = mint_uuid();
551 if std::fs::write(&preset.path, write_preset_file(&meta, &blob)).is_ok() {
552 preset.uri = preset_uri(&self.vendor, &self.plugin_name, &meta.uuid);
553 preset.uuid = meta.uuid;
554 }
555 }
556
557 #[must_use]
563 pub fn find(&self, sel: &str) -> Option<PresetRef> {
564 let uuid = if let Some((vendor, plugin, uuid)) = parse_preset_uri(sel) {
565 if vendor != safe_filename(&self.vendor) || plugin != safe_filename(&self.plugin_name) {
566 return None;
567 }
568 uuid
569 } else {
570 sel
571 };
572 if uuid.is_empty() {
573 return None;
574 }
575 let presets = self.enumerate();
576 presets
577 .iter()
578 .find(|p| p.uuid == uuid)
579 .or_else(|| presets.iter().find(|p| p.name == sel))
580 .cloned()
581 }
582
583 pub fn load(&self, uri_or_uuid: &str) -> Result<DeserializedState, PresetError> {
592 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
593 load_preset_file(&preset.path, self.plugin_id_hash).ok_or(PresetError::InvalidState)
594 }
595
596 pub fn save(
611 &self,
612 mut meta: PresetMeta,
613 params: &[(u32, f64)],
614 extra: &[u8],
615 ) -> Result<PresetRef, PresetError> {
616 let user_root = self
617 .user_root
618 .as_ref()
619 .ok_or(PresetError::NoUserDirectory)?;
620 let file_stem = safe_filename(&meta.name);
621 if file_stem.is_empty() {
622 return Err(PresetError::InvalidName);
623 }
624
625 let existing = self.enumerate().into_iter().find(|p| {
626 p.scope == PresetScope::User
627 && p.name == meta.name
628 && p.category.as_deref().unwrap_or_default()
629 == if meta.category.is_empty() {
630 ""
631 } else {
632 meta.category.as_str()
633 }
634 });
635 let path = if let Some(existing) = &existing {
636 meta.uuid.clone_from(&existing.uuid);
637 existing.path.clone()
638 } else {
639 if meta.uuid.is_empty() {
640 meta.uuid = mint_uuid();
641 }
642 let dir = if meta.category.is_empty() {
643 user_root.clone()
644 } else {
645 user_root.join(safe_filename(&meta.category))
646 };
647 dir.join(format!("{file_stem}.{PRESET_FILE_EXT}"))
648 };
649
650 let ids: Vec<u32> = params.iter().map(|(id, _)| *id).collect();
651 let values: Vec<f64> = params.iter().map(|(_, v)| *v).collect();
652 let blob = serialize_state(self.plugin_id_hash, &ids, &values, extra);
653
654 if let Some(parent) = path.parent() {
655 std::fs::create_dir_all(parent)?;
656 }
657 std::fs::write(&path, write_preset_file(&meta, &blob))?;
658 read_preset_ref(
659 self.user_root.as_deref(),
660 &path,
661 PresetScope::User,
662 &self.vendor,
663 &self.plugin_name,
664 self.plugin_id_hash,
665 )
666 .ok_or(PresetError::InvalidState)
667 }
668
669 pub fn rename(&self, uri_or_uuid: &str, new_name: &str) -> Result<(), PresetError> {
679 let preset = self.user_preset(uri_or_uuid)?;
680 rewrite_meta(&preset.path, |meta| new_name.clone_into(&mut meta.name))
681 }
682
683 pub fn recategorise(&self, uri_or_uuid: &str, new_category: &str) -> Result<(), PresetError> {
693 let user_root = self
694 .user_root
695 .as_ref()
696 .ok_or(PresetError::NoUserDirectory)?;
697 let preset = self.user_preset(uri_or_uuid)?;
698
699 rewrite_meta(&preset.path, |meta| {
700 new_category.clone_into(&mut meta.category);
701 })?;
702
703 let dir = if new_category.is_empty() {
704 user_root.clone()
705 } else {
706 user_root.join(safe_filename(new_category))
707 };
708 let file_name = preset
709 .path
710 .file_name()
711 .ok_or(PresetError::InvalidState)?
712 .to_os_string();
713 let dest = dir.join(file_name);
714 if dest != preset.path {
715 std::fs::create_dir_all(&dir)?;
716 std::fs::rename(&preset.path, &dest)?;
717 }
718 Ok(())
719 }
720
721 pub fn delete(&self, uri_or_uuid: &str) -> Result<(), PresetError> {
728 let preset = self.user_preset(uri_or_uuid)?;
729 std::fs::remove_file(&preset.path)?;
730 Ok(())
731 }
732
733 fn user_preset(&self, uri_or_uuid: &str) -> Result<PresetRef, PresetError> {
734 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
735 if preset.scope != PresetScope::User {
736 return Err(PresetError::ReadOnlyScope);
737 }
738 Ok(preset)
739 }
740}
741
742fn rewrite_meta(path: &Path, edit: impl FnOnce(&mut PresetMeta)) -> Result<(), PresetError> {
743 let bytes = std::fs::read(path)?;
744 let (mut meta, blob) = parse_preset_file(&bytes).ok_or(PresetError::InvalidState)?;
745 edit(&mut meta);
746 std::fs::write(path, write_preset_file(&meta, &blob))?;
747 Ok(())
748}
749
750fn scope_rank(scope: PresetScope) -> u8 {
751 match scope {
752 PresetScope::Factory => 0,
753 PresetScope::User => 1,
754 PresetScope::Pack => 2,
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 const TEST_HASH: u64 = 42;
764
765 fn write_sample(dir: &Path, rel: &str, meta: &PresetMeta, hash: u64) {
766 let path = dir.join(rel);
767 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
768 let blob = serialize_state(hash, &[0], &[0.5], b"");
769 std::fs::write(path, write_preset_file(meta, &blob)).unwrap();
770 }
771
772 fn temp_dir(name: &str) -> PathBuf {
773 let dir = std::env::temp_dir().join(format!("truce-presets-{name}"));
774 let _ = std::fs::remove_dir_all(&dir);
775 std::fs::create_dir_all(&dir).unwrap();
776 dir
777 }
778
779 fn store(name: &str) -> (PresetStore, PathBuf, PathBuf) {
780 let user = temp_dir(&format!("{name}-user"));
781 let factory = temp_dir(&format!("{name}-factory"));
782 let store = PresetStore::new("Acme", "Synth", 42, None)
783 .with_user_root(&user)
784 .with_factory_root(&factory);
785 (store, user, factory)
786 }
787
788 fn meta(uuid: &str, name: &str, category: &str) -> PresetMeta {
789 PresetMeta {
790 uuid: uuid.into(),
791 name: name.into(),
792 category: category.into(),
793 ..PresetMeta::default()
794 }
795 }
796
797 #[test]
798 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
799 fn enumerates_with_directory_category_fallback() {
800 let tmp = temp_dir("enum");
801 write_sample(
802 &tmp,
803 "pad/a.trucepreset",
804 &meta("u1", "A", "Lead"),
805 TEST_HASH,
806 );
807 write_sample(&tmp, "pad/b.trucepreset", &meta("u2", "B", ""), TEST_HASH);
808 write_sample(&tmp, "c.trucepreset", &meta("u3", "C", ""), TEST_HASH);
809 std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
811 write_sample(
814 &tmp,
815 "pad/foreign.trucepreset",
816 &meta("u4", "F", ""),
817 TEST_HASH ^ 1,
818 );
819
820 let refs = enumerate_scope(&tmp, PresetScope::Factory, "Acme", "Synth", TEST_HASH);
821 assert_eq!(refs.len(), 3);
822 let by_uuid = |u: &str| refs.iter().find(|r| r.uuid == u).unwrap();
823 assert_eq!(by_uuid("u1").category.as_deref(), Some("Lead"));
824 assert_eq!(by_uuid("u2").category.as_deref(), Some("pad"));
825 assert_eq!(by_uuid("u3").category, None);
826 assert_eq!(by_uuid("u1").uri, "truce-preset://Acme/Synth/u1");
827 assert_eq!(by_uuid("u1").scope, PresetScope::Factory);
828
829 let _ = std::fs::remove_dir_all(&tmp);
830 }
831
832 #[test]
833 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
834 fn missing_root_is_empty() {
835 let refs = enumerate_scope(
836 Path::new("/nonexistent/truce-presets"),
837 PresetScope::User,
838 "V",
839 "P",
840 TEST_HASH,
841 );
842 assert!(refs.is_empty());
843 }
844
845 #[test]
846 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
847 fn load_validates_plugin_hash() {
848 let tmp = temp_dir("load");
849 let hash = crate::state::hash_plugin_id("com.acme.synth");
850 let blob = serialize_state(hash, &[0, 1], &[0.25, 8200.0], b"xs");
851 let path = tmp.join("loadable.trucepreset");
852 std::fs::write(&path, write_preset_file(&meta("u9", "Loadable", ""), &blob)).unwrap();
853
854 let state = load_preset_file(&path, hash).unwrap();
855 assert_eq!(state.params, vec![(0, 0.25), (1, 8200.0)]);
856 assert_eq!(state.extra.as_deref(), Some(&b"xs"[..]));
857 assert!(load_preset_file(&path, hash ^ 1).is_none());
858
859 let _ = std::fs::remove_dir_all(&tmp);
860 }
861
862 #[test]
863 fn sanitize_user_dir_rules() {
864 let ok = |raw: &str, want: &str| {
865 assert_eq!(
866 sanitize_preset_user_dir(raw),
867 Some(PathBuf::from(want)),
868 "{raw}"
869 );
870 };
871 ok("Acme/MySynth", "Acme/MySynth");
872 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);
879 assert_eq!(sanitize_preset_user_dir("a/../b"), None);
880 assert_eq!(sanitize_preset_user_dir(""), None);
881 assert_eq!(sanitize_preset_user_dir("///"), None);
882 assert_eq!(sanitize_preset_user_dir("..."), None);
884 }
885
886 #[test]
887 #[cfg_attr(
888 miri,
889 ignore = "resolves the real home dir; absent in Miri's isolated env"
890 )]
891 fn user_root_honours_override() {
892 let default = user_preset_root("Acme", "My Synth", None).unwrap();
893 let overridden = user_preset_root("Acme", "My Synth", Some("AcmeAudio/Synth")).unwrap();
894 let unusable = user_preset_root("Acme", "My Synth", Some("../nope")).unwrap();
895
896 assert!(
897 default.ends_with("truce/Acme/My Synth")
898 || default.ends_with("truce/Acme/My Synth/presets")
899 );
900 assert!(overridden.to_string_lossy().contains("AcmeAudio"));
901 assert!(overridden.ends_with("AcmeAudio/Synth"));
902 assert_eq!(unusable, default);
904 }
905
906 #[test]
907 fn uri_round_trips() {
908 let uri = preset_uri("Acme Co", "My Synth", "u-1");
909 assert_eq!(parse_preset_uri(&uri), Some(("Acme Co", "My Synth", "u-1")));
910 assert_eq!(parse_preset_uri("nope://x/y/z"), None);
911 assert_eq!(parse_preset_uri("truce-preset://only/two"), None);
912 }
913
914 #[test]
915 #[cfg_attr(miri, ignore = "reads the realtime clock; Miri isolation rejects it")]
916 fn mint_uuid_is_v4_shaped_and_distinct() {
917 let a = mint_uuid();
918 let b = mint_uuid();
919 assert_eq!(a.len(), 36);
920 assert_eq!(&a[14..15], "4");
921 assert_ne!(a, b);
922 }
923
924 #[test]
925 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
926 fn user_overrides_factory_and_packs_classify() {
927 let (store, user, factory) = store("dedup");
928 write_sample(
929 &factory,
930 "lead/a.trucepreset",
931 &meta("u1", "A", ""),
932 TEST_HASH,
933 );
934 write_sample(
935 &factory,
936 "lead/b.trucepreset",
937 &meta("u2", "B", ""),
938 TEST_HASH,
939 );
940 write_sample(
942 &user,
943 "my/a2.trucepreset",
944 &meta("u1", "A edited", ""),
945 TEST_HASH,
946 );
947 write_sample(
948 &user,
949 "packs/edm/lead/p.trucepreset",
950 &meta("u4", "P", ""),
951 TEST_HASH,
952 );
953
954 let refs = store.enumerate();
955 assert_eq!(refs.len(), 3);
956 let a = refs.iter().find(|r| r.uuid == "u1").unwrap();
957 assert_eq!(a.scope, PresetScope::User);
958 assert_eq!(a.name, "A edited");
959 assert_eq!(
960 refs.iter().find(|r| r.uuid == "u4").unwrap().scope,
961 PresetScope::Pack
962 );
963
964 let _ = std::fs::remove_dir_all(&user);
965 let _ = std::fs::remove_dir_all(&factory);
966 }
967
968 #[test]
969 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
970 fn stamps_missing_uuid_on_user_files() {
971 let (store, user, factory) = store("stamp");
972 write_sample(&user, "x.trucepreset", &meta("", "Handmade", ""), TEST_HASH);
973
974 let refs = store.enumerate();
975 let stamped = &refs[0];
976 assert_eq!(stamped.uuid.len(), 36);
977 let again = store.enumerate();
979 assert_eq!(again[0].uuid, stamped.uuid);
980
981 let _ = std::fs::remove_dir_all(&user);
982 let _ = std::fs::remove_dir_all(&factory);
983 }
984
985 #[test]
986 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
987 fn save_load_rename_recategorise_delete() {
988 let (store, user, factory) = store("crud");
989
990 let saved = store
991 .save(meta("", "My Lead", "lead"), &[(0, 0.5), (1, 440.0)], b"x")
992 .unwrap();
993 assert_eq!(saved.scope, PresetScope::User);
994 assert!(saved.path.starts_with(user.join("lead")));
995 assert_eq!(saved.uuid.len(), 36);
996
997 let state = store.load(&saved.uri).unwrap();
998 assert_eq!(state.params, vec![(0, 0.5), (1, 440.0)]);
999
1000 assert_eq!(store.find(&saved.uri).unwrap().uuid, saved.uuid);
1002 assert_eq!(store.find(&saved.uuid).unwrap().uuid, saved.uuid);
1003 assert_eq!(store.find("My Lead").unwrap().uuid, saved.uuid);
1004 assert!(store.find("nonexistent").is_none());
1005
1006 let resaved = store
1008 .save(meta("", "My Lead", "lead"), &[(0, 0.9)], &[])
1009 .unwrap();
1010 assert_eq!(resaved.uuid, saved.uuid);
1011 assert_eq!(store.enumerate().len(), 1);
1012
1013 store.rename(&saved.uuid, "Better Lead").unwrap();
1014 let renamed = store.find(&saved.uuid).unwrap();
1015 assert_eq!(renamed.name, "Better Lead");
1016 assert_eq!(renamed.uri, saved.uri);
1017
1018 store.recategorise(&saved.uuid, "bass").unwrap();
1019 let moved = store.find(&saved.uuid).unwrap();
1020 assert_eq!(moved.category.as_deref(), Some("bass"));
1021 assert!(moved.path.starts_with(user.join("bass")));
1022
1023 store.delete(&saved.uuid).unwrap();
1024 assert!(store.find(&saved.uuid).is_none());
1025 assert!(matches!(
1026 store.delete(&saved.uuid),
1027 Err(PresetError::NotFound)
1028 ));
1029
1030 let _ = std::fs::remove_dir_all(&user);
1031 let _ = std::fs::remove_dir_all(&factory);
1032 }
1033
1034 #[test]
1035 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
1036 fn factory_presets_are_read_only() {
1037 let (store, user, factory) = store("readonly");
1038 write_sample(&factory, "a.trucepreset", &meta("u1", "A", ""), TEST_HASH);
1039
1040 assert!(matches!(
1041 store.rename("u1", "B"),
1042 Err(PresetError::ReadOnlyScope)
1043 ));
1044 assert!(matches!(
1045 store.delete("u1"),
1046 Err(PresetError::ReadOnlyScope)
1047 ));
1048
1049 let _ = std::fs::remove_dir_all(&user);
1050 let _ = std::fs::remove_dir_all(&factory);
1051 }
1052}