Skip to main content

truce_utils/
presets.rs

1//! Preset library: scope roots, the filesystem walk, loading, and
2//! the management (CRUD) API.
3//!
4//! A preset on disk is a `.trucepreset` container ([`crate::preset`])
5//! holding display metadata plus the canonical state envelope.
6//! Format wrappers use the discovery half to surface presets to
7//! hosts (re-exported as `truce_core::presets`); in-editor preset
8//! menus and `cargo truce preset` use [`PresetStore`] for the
9//! management operations. Lives in `truce-utils` (std-only) so the
10//! CLI shares one implementation with the runtime.
11//!
12//! Factory presets live inside the installed plugin bundle (the
13//! wrapper derives that root from its own on-disk location, which is
14//! format- and OS-specific). User and pack presets live under the
15//! per-OS root returned by [`user_preset_root`], shared by every
16//! format so one library serves them all.
17
18use 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/// Where a preset lives. `Factory` presets sit inside the plugin
29/// bundle, written at install time; `User` presets live in the
30/// per-OS user directory; `Pack` presets came from a third-party
31/// drop-in under the user root's `packs/` subdirectory.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum PresetScope {
34    Factory,
35    User,
36    Pack,
37}
38
39/// One discovered preset: display metadata plus where to load it
40/// from. The state blob is *not* held here - hosts enumerate far
41/// more presets than they load, so the file is re-read lazily at
42/// load time via [`load_preset_file`].
43#[derive(Debug, Clone)]
44pub struct PresetRef {
45    /// Stable identity from the preset's metadata. Survives file
46    /// rename, move, and recategorise; empty for files authored
47    /// without one.
48    pub uuid: String,
49    /// `truce-preset://<vendor>/<plugin>/<uuid>` - see [`preset_uri`].
50    pub uri: String,
51    /// Human-readable name (from metadata, not the filename).
52    pub name: String,
53    /// Explicit metadata category, falling back to the preset's
54    /// parent directory name within its scope root. `None` when
55    /// neither exists.
56    pub category: Option<String>,
57    pub author: Option<String>,
58    pub comment: Option<String>,
59    pub tags: Vec<String>,
60    /// The library's "init sound" marker from the metadata.
61    pub default: bool,
62    pub scope: PresetScope,
63    /// Absolute path to the on-disk file.
64    pub path: PathBuf,
65}
66
67/// The per-OS user-scope preset root for a plugin. One directory
68/// serves every plugin format; third-party packs drop into a
69/// `packs/<pack-name>/` subdirectory of it.
70///
71/// `user_dir` is the optional `[plugin.presets]` `user_dir`
72/// override from `truce.toml`. When `None` (or unusable - see
73/// [`sanitize_preset_user_dir`]), the default subpath is
74/// `truce/<vendor>/<plugin>`, with a trailing `presets` directory
75/// on Windows / Linux. Where the path resolves per OS:
76///
77/// | OS | default | with `user_dir = "Acme/MySynth"` |
78/// |---|---|---|
79/// | macOS | `~/Library/Audio/Presets/truce/<vendor>/<plugin>/` | `~/Library/Audio/Presets/Acme/MySynth/` |
80/// | Windows | `%APPDATA%\truce\<vendor>\<plugin>\presets\` | `%APPDATA%\Acme\MySynth\` |
81/// | Linux | `$XDG_DATA_HOME/truce/<vendor>/<plugin>/presets/` | `$XDG_DATA_HOME/truce/Acme/MySynth/` |
82/// | iOS | `<container>/Library/Application Support/truce/<vendor>/<plugin>/presets/` | `<container>/Library/Application Support/Acme/MySynth/` |
83///
84/// (`$XDG_DATA_HOME` falls back to `~/.local/share` when unset.)
85/// The override replaces the whole default subpath and no `presets`
86/// suffix is appended - except on Linux, where it stays under the
87/// `truce/` namespace: `$XDG_DATA_HOME` is a flat root shared by
88/// every app, unlike macOS's preset-specific directory or the
89/// per-vendor `%APPDATA%` convention.
90///
91/// Returns `None` when the relevant home / app-data environment
92/// variable is missing (sandboxed or otherwise degenerate hosts);
93/// callers skip the user scope in that case.
94#[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// An AUv3 app-extension sandbox has no reliable `HOME`, but its `TMPDIR`
160// is always `<container>/tmp/`, so the container root is that parent.
161// Anything else (no `TMPDIR`, or a non-appex shape) falls back to `HOME`.
162#[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/// Sanitize a `[plugin.presets]` `user_dir` override into a safe
179/// relative subpath. The value is author-controlled text that lands
180/// in filesystem paths, so it's interpreted strictly:
181///
182/// - split on `/` and `\`, each segment run through
183///   [`safe_filename`] (drive colons, reserved characters, leading /
184///   trailing dots all collapse);
185/// - empty and `.` segments are dropped, which also neutralises
186///   leading separators (absolute paths);
187/// - any `..` segment rejects the whole override.
188///
189/// Returns `None` for an unusable value - resolvers fall back to
190/// the default subpath, and `cargo truce` validates the field
191/// loudly at install / preset time.
192#[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/// Build the stable preset URI:
212/// `truce-preset://<vendor>/<plugin>/<uuid>`.
213///
214/// Built from the metadata UUID (not the file path) so rename /
215/// move / recategorise never breaks a host-side reference. Vendor
216/// and plugin segments are sanitized the same way the on-disk
217/// preset directories are, keeping URI and path derivation in sync.
218#[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/// Parse a [`preset_uri`]-shaped string into
228/// `(vendor, plugin, uuid)`. Returns `None` for anything that isn't
229/// a three-segment `truce-preset://` URI.
230#[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/// Generate a UUIDv4-shaped identifier from `std`'s process-seeded
241/// `SipHash` entropy plus the wall clock. Uniqueness-grade (the only
242/// property preset identity needs), not cryptographic.
243#[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    // RFC 4122 version (4) and variant (10x) bits.
259    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/// Recursively walk one scope root for `.trucepreset` files and
273/// parse each file's metadata block.
274///
275/// Files that fail to read or parse are skipped - a corrupt preset
276/// shouldn't take down the host's scan, and the load path re-reports
277/// failures for anything a user actually selects. A missing root
278/// yields an empty list (the user scope doesn't exist until
279/// something writes to it).
280#[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
299/// The per-walk constants every visited file is classified against.
300struct WalkScope<'a> {
301    scope: PresetScope,
302    vendor: &'a str,
303    plugin_name: &'a str,
304    plugin_id_hash: u64,
305}
306
307/// Directory-recursion ceiling for the preset walk. Preset libraries
308/// are at most `packs/<pack>/<category>/` deep; anything deeper is a
309/// mis-drop or a filesystem cycle via symlinks, and bailing beats
310/// hanging the host's scan.
311const 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    // Deterministic enumeration order regardless of filesystem.
322    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/// Parse one preset file's metadata into a [`PresetRef`]. `root` is
343/// the scope root the directory-derived category is computed against;
344/// pass `None` (or the file's own parent) for a standalone file query
345/// to suppress the fallback.
346///
347/// Only presets this plugin can actually load are returned: the
348/// payload envelope must carry `plugin_id_hash`. The preset folders
349/// are keyed by display name, so they can hold files a load would
350/// refuse - another plugin's exports copied in, or a pre-identity-
351/// change build's saves - and listing those (in host preset browsers,
352/// factory menus, CLAP discovery) only to fail the load is worse than
353/// leaving them invisible.
354#[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    // Explicit metadata category wins; otherwise the parent directory
370    // name within the scope root (a file at the root itself has no
371    // directory-derived category).
372    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/// Read a preset file and extract its state, validating the embedded
398/// envelope against this plugin's identity hash. Returns `None` for
399/// unreadable / malformed files and for presets saved by a different
400/// plugin (a pack dropped into the wrong directory).
401#[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// ---------------------------------------------------------------------------
409// Management (CRUD)
410// ---------------------------------------------------------------------------
411
412/// Why a [`PresetStore`] operation failed.
413#[derive(Debug)]
414pub enum PresetError {
415    /// No preset with that URI / uuid exists in any scope.
416    NotFound,
417    /// The operation mutates, but the preset lives in the factory
418    /// or pack scope - both are read-only from the runtime's
419    /// perspective (`cargo truce install` owns factory; pack files
420    /// belong to their distributor).
421    ReadOnlyScope,
422    /// The per-OS user preset directory could not be resolved
423    /// (missing home / app-data environment).
424    NoUserDirectory,
425    /// The preset's embedded state envelope doesn't parse or belongs
426    /// to a different plugin.
427    InvalidState,
428    /// The display name is empty after sanitization.
429    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
454/// One plugin's preset library across all three scopes, with the
455/// management operations layered on top of the discovery walk.
456///
457/// `enumerate` applies the identity rule: two presets with the same
458/// uuid in different scopes are the same logical preset, and the
459/// more user-proximate copy wins (User over Pack over Factory) so a
460/// user "override" of a factory preset shows once, not twice.
461/// Mutations only ever touch the user scope.
462pub 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    /// A store for the given plugin identity. The user root resolves
472    /// from the per-OS environment, honouring the optional
473    /// `[plugin.presets]` `user_dir` override (see
474    /// [`user_preset_root`] for where the path lands per OS); the
475    /// factory root (inside the installed bundle) is format-specific,
476    /// so callers that have one add it via
477    /// [`Self::with_factory_root`].
478    #[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    /// Override the user root (tests, tools operating on a staged
501    /// library).
502    #[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    /// Every preset across factory, user, and pack scopes, deduped
514    /// by uuid (User wins over Pack wins over Factory), ordered
515    /// factory-first then by category / name within each scope.
516    ///
517    /// User / pack files that arrived without a uuid (hand-assembled
518    /// packs, pre-tool files) get one minted and written back on
519    /// first read, so their identity is stable from then on;
520    /// write-back failures degrade to an empty uuid rather than
521    /// hiding the preset.
522    #[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        // Precedence order for the dedup pass; presentation order is
557        // re-sorted below.
558        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    /// Mint and persist a uuid for a user / pack preset that has
576    /// none. Best-effort: on any failure the preset keeps its empty
577    /// uuid for this enumeration.
578    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    /// Resolve a preset by `truce-preset://` URI, bare uuid, or
593    /// display name. A URI for a different vendor / plugin returns
594    /// `None`. Name matching is a fallback after uuid (names are
595    /// unique per category by library validation; on a cross-category
596    /// name clash the first in enumeration order wins).
597    #[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    /// Load a preset's state, validating it against this plugin's
619    /// identity hash.
620    ///
621    /// # Errors
622    ///
623    /// [`PresetError::NotFound`] for an unknown URI;
624    /// [`PresetError::InvalidState`] when the file's envelope doesn't
625    /// parse or belongs to a different plugin.
626    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    /// Save a preset into the user scope.
632    ///
633    /// A user preset with the same `(category, name)` is overwritten
634    /// in place, keeping its uuid - the natural "Save" gesture.
635    /// Otherwise a new file is created with a minted uuid (or
636    /// `meta.uuid` when the caller pre-assigned one). `meta.name` is
637    /// the display name; the file lands at
638    /// `<user root>/<category>/<name>.trucepreset`.
639    ///
640    /// # Errors
641    ///
642    /// [`PresetError::NoUserDirectory`] when the per-OS root can't be
643    /// resolved, [`PresetError::InvalidName`] for a name that
644    /// sanitizes to nothing, [`PresetError::Io`] on write failure.
645    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    /// Rename a user preset's display name. The uuid (and therefore
705    /// the URI and any host-side reference) is unchanged; the file
706    /// keeps its on-disk name.
707    ///
708    /// # Errors
709    ///
710    /// [`PresetError::NotFound`], [`PresetError::ReadOnlyScope`] for
711    /// factory / pack presets, [`PresetError::InvalidState`] for a
712    /// file that no longer parses, [`PresetError::Io`].
713    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    /// Move a user preset to a different category. Rewrites the
719    /// explicit `category` metadata *and* moves the file into the
720    /// matching directory so the on-disk layout stays readable. The
721    /// uuid is unchanged.
722    ///
723    /// # Errors
724    ///
725    /// Same surface as [`Self::rename`], plus
726    /// [`PresetError::NoUserDirectory`].
727    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    /// Delete a user preset.
757    ///
758    /// # Errors
759    ///
760    /// [`PresetError::NotFound`], [`PresetError::ReadOnlyScope`] for
761    /// factory / pack presets, [`PresetError::Io`].
762    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    /// Hash every test fixture's payload is written under.
798    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        // Non-preset files are ignored.
845        std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
846        // A foreign plugin's preset (wrong payload hash) is invisible:
847        // listing it would only set up a load failure.
848        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"); // windows separators
908        ok("/Acme/MySynth/", "Acme/MySynth"); // absolute neutralised
909        ok("Acme/./MySynth", "Acme/MySynth"); // `.` dropped
910        ok("C:/Acme", "C/Acme"); // drive colon collapses
911        ok(" Acme / My Synth ", "Acme/My Synth"); // trimmed, spaces kept
912
913        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        // `safe_filename` trims dot runs, leaving no usable segment.
918        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        // Unusable override falls back to the default path.
938        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        // A `TMPDIR` that isn't the appex `<container>/tmp/` shape must not
972        // be treated as a container anchor; fall back to `HOME`.
973        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        // User override of u1 + a pack drop-in.
1016        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        // Persisted: a second enumeration sees the same identity.
1053        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        // find resolves by uri, uuid, and display name.
1076        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        // Same (category, name) saves in place, keeping the uuid.
1082        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}