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]
95pub fn user_preset_root(
96 vendor: &str,
97 plugin_name: &str,
98 user_dir: Option<&str>,
99) -> Option<PathBuf> {
100 let override_subpath = user_dir.and_then(sanitize_preset_user_dir);
101 let has_override = override_subpath.is_some();
102 let subpath = override_subpath.unwrap_or_else(|| {
103 PathBuf::from("truce")
104 .join(safe_filename(vendor))
105 .join(safe_filename(plugin_name))
106 });
107
108 #[cfg(target_os = "ios")]
109 {
110 let app_support = ios_application_support_dir()?;
111 let mut root = app_support.join(subpath);
112 if !has_override {
113 root.push("presets");
114 }
115 Some(root)
116 }
117 #[cfg(target_os = "macos")]
118 {
119 let home = std::env::var_os("HOME")?;
120 let _ = has_override;
121 Some(
122 PathBuf::from(home)
123 .join("Library/Audio/Presets")
124 .join(subpath),
125 )
126 }
127 #[cfg(target_os = "windows")]
128 {
129 let appdata = std::env::var_os("APPDATA")?;
130 let mut root = PathBuf::from(appdata).join(subpath);
131 if !has_override {
132 root.push("presets");
133 }
134 Some(root)
135 }
136 #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "windows")))]
137 {
138 let data_home = std::env::var_os("XDG_DATA_HOME")
139 .map(PathBuf::from)
140 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
141 let mut root = data_home;
142 if has_override {
143 root.push("truce");
144 }
145 root.push(subpath);
146 if !has_override {
147 root.push("presets");
148 }
149 Some(root)
150 }
151}
152
153#[cfg(target_os = "ios")]
154fn ios_application_support_dir() -> Option<PathBuf> {
155 ios_container_home(std::env::var_os("TMPDIR"), std::env::var_os("HOME"))
156 .map(|home| home.join("Library/Application Support"))
157}
158
159#[cfg(any(target_os = "ios", test))]
163fn ios_container_home(
164 tmpdir: Option<std::ffi::OsString>,
165 home: Option<std::ffi::OsString>,
166) -> Option<PathBuf> {
167 if let Some(tmpdir) = tmpdir {
168 let tmpdir = PathBuf::from(tmpdir);
169 if tmpdir.file_name().and_then(|s| s.to_str()) == Some("tmp")
170 && let Some(parent) = tmpdir.parent()
171 {
172 return Some(parent.to_path_buf());
173 }
174 }
175 home.map(PathBuf::from)
176}
177
178#[must_use]
193pub fn sanitize_preset_user_dir(raw: &str) -> Option<PathBuf> {
194 let mut out = PathBuf::new();
195 for segment in raw.split(['/', '\\']) {
196 let segment = segment.trim();
197 if segment.is_empty() || segment == "." {
198 continue;
199 }
200 if segment == ".." {
201 return None;
202 }
203 let safe = safe_filename(segment);
204 if !safe.is_empty() {
205 out.push(safe);
206 }
207 }
208 (!out.as_os_str().is_empty()).then_some(out)
209}
210
211#[must_use]
219pub fn preset_uri(vendor: &str, plugin_name: &str, uuid: &str) -> String {
220 format!(
221 "truce-preset://{}/{}/{uuid}",
222 safe_filename(vendor),
223 safe_filename(plugin_name)
224 )
225}
226
227#[must_use]
231pub fn parse_preset_uri(uri: &str) -> Option<(&str, &str, &str)> {
232 let rest = uri.strip_prefix("truce-preset://")?;
233 let mut segments = rest.splitn(3, '/');
234 let vendor = segments.next().filter(|s| !s.is_empty())?;
235 let plugin = segments.next().filter(|s| !s.is_empty())?;
236 let uuid = segments.next().filter(|s| !s.is_empty())?;
237 Some((vendor, plugin, uuid))
238}
239
240#[must_use]
244pub fn mint_uuid() -> String {
245 use std::fmt::Write as _;
246 use std::hash::{BuildHasher, Hasher};
247 let mix = |salt: u64| {
248 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
249 h.write_u64(salt);
250 let nanos = std::time::SystemTime::now()
251 .duration_since(std::time::UNIX_EPOCH)
252 .map_or(0, |d| u64::from(d.subsec_nanos()));
253 h.finish() ^ nanos.rotate_left(17)
254 };
255 let mut bytes = [0u8; 16];
256 bytes[..8].copy_from_slice(&mix(0x9e37_79b9).to_le_bytes());
257 bytes[8..].copy_from_slice(&mix(0x85eb_ca6b).to_le_bytes());
258 bytes[6] = (bytes[6] & 0x0f) | 0x40;
260 bytes[8] = (bytes[8] & 0x3f) | 0x80;
261
262 let mut out = String::with_capacity(36);
263 for (i, b) in bytes.iter().enumerate() {
264 if matches!(i, 4 | 6 | 8 | 10) {
265 out.push('-');
266 }
267 let _ = write!(out, "{b:02x}");
268 }
269 out
270}
271
272#[must_use]
281pub fn enumerate_scope(
282 root: &Path,
283 scope: PresetScope,
284 vendor: &str,
285 plugin_name: &str,
286 plugin_id_hash: u64,
287) -> Vec<PresetRef> {
288 let ctx = WalkScope {
289 scope,
290 vendor,
291 plugin_name,
292 plugin_id_hash,
293 };
294 let mut out = Vec::new();
295 walk(root, root, &ctx, &mut out, 0);
296 out
297}
298
299struct WalkScope<'a> {
301 scope: PresetScope,
302 vendor: &'a str,
303 plugin_name: &'a str,
304 plugin_id_hash: u64,
305}
306
307const MAX_WALK_DEPTH: usize = 6;
312
313fn walk(root: &Path, dir: &Path, ctx: &WalkScope<'_>, out: &mut Vec<PresetRef>, depth: usize) {
314 if depth > MAX_WALK_DEPTH {
315 return;
316 }
317 let Ok(entries) = std::fs::read_dir(dir) else {
318 return;
319 };
320 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
321 entries.sort_by_key(std::fs::DirEntry::file_name);
323 for entry in entries {
324 let path = entry.path();
325 if path.is_dir() {
326 walk(root, &path, ctx, out, depth + 1);
327 } else if path.extension().and_then(|e| e.to_str()) == Some(PRESET_FILE_EXT)
328 && let Some(preset) = read_preset_ref(
329 Some(root),
330 &path,
331 ctx.scope,
332 ctx.vendor,
333 ctx.plugin_name,
334 ctx.plugin_id_hash,
335 )
336 {
337 out.push(preset);
338 }
339 }
340}
341
342#[must_use]
355pub fn read_preset_ref(
356 root: Option<&Path>,
357 path: &Path,
358 scope: PresetScope,
359 vendor: &str,
360 plugin_name: &str,
361 plugin_id_hash: u64,
362) -> Option<PresetRef> {
363 let bytes = std::fs::read(path).ok()?;
364 let (meta, blob) = parse_preset_file(&bytes)?;
365 if !matches!(parse_state(&blob, plugin_id_hash), StateParse::Ok(_)) {
366 return None;
367 }
368
369 let category = if meta.category.is_empty() {
373 path.parent()
374 .filter(|parent| Some(*parent) != root)
375 .and_then(|parent| parent.file_name())
376 .and_then(|n| n.to_str())
377 .map(str::to_string)
378 } else {
379 Some(meta.category)
380 };
381
382 let none_if_empty = |s: String| if s.is_empty() { None } else { Some(s) };
383 Some(PresetRef {
384 uri: preset_uri(vendor, plugin_name, &meta.uuid),
385 uuid: meta.uuid,
386 name: meta.name,
387 category,
388 author: none_if_empty(meta.author),
389 comment: none_if_empty(meta.comment),
390 tags: meta.tags,
391 default: meta.default,
392 scope,
393 path: path.to_path_buf(),
394 })
395}
396
397#[must_use]
402pub fn load_preset_file(path: &Path, plugin_id_hash: u64) -> Option<DeserializedState> {
403 let bytes = std::fs::read(path).ok()?;
404 let (_, blob) = parse_preset_file(&bytes)?;
405 deserialize_state(&blob, plugin_id_hash)
406}
407
408#[derive(Debug)]
414pub enum PresetError {
415 NotFound,
417 ReadOnlyScope,
422 NoUserDirectory,
425 InvalidState,
428 InvalidName,
430 Io(std::io::Error),
431}
432
433impl std::fmt::Display for PresetError {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 match self {
436 Self::NotFound => f.write_str("preset not found"),
437 Self::ReadOnlyScope => f.write_str("factory / pack presets are read-only"),
438 Self::NoUserDirectory => f.write_str("user preset directory could not be resolved"),
439 Self::InvalidState => f.write_str("preset state is malformed or from another plugin"),
440 Self::InvalidName => f.write_str("preset name is empty"),
441 Self::Io(e) => write!(f, "preset io: {e}"),
442 }
443 }
444}
445
446impl std::error::Error for PresetError {}
447
448impl From<std::io::Error> for PresetError {
449 fn from(e: std::io::Error) -> Self {
450 Self::Io(e)
451 }
452}
453
454pub struct PresetStore {
463 vendor: String,
464 plugin_name: String,
465 plugin_id_hash: u64,
466 factory_root: Option<PathBuf>,
467 user_root: Option<PathBuf>,
468}
469
470impl PresetStore {
471 #[must_use]
479 pub fn new(
480 vendor: &str,
481 plugin_name: &str,
482 plugin_id_hash: u64,
483 user_dir: Option<&str>,
484 ) -> Self {
485 Self {
486 vendor: vendor.to_string(),
487 plugin_name: plugin_name.to_string(),
488 plugin_id_hash,
489 factory_root: None,
490 user_root: user_preset_root(vendor, plugin_name, user_dir),
491 }
492 }
493
494 #[must_use]
495 pub fn with_factory_root(mut self, root: impl Into<PathBuf>) -> Self {
496 self.factory_root = Some(root.into());
497 self
498 }
499
500 #[must_use]
503 pub fn with_user_root(mut self, root: impl Into<PathBuf>) -> Self {
504 self.user_root = Some(root.into());
505 self
506 }
507
508 #[must_use]
509 pub fn user_root(&self) -> Option<&Path> {
510 self.user_root.as_deref()
511 }
512
513 #[must_use]
523 pub fn enumerate(&self) -> Vec<PresetRef> {
524 let mut user: Vec<PresetRef> = Vec::new();
525 let mut packs: Vec<PresetRef> = Vec::new();
526 if let Some(root) = &self.user_root {
527 let packs_root = root.join("packs");
528 for mut preset in enumerate_scope(
529 root,
530 PresetScope::User,
531 &self.vendor,
532 &self.plugin_name,
533 self.plugin_id_hash,
534 ) {
535 if preset.uuid.is_empty() {
536 self.stamp_uuid(&mut preset);
537 }
538 if preset.path.starts_with(&packs_root) {
539 preset.scope = PresetScope::Pack;
540 packs.push(preset);
541 } else {
542 user.push(preset);
543 }
544 }
545 }
546 let factory = self.factory_root.as_ref().map_or_else(Vec::new, |root| {
547 enumerate_scope(
548 root,
549 PresetScope::Factory,
550 &self.vendor,
551 &self.plugin_name,
552 self.plugin_id_hash,
553 )
554 });
555
556 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
559 let mut out: Vec<PresetRef> = Vec::new();
560 for preset in user.into_iter().chain(packs).chain(factory) {
561 if !preset.uuid.is_empty() && !seen.insert(preset.uuid.clone()) {
562 continue;
563 }
564 out.push(preset);
565 }
566 out.sort_by(|a, b| {
567 scope_rank(a.scope)
568 .cmp(&scope_rank(b.scope))
569 .then_with(|| a.category.cmp(&b.category))
570 .then_with(|| a.name.cmp(&b.name))
571 });
572 out
573 }
574
575 fn stamp_uuid(&self, preset: &mut PresetRef) {
579 let Ok(bytes) = std::fs::read(&preset.path) else {
580 return;
581 };
582 let Some((mut meta, blob)) = parse_preset_file(&bytes) else {
583 return;
584 };
585 meta.uuid = mint_uuid();
586 if std::fs::write(&preset.path, write_preset_file(&meta, &blob)).is_ok() {
587 preset.uri = preset_uri(&self.vendor, &self.plugin_name, &meta.uuid);
588 preset.uuid = meta.uuid;
589 }
590 }
591
592 #[must_use]
598 pub fn find(&self, sel: &str) -> Option<PresetRef> {
599 let uuid = if let Some((vendor, plugin, uuid)) = parse_preset_uri(sel) {
600 if vendor != safe_filename(&self.vendor) || plugin != safe_filename(&self.plugin_name) {
601 return None;
602 }
603 uuid
604 } else {
605 sel
606 };
607 if uuid.is_empty() {
608 return None;
609 }
610 let presets = self.enumerate();
611 presets
612 .iter()
613 .find(|p| p.uuid == uuid)
614 .or_else(|| presets.iter().find(|p| p.name == sel))
615 .cloned()
616 }
617
618 pub fn load(&self, uri_or_uuid: &str) -> Result<DeserializedState, PresetError> {
627 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
628 load_preset_file(&preset.path, self.plugin_id_hash).ok_or(PresetError::InvalidState)
629 }
630
631 pub fn save(
646 &self,
647 mut meta: PresetMeta,
648 params: &[(u32, f64)],
649 extra: &[u8],
650 ) -> Result<PresetRef, PresetError> {
651 let user_root = self
652 .user_root
653 .as_ref()
654 .ok_or(PresetError::NoUserDirectory)?;
655 let file_stem = safe_filename(&meta.name);
656 if file_stem.is_empty() {
657 return Err(PresetError::InvalidName);
658 }
659
660 let existing = self.enumerate().into_iter().find(|p| {
661 p.scope == PresetScope::User
662 && p.name == meta.name
663 && p.category.as_deref().unwrap_or_default()
664 == if meta.category.is_empty() {
665 ""
666 } else {
667 meta.category.as_str()
668 }
669 });
670 let path = if let Some(existing) = &existing {
671 meta.uuid.clone_from(&existing.uuid);
672 existing.path.clone()
673 } else {
674 if meta.uuid.is_empty() {
675 meta.uuid = mint_uuid();
676 }
677 let dir = if meta.category.is_empty() {
678 user_root.clone()
679 } else {
680 user_root.join(safe_filename(&meta.category))
681 };
682 dir.join(format!("{file_stem}.{PRESET_FILE_EXT}"))
683 };
684
685 let ids: Vec<u32> = params.iter().map(|(id, _)| *id).collect();
686 let values: Vec<f64> = params.iter().map(|(_, v)| *v).collect();
687 let blob = serialize_state(self.plugin_id_hash, &ids, &values, extra, &[]);
688
689 if let Some(parent) = path.parent() {
690 std::fs::create_dir_all(parent)?;
691 }
692 std::fs::write(&path, write_preset_file(&meta, &blob))?;
693 read_preset_ref(
694 self.user_root.as_deref(),
695 &path,
696 PresetScope::User,
697 &self.vendor,
698 &self.plugin_name,
699 self.plugin_id_hash,
700 )
701 .ok_or(PresetError::InvalidState)
702 }
703
704 pub fn rename(&self, uri_or_uuid: &str, new_name: &str) -> Result<(), PresetError> {
714 let preset = self.user_preset(uri_or_uuid)?;
715 rewrite_meta(&preset.path, |meta| new_name.clone_into(&mut meta.name))
716 }
717
718 pub fn recategorise(&self, uri_or_uuid: &str, new_category: &str) -> Result<(), PresetError> {
728 let user_root = self
729 .user_root
730 .as_ref()
731 .ok_or(PresetError::NoUserDirectory)?;
732 let preset = self.user_preset(uri_or_uuid)?;
733
734 rewrite_meta(&preset.path, |meta| {
735 new_category.clone_into(&mut meta.category);
736 })?;
737
738 let dir = if new_category.is_empty() {
739 user_root.clone()
740 } else {
741 user_root.join(safe_filename(new_category))
742 };
743 let file_name = preset
744 .path
745 .file_name()
746 .ok_or(PresetError::InvalidState)?
747 .to_os_string();
748 let dest = dir.join(file_name);
749 if dest != preset.path {
750 std::fs::create_dir_all(&dir)?;
751 std::fs::rename(&preset.path, &dest)?;
752 }
753 Ok(())
754 }
755
756 pub fn delete(&self, uri_or_uuid: &str) -> Result<(), PresetError> {
763 let preset = self.user_preset(uri_or_uuid)?;
764 std::fs::remove_file(&preset.path)?;
765 Ok(())
766 }
767
768 fn user_preset(&self, uri_or_uuid: &str) -> Result<PresetRef, PresetError> {
769 let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
770 if preset.scope != PresetScope::User {
771 return Err(PresetError::ReadOnlyScope);
772 }
773 Ok(preset)
774 }
775}
776
777fn rewrite_meta(path: &Path, edit: impl FnOnce(&mut PresetMeta)) -> Result<(), PresetError> {
778 let bytes = std::fs::read(path)?;
779 let (mut meta, blob) = parse_preset_file(&bytes).ok_or(PresetError::InvalidState)?;
780 edit(&mut meta);
781 std::fs::write(path, write_preset_file(&meta, &blob))?;
782 Ok(())
783}
784
785fn scope_rank(scope: PresetScope) -> u8 {
786 match scope {
787 PresetScope::Factory => 0,
788 PresetScope::User => 1,
789 PresetScope::Pack => 2,
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 const TEST_HASH: u64 = 42;
799
800 fn write_sample(dir: &Path, rel: &str, meta: &PresetMeta, hash: u64) {
801 let path = dir.join(rel);
802 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
803 let blob = serialize_state(hash, &[0], &[0.5], b"", &[]);
804 std::fs::write(path, write_preset_file(meta, &blob)).unwrap();
805 }
806
807 fn temp_dir(name: &str) -> PathBuf {
808 let dir = std::env::temp_dir().join(format!("truce-presets-{name}"));
809 let _ = std::fs::remove_dir_all(&dir);
810 std::fs::create_dir_all(&dir).unwrap();
811 dir
812 }
813
814 fn store(name: &str) -> (PresetStore, PathBuf, PathBuf) {
815 let user = temp_dir(&format!("{name}-user"));
816 let factory = temp_dir(&format!("{name}-factory"));
817 let store = PresetStore::new("Acme", "Synth", 42, None)
818 .with_user_root(&user)
819 .with_factory_root(&factory);
820 (store, user, factory)
821 }
822
823 fn meta(uuid: &str, name: &str, category: &str) -> PresetMeta {
824 PresetMeta {
825 uuid: uuid.into(),
826 name: name.into(),
827 category: category.into(),
828 ..PresetMeta::default()
829 }
830 }
831
832 #[test]
833 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
834 fn enumerates_with_directory_category_fallback() {
835 let tmp = temp_dir("enum");
836 write_sample(
837 &tmp,
838 "pad/a.trucepreset",
839 &meta("u1", "A", "Lead"),
840 TEST_HASH,
841 );
842 write_sample(&tmp, "pad/b.trucepreset", &meta("u2", "B", ""), TEST_HASH);
843 write_sample(&tmp, "c.trucepreset", &meta("u3", "C", ""), TEST_HASH);
844 std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
846 write_sample(
849 &tmp,
850 "pad/foreign.trucepreset",
851 &meta("u4", "F", ""),
852 TEST_HASH ^ 1,
853 );
854
855 let refs = enumerate_scope(&tmp, PresetScope::Factory, "Acme", "Synth", TEST_HASH);
856 assert_eq!(refs.len(), 3);
857 let by_uuid = |u: &str| refs.iter().find(|r| r.uuid == u).unwrap();
858 assert_eq!(by_uuid("u1").category.as_deref(), Some("Lead"));
859 assert_eq!(by_uuid("u2").category.as_deref(), Some("pad"));
860 assert_eq!(by_uuid("u3").category, None);
861 assert_eq!(by_uuid("u1").uri, "truce-preset://Acme/Synth/u1");
862 assert_eq!(by_uuid("u1").scope, PresetScope::Factory);
863
864 let _ = std::fs::remove_dir_all(&tmp);
865 }
866
867 #[test]
868 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
869 fn missing_root_is_empty() {
870 let refs = enumerate_scope(
871 Path::new("/nonexistent/truce-presets"),
872 PresetScope::User,
873 "V",
874 "P",
875 TEST_HASH,
876 );
877 assert!(refs.is_empty());
878 }
879
880 #[test]
881 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
882 fn load_validates_plugin_hash() {
883 let tmp = temp_dir("load");
884 let hash = crate::state::hash_plugin_id("com.acme.synth");
885 let blob = serialize_state(hash, &[0, 1], &[0.25, 8200.0], b"xs", &[]);
886 let path = tmp.join("loadable.trucepreset");
887 std::fs::write(&path, write_preset_file(&meta("u9", "Loadable", ""), &blob)).unwrap();
888
889 let state = load_preset_file(&path, hash).unwrap();
890 assert_eq!(state.params, vec![(0, 0.25), (1, 8200.0)]);
891 assert_eq!(state.extra.as_deref(), Some(&b"xs"[..]));
892 assert!(load_preset_file(&path, hash ^ 1).is_none());
893
894 let _ = std::fs::remove_dir_all(&tmp);
895 }
896
897 #[test]
898 fn sanitize_user_dir_rules() {
899 let ok = |raw: &str, want: &str| {
900 assert_eq!(
901 sanitize_preset_user_dir(raw),
902 Some(PathBuf::from(want)),
903 "{raw}"
904 );
905 };
906 ok("Acme/MySynth", "Acme/MySynth");
907 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);
914 assert_eq!(sanitize_preset_user_dir("a/../b"), None);
915 assert_eq!(sanitize_preset_user_dir(""), None);
916 assert_eq!(sanitize_preset_user_dir("///"), None);
917 assert_eq!(sanitize_preset_user_dir("..."), None);
919 }
920
921 #[test]
922 #[cfg_attr(
923 miri,
924 ignore = "resolves the real home dir; absent in Miri's isolated env"
925 )]
926 fn user_root_honours_override() {
927 let default = user_preset_root("Acme", "My Synth", None).unwrap();
928 let overridden = user_preset_root("Acme", "My Synth", Some("AcmeAudio/Synth")).unwrap();
929 let unusable = user_preset_root("Acme", "My Synth", Some("../nope")).unwrap();
930
931 assert!(
932 default.ends_with("truce/Acme/My Synth")
933 || default.ends_with("truce/Acme/My Synth/presets")
934 );
935 assert!(overridden.to_string_lossy().contains("AcmeAudio"));
936 assert!(overridden.ends_with("AcmeAudio/Synth"));
937 assert_eq!(unusable, default);
939 }
940
941 #[test]
942 fn ios_container_home_prefers_tmpdir_container() {
943 let tmpdir = std::ffi::OsString::from(
944 "/private/var/mobile/Containers/Data/PluginKitPlugin/ABCDEF/tmp/",
945 );
946 let home = std::ffi::OsString::from("/var/mobile");
947
948 let root = ios_container_home(Some(tmpdir), Some(home)).unwrap();
949
950 assert_eq!(
951 root,
952 PathBuf::from("/private/var/mobile/Containers/Data/PluginKitPlugin/ABCDEF")
953 );
954 }
955
956 #[test]
957 fn ios_container_home_falls_back_to_home() {
958 let home =
959 std::ffi::OsString::from("/private/var/mobile/Containers/Data/Application/ABCDEF");
960
961 let root = ios_container_home(None, Some(home)).unwrap();
962
963 assert_eq!(
964 root,
965 PathBuf::from("/private/var/mobile/Containers/Data/Application/ABCDEF")
966 );
967 }
968
969 #[test]
970 fn ios_container_home_ignores_non_tmp_tmpdir() {
971 let tmpdir = std::ffi::OsString::from("/tmp/scratch/");
974 let home = std::ffi::OsString::from("/var/mobile");
975
976 let root = ios_container_home(Some(tmpdir), Some(home)).unwrap();
977
978 assert_eq!(root, PathBuf::from("/var/mobile"));
979 }
980
981 #[test]
982 fn uri_round_trips() {
983 let uri = preset_uri("Acme Co", "My Synth", "u-1");
984 assert_eq!(parse_preset_uri(&uri), Some(("Acme Co", "My Synth", "u-1")));
985 assert_eq!(parse_preset_uri("nope://x/y/z"), None);
986 assert_eq!(parse_preset_uri("truce-preset://only/two"), None);
987 }
988
989 #[test]
990 #[cfg_attr(miri, ignore = "reads the realtime clock; Miri isolation rejects it")]
991 fn mint_uuid_is_v4_shaped_and_distinct() {
992 let a = mint_uuid();
993 let b = mint_uuid();
994 assert_eq!(a.len(), 36);
995 assert_eq!(&a[14..15], "4");
996 assert_ne!(a, b);
997 }
998
999 #[test]
1000 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
1001 fn user_overrides_factory_and_packs_classify() {
1002 let (store, user, factory) = store("dedup");
1003 write_sample(
1004 &factory,
1005 "lead/a.trucepreset",
1006 &meta("u1", "A", ""),
1007 TEST_HASH,
1008 );
1009 write_sample(
1010 &factory,
1011 "lead/b.trucepreset",
1012 &meta("u2", "B", ""),
1013 TEST_HASH,
1014 );
1015 write_sample(
1017 &user,
1018 "my/a2.trucepreset",
1019 &meta("u1", "A edited", ""),
1020 TEST_HASH,
1021 );
1022 write_sample(
1023 &user,
1024 "packs/edm/lead/p.trucepreset",
1025 &meta("u4", "P", ""),
1026 TEST_HASH,
1027 );
1028
1029 let refs = store.enumerate();
1030 assert_eq!(refs.len(), 3);
1031 let a = refs.iter().find(|r| r.uuid == "u1").unwrap();
1032 assert_eq!(a.scope, PresetScope::User);
1033 assert_eq!(a.name, "A edited");
1034 assert_eq!(
1035 refs.iter().find(|r| r.uuid == "u4").unwrap().scope,
1036 PresetScope::Pack
1037 );
1038
1039 let _ = std::fs::remove_dir_all(&user);
1040 let _ = std::fs::remove_dir_all(&factory);
1041 }
1042
1043 #[test]
1044 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
1045 fn stamps_missing_uuid_on_user_files() {
1046 let (store, user, factory) = store("stamp");
1047 write_sample(&user, "x.trucepreset", &meta("", "Handmade", ""), TEST_HASH);
1048
1049 let refs = store.enumerate();
1050 let stamped = &refs[0];
1051 assert_eq!(stamped.uuid.len(), 36);
1052 let again = store.enumerate();
1054 assert_eq!(again[0].uuid, stamped.uuid);
1055
1056 let _ = std::fs::remove_dir_all(&user);
1057 let _ = std::fs::remove_dir_all(&factory);
1058 }
1059
1060 #[test]
1061 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
1062 fn save_load_rename_recategorise_delete() {
1063 let (store, user, factory) = store("crud");
1064
1065 let saved = store
1066 .save(meta("", "My Lead", "lead"), &[(0, 0.5), (1, 440.0)], b"x")
1067 .unwrap();
1068 assert_eq!(saved.scope, PresetScope::User);
1069 assert!(saved.path.starts_with(user.join("lead")));
1070 assert_eq!(saved.uuid.len(), 36);
1071
1072 let state = store.load(&saved.uri).unwrap();
1073 assert_eq!(state.params, vec![(0, 0.5), (1, 440.0)]);
1074
1075 assert_eq!(store.find(&saved.uri).unwrap().uuid, saved.uuid);
1077 assert_eq!(store.find(&saved.uuid).unwrap().uuid, saved.uuid);
1078 assert_eq!(store.find("My Lead").unwrap().uuid, saved.uuid);
1079 assert!(store.find("nonexistent").is_none());
1080
1081 let resaved = store
1083 .save(meta("", "My Lead", "lead"), &[(0, 0.9)], &[])
1084 .unwrap();
1085 assert_eq!(resaved.uuid, saved.uuid);
1086 assert_eq!(store.enumerate().len(), 1);
1087
1088 store.rename(&saved.uuid, "Better Lead").unwrap();
1089 let renamed = store.find(&saved.uuid).unwrap();
1090 assert_eq!(renamed.name, "Better Lead");
1091 assert_eq!(renamed.uri, saved.uri);
1092
1093 store.recategorise(&saved.uuid, "bass").unwrap();
1094 let moved = store.find(&saved.uuid).unwrap();
1095 assert_eq!(moved.category.as_deref(), Some("bass"));
1096 assert!(moved.path.starts_with(user.join("bass")));
1097
1098 store.delete(&saved.uuid).unwrap();
1099 assert!(store.find(&saved.uuid).is_none());
1100 assert!(matches!(
1101 store.delete(&saved.uuid),
1102 Err(PresetError::NotFound)
1103 ));
1104
1105 let _ = std::fs::remove_dir_all(&user);
1106 let _ = std::fs::remove_dir_all(&factory);
1107 }
1108
1109 #[test]
1110 #[cfg_attr(miri, ignore = "real file I/O; Miri isolation rejects it")]
1111 fn factory_presets_are_read_only() {
1112 let (store, user, factory) = store("readonly");
1113 write_sample(&factory, "a.trucepreset", &meta("u1", "A", ""), TEST_HASH);
1114
1115 assert!(matches!(
1116 store.rename("u1", "B"),
1117 Err(PresetError::ReadOnlyScope)
1118 ));
1119 assert!(matches!(
1120 store.delete("u1"),
1121 Err(PresetError::ReadOnlyScope)
1122 ));
1123
1124 let _ = std::fs::remove_dir_all(&user);
1125 let _ = std::fs::remove_dir_all(&factory);
1126 }
1127}