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, parse_preset_file, parse_preset_meta, write_preset_file};
21use crate::safe_filename;
22use crate::state::{DeserializedState, deserialize_state, serialize_state};
23
24pub use crate::preset::PRESET_FILE_EXT;
25
26/// Where a preset lives. `Factory` presets sit inside the plugin
27/// bundle, written at install time; `User` presets live in the
28/// per-OS user directory; `Pack` presets came from a third-party
29/// drop-in under the user root's `packs/` subdirectory.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PresetScope {
32    Factory,
33    User,
34    Pack,
35}
36
37/// One discovered preset: display metadata plus where to load it
38/// from. The state blob is *not* held here - hosts enumerate far
39/// more presets than they load, so the file is re-read lazily at
40/// load time via [`load_preset_file`].
41#[derive(Debug, Clone)]
42pub struct PresetRef {
43    /// Stable identity from the preset's metadata. Survives file
44    /// rename, move, and recategorise; empty for files authored
45    /// without one.
46    pub uuid: String,
47    /// `truce-preset://<vendor>/<plugin>/<uuid>` - see [`preset_uri`].
48    pub uri: String,
49    /// Human-readable name (from metadata, not the filename).
50    pub name: String,
51    /// Explicit metadata category, falling back to the preset's
52    /// parent directory name within its scope root. `None` when
53    /// neither exists.
54    pub category: Option<String>,
55    pub author: Option<String>,
56    pub comment: Option<String>,
57    pub tags: Vec<String>,
58    /// The library's "init sound" marker from the metadata.
59    pub default: bool,
60    pub scope: PresetScope,
61    /// Absolute path to the on-disk file.
62    pub path: PathBuf,
63}
64
65/// The per-OS user-scope preset root for a plugin. One directory
66/// serves every plugin format; third-party packs drop into a
67/// `packs/<pack-name>/` subdirectory of it.
68///
69/// `user_dir` is the optional `[plugin.presets]` `user_dir`
70/// override from `truce.toml`. When `None` (or unusable - see
71/// [`sanitize_preset_user_dir`]), the default subpath is
72/// `truce/<vendor>/<plugin>`, with a trailing `presets` directory
73/// on Windows / Linux. Where the path resolves per OS:
74///
75/// | OS | default | with `user_dir = "Acme/MySynth"` |
76/// |---|---|---|
77/// | macOS | `~/Library/Audio/Presets/truce/<vendor>/<plugin>/` | `~/Library/Audio/Presets/Acme/MySynth/` |
78/// | Windows | `%APPDATA%\truce\<vendor>\<plugin>\presets\` | `%APPDATA%\Acme\MySynth\` |
79/// | Linux | `$XDG_DATA_HOME/truce/<vendor>/<plugin>/presets/` | `$XDG_DATA_HOME/truce/Acme/MySynth/` |
80///
81/// (`$XDG_DATA_HOME` falls back to `~/.local/share` when unset.)
82/// The override replaces the whole default subpath and no `presets`
83/// suffix is appended - except on Linux, where it stays under the
84/// `truce/` namespace: `$XDG_DATA_HOME` is a flat root shared by
85/// every app, unlike macOS's preset-specific directory or the
86/// per-vendor `%APPDATA%` convention.
87///
88/// Returns `None` when the relevant home / app-data environment
89/// variable is missing (sandboxed or otherwise degenerate hosts);
90/// callers skip the user scope in that case.
91#[must_use]
92pub fn user_preset_root(
93    vendor: &str,
94    plugin_name: &str,
95    user_dir: Option<&str>,
96) -> Option<PathBuf> {
97    let override_subpath = user_dir.and_then(sanitize_preset_user_dir);
98    let has_override = override_subpath.is_some();
99    let subpath = override_subpath.unwrap_or_else(|| {
100        PathBuf::from("truce")
101            .join(safe_filename(vendor))
102            .join(safe_filename(plugin_name))
103    });
104
105    #[cfg(target_os = "macos")]
106    {
107        let home = std::env::var_os("HOME")?;
108        let _ = has_override;
109        Some(
110            PathBuf::from(home)
111                .join("Library/Audio/Presets")
112                .join(subpath),
113        )
114    }
115    #[cfg(target_os = "windows")]
116    {
117        let appdata = std::env::var_os("APPDATA")?;
118        let mut root = PathBuf::from(appdata).join(subpath);
119        if !has_override {
120            root.push("presets");
121        }
122        Some(root)
123    }
124    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
125    {
126        let data_home = std::env::var_os("XDG_DATA_HOME")
127            .map(PathBuf::from)
128            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
129        let mut root = data_home;
130        if has_override {
131            root.push("truce");
132        }
133        root.push(subpath);
134        if !has_override {
135            root.push("presets");
136        }
137        Some(root)
138    }
139}
140
141/// Sanitize a `[plugin.presets]` `user_dir` override into a safe
142/// relative subpath. The value is author-controlled text that lands
143/// in filesystem paths, so it's interpreted strictly:
144///
145/// - split on `/` and `\`, each segment run through
146///   [`safe_filename`] (drive colons, reserved characters, leading /
147///   trailing dots all collapse);
148/// - empty and `.` segments are dropped, which also neutralises
149///   leading separators (absolute paths);
150/// - any `..` segment rejects the whole override.
151///
152/// Returns `None` for an unusable value - resolvers fall back to
153/// the default subpath, and `cargo truce` validates the field
154/// loudly at install / preset time.
155#[must_use]
156pub fn sanitize_preset_user_dir(raw: &str) -> Option<PathBuf> {
157    let mut out = PathBuf::new();
158    for segment in raw.split(['/', '\\']) {
159        let segment = segment.trim();
160        if segment.is_empty() || segment == "." {
161            continue;
162        }
163        if segment == ".." {
164            return None;
165        }
166        let safe = safe_filename(segment);
167        if !safe.is_empty() {
168            out.push(safe);
169        }
170    }
171    (!out.as_os_str().is_empty()).then_some(out)
172}
173
174/// Build the stable preset URI:
175/// `truce-preset://<vendor>/<plugin>/<uuid>`.
176///
177/// Built from the metadata UUID (not the file path) so rename /
178/// move / recategorise never breaks a host-side reference. Vendor
179/// and plugin segments are sanitized the same way the on-disk
180/// preset directories are, keeping URI and path derivation in sync.
181#[must_use]
182pub fn preset_uri(vendor: &str, plugin_name: &str, uuid: &str) -> String {
183    format!(
184        "truce-preset://{}/{}/{uuid}",
185        safe_filename(vendor),
186        safe_filename(plugin_name)
187    )
188}
189
190/// Parse a [`preset_uri`]-shaped string into
191/// `(vendor, plugin, uuid)`. Returns `None` for anything that isn't
192/// a three-segment `truce-preset://` URI.
193#[must_use]
194pub fn parse_preset_uri(uri: &str) -> Option<(&str, &str, &str)> {
195    let rest = uri.strip_prefix("truce-preset://")?;
196    let mut segments = rest.splitn(3, '/');
197    let vendor = segments.next().filter(|s| !s.is_empty())?;
198    let plugin = segments.next().filter(|s| !s.is_empty())?;
199    let uuid = segments.next().filter(|s| !s.is_empty())?;
200    Some((vendor, plugin, uuid))
201}
202
203/// Generate a UUIDv4-shaped identifier from `std`'s process-seeded
204/// `SipHash` entropy plus the wall clock. Uniqueness-grade (the only
205/// property preset identity needs), not cryptographic.
206#[must_use]
207pub fn mint_uuid() -> String {
208    use std::fmt::Write as _;
209    use std::hash::{BuildHasher, Hasher};
210    let mix = |salt: u64| {
211        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
212        h.write_u64(salt);
213        let nanos = std::time::SystemTime::now()
214            .duration_since(std::time::UNIX_EPOCH)
215            .map_or(0, |d| u64::from(d.subsec_nanos()));
216        h.finish() ^ nanos.rotate_left(17)
217    };
218    let mut bytes = [0u8; 16];
219    bytes[..8].copy_from_slice(&mix(0x9e37_79b9).to_le_bytes());
220    bytes[8..].copy_from_slice(&mix(0x85eb_ca6b).to_le_bytes());
221    // RFC 4122 version (4) and variant (10x) bits.
222    bytes[6] = (bytes[6] & 0x0f) | 0x40;
223    bytes[8] = (bytes[8] & 0x3f) | 0x80;
224
225    let mut out = String::with_capacity(36);
226    for (i, b) in bytes.iter().enumerate() {
227        if matches!(i, 4 | 6 | 8 | 10) {
228            out.push('-');
229        }
230        let _ = write!(out, "{b:02x}");
231    }
232    out
233}
234
235/// Recursively walk one scope root for `.trucepreset` files and
236/// parse each file's metadata block.
237///
238/// Files that fail to read or parse are skipped - a corrupt preset
239/// shouldn't take down the host's scan, and the load path re-reports
240/// failures for anything a user actually selects. A missing root
241/// yields an empty list (the user scope doesn't exist until
242/// something writes to it).
243#[must_use]
244pub fn enumerate_scope(
245    root: &Path,
246    scope: PresetScope,
247    vendor: &str,
248    plugin_name: &str,
249) -> Vec<PresetRef> {
250    let mut out = Vec::new();
251    walk(root, root, scope, vendor, plugin_name, &mut out, 0);
252    out
253}
254
255/// Directory-recursion ceiling for the preset walk. Preset libraries
256/// are at most `packs/<pack>/<category>/` deep; anything deeper is a
257/// mis-drop or a filesystem cycle via symlinks, and bailing beats
258/// hanging the host's scan.
259const MAX_WALK_DEPTH: usize = 6;
260
261fn walk(
262    root: &Path,
263    dir: &Path,
264    scope: PresetScope,
265    vendor: &str,
266    plugin_name: &str,
267    out: &mut Vec<PresetRef>,
268    depth: usize,
269) {
270    if depth > MAX_WALK_DEPTH {
271        return;
272    }
273    let Ok(entries) = std::fs::read_dir(dir) else {
274        return;
275    };
276    let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
277    // Deterministic enumeration order regardless of filesystem.
278    entries.sort_by_key(std::fs::DirEntry::file_name);
279    for entry in entries {
280        let path = entry.path();
281        if path.is_dir() {
282            walk(root, &path, scope, vendor, plugin_name, out, depth + 1);
283        } else if path.extension().and_then(|e| e.to_str()) == Some(PRESET_FILE_EXT)
284            && let Some(preset) = read_preset_ref(Some(root), &path, scope, vendor, plugin_name)
285        {
286            out.push(preset);
287        }
288    }
289}
290
291/// Parse one preset file's metadata into a [`PresetRef`]. `root` is
292/// the scope root the directory-derived category is computed against;
293/// pass `None` (or the file's own parent) for a standalone file query
294/// to suppress the fallback.
295#[must_use]
296pub fn read_preset_ref(
297    root: Option<&Path>,
298    path: &Path,
299    scope: PresetScope,
300    vendor: &str,
301    plugin_name: &str,
302) -> Option<PresetRef> {
303    let bytes = std::fs::read(path).ok()?;
304    let meta = parse_preset_meta(&bytes)?;
305
306    // Explicit metadata category wins; otherwise the parent directory
307    // name within the scope root (a file at the root itself has no
308    // directory-derived category).
309    let category = if meta.category.is_empty() {
310        path.parent()
311            .filter(|parent| Some(*parent) != root)
312            .and_then(|parent| parent.file_name())
313            .and_then(|n| n.to_str())
314            .map(str::to_string)
315    } else {
316        Some(meta.category)
317    };
318
319    let none_if_empty = |s: String| if s.is_empty() { None } else { Some(s) };
320    Some(PresetRef {
321        uri: preset_uri(vendor, plugin_name, &meta.uuid),
322        uuid: meta.uuid,
323        name: meta.name,
324        category,
325        author: none_if_empty(meta.author),
326        comment: none_if_empty(meta.comment),
327        tags: meta.tags,
328        default: meta.default,
329        scope,
330        path: path.to_path_buf(),
331    })
332}
333
334/// Read a preset file and extract its state, validating the embedded
335/// envelope against this plugin's identity hash. Returns `None` for
336/// unreadable / malformed files and for presets saved by a different
337/// plugin (a pack dropped into the wrong directory).
338#[must_use]
339pub fn load_preset_file(path: &Path, plugin_id_hash: u64) -> Option<DeserializedState> {
340    let bytes = std::fs::read(path).ok()?;
341    let (_, blob) = parse_preset_file(&bytes)?;
342    deserialize_state(&blob, plugin_id_hash)
343}
344
345// ---------------------------------------------------------------------------
346// Management (CRUD)
347// ---------------------------------------------------------------------------
348
349/// Why a [`PresetStore`] operation failed.
350#[derive(Debug)]
351pub enum PresetError {
352    /// No preset with that URI / uuid exists in any scope.
353    NotFound,
354    /// The operation mutates, but the preset lives in the factory
355    /// or pack scope - both are read-only from the runtime's
356    /// perspective (`cargo truce install` owns factory; pack files
357    /// belong to their distributor).
358    ReadOnlyScope,
359    /// The per-OS user preset directory could not be resolved
360    /// (missing home / app-data environment).
361    NoUserDirectory,
362    /// The preset's embedded state envelope doesn't parse or belongs
363    /// to a different plugin.
364    InvalidState,
365    /// The display name is empty after sanitization.
366    InvalidName,
367    Io(std::io::Error),
368}
369
370impl std::fmt::Display for PresetError {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        match self {
373            Self::NotFound => f.write_str("preset not found"),
374            Self::ReadOnlyScope => f.write_str("factory / pack presets are read-only"),
375            Self::NoUserDirectory => f.write_str("user preset directory could not be resolved"),
376            Self::InvalidState => f.write_str("preset state is malformed or from another plugin"),
377            Self::InvalidName => f.write_str("preset name is empty"),
378            Self::Io(e) => write!(f, "preset io: {e}"),
379        }
380    }
381}
382
383impl std::error::Error for PresetError {}
384
385impl From<std::io::Error> for PresetError {
386    fn from(e: std::io::Error) -> Self {
387        Self::Io(e)
388    }
389}
390
391/// One plugin's preset library across all three scopes, with the
392/// management operations layered on top of the discovery walk.
393///
394/// `enumerate` applies the identity rule: two presets with the same
395/// uuid in different scopes are the same logical preset, and the
396/// more user-proximate copy wins (User over Pack over Factory) so a
397/// user "override" of a factory preset shows once, not twice.
398/// Mutations only ever touch the user scope.
399pub struct PresetStore {
400    vendor: String,
401    plugin_name: String,
402    plugin_id_hash: u64,
403    factory_root: Option<PathBuf>,
404    user_root: Option<PathBuf>,
405}
406
407impl PresetStore {
408    /// A store for the given plugin identity. The user root resolves
409    /// from the per-OS environment, honouring the optional
410    /// `[plugin.presets]` `user_dir` override (see
411    /// [`user_preset_root`] for where the path lands per OS); the
412    /// factory root (inside the installed bundle) is format-specific,
413    /// so callers that have one add it via
414    /// [`Self::with_factory_root`].
415    #[must_use]
416    pub fn new(
417        vendor: &str,
418        plugin_name: &str,
419        plugin_id_hash: u64,
420        user_dir: Option<&str>,
421    ) -> Self {
422        Self {
423            vendor: vendor.to_string(),
424            plugin_name: plugin_name.to_string(),
425            plugin_id_hash,
426            factory_root: None,
427            user_root: user_preset_root(vendor, plugin_name, user_dir),
428        }
429    }
430
431    #[must_use]
432    pub fn with_factory_root(mut self, root: impl Into<PathBuf>) -> Self {
433        self.factory_root = Some(root.into());
434        self
435    }
436
437    /// Override the user root (tests, tools operating on a staged
438    /// library).
439    #[must_use]
440    pub fn with_user_root(mut self, root: impl Into<PathBuf>) -> Self {
441        self.user_root = Some(root.into());
442        self
443    }
444
445    #[must_use]
446    pub fn user_root(&self) -> Option<&Path> {
447        self.user_root.as_deref()
448    }
449
450    /// Every preset across factory, user, and pack scopes, deduped
451    /// by uuid (User wins over Pack wins over Factory), ordered
452    /// factory-first then by category / name within each scope.
453    ///
454    /// User / pack files that arrived without a uuid (hand-assembled
455    /// packs, pre-tool files) get one minted and written back on
456    /// first read, so their identity is stable from then on;
457    /// write-back failures degrade to an empty uuid rather than
458    /// hiding the preset.
459    #[must_use]
460    pub fn enumerate(&self) -> Vec<PresetRef> {
461        let mut user: Vec<PresetRef> = Vec::new();
462        let mut packs: Vec<PresetRef> = Vec::new();
463        if let Some(root) = &self.user_root {
464            let packs_root = root.join("packs");
465            for mut preset in
466                enumerate_scope(root, PresetScope::User, &self.vendor, &self.plugin_name)
467            {
468                if preset.uuid.is_empty() {
469                    self.stamp_uuid(&mut preset);
470                }
471                if preset.path.starts_with(&packs_root) {
472                    preset.scope = PresetScope::Pack;
473                    packs.push(preset);
474                } else {
475                    user.push(preset);
476                }
477            }
478        }
479        let factory = self.factory_root.as_ref().map_or_else(Vec::new, |root| {
480            enumerate_scope(root, PresetScope::Factory, &self.vendor, &self.plugin_name)
481        });
482
483        // Precedence order for the dedup pass; presentation order is
484        // re-sorted below.
485        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
486        let mut out: Vec<PresetRef> = Vec::new();
487        for preset in user.into_iter().chain(packs).chain(factory) {
488            if !preset.uuid.is_empty() && !seen.insert(preset.uuid.clone()) {
489                continue;
490            }
491            out.push(preset);
492        }
493        out.sort_by(|a, b| {
494            scope_rank(a.scope)
495                .cmp(&scope_rank(b.scope))
496                .then_with(|| a.category.cmp(&b.category))
497                .then_with(|| a.name.cmp(&b.name))
498        });
499        out
500    }
501
502    /// Mint and persist a uuid for a user / pack preset that has
503    /// none. Best-effort: on any failure the preset keeps its empty
504    /// uuid for this enumeration.
505    fn stamp_uuid(&self, preset: &mut PresetRef) {
506        let Ok(bytes) = std::fs::read(&preset.path) else {
507            return;
508        };
509        let Some((mut meta, blob)) = parse_preset_file(&bytes) else {
510            return;
511        };
512        meta.uuid = mint_uuid();
513        if std::fs::write(&preset.path, write_preset_file(&meta, &blob)).is_ok() {
514            preset.uri = preset_uri(&self.vendor, &self.plugin_name, &meta.uuid);
515            preset.uuid = meta.uuid;
516        }
517    }
518
519    /// Resolve a preset by full `truce-preset://` URI or bare uuid.
520    /// URIs for a different vendor / plugin return `None`.
521    #[must_use]
522    pub fn find(&self, uri_or_uuid: &str) -> Option<PresetRef> {
523        let uuid = if let Some((vendor, plugin, uuid)) = parse_preset_uri(uri_or_uuid) {
524            if vendor != safe_filename(&self.vendor) || plugin != safe_filename(&self.plugin_name) {
525                return None;
526            }
527            uuid
528        } else {
529            uri_or_uuid
530        };
531        if uuid.is_empty() {
532            return None;
533        }
534        self.enumerate().into_iter().find(|p| p.uuid == uuid)
535    }
536
537    /// Load a preset's state, validating it against this plugin's
538    /// identity hash.
539    ///
540    /// # Errors
541    ///
542    /// [`PresetError::NotFound`] for an unknown URI;
543    /// [`PresetError::InvalidState`] when the file's envelope doesn't
544    /// parse or belongs to a different plugin.
545    pub fn load(&self, uri_or_uuid: &str) -> Result<DeserializedState, PresetError> {
546        let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
547        load_preset_file(&preset.path, self.plugin_id_hash).ok_or(PresetError::InvalidState)
548    }
549
550    /// Save a preset into the user scope.
551    ///
552    /// A user preset with the same `(category, name)` is overwritten
553    /// in place, keeping its uuid - the natural "Save" gesture.
554    /// Otherwise a new file is created with a minted uuid (or
555    /// `meta.uuid` when the caller pre-assigned one). `meta.name` is
556    /// the display name; the file lands at
557    /// `<user root>/<category>/<name>.trucepreset`.
558    ///
559    /// # Errors
560    ///
561    /// [`PresetError::NoUserDirectory`] when the per-OS root can't be
562    /// resolved, [`PresetError::InvalidName`] for a name that
563    /// sanitizes to nothing, [`PresetError::Io`] on write failure.
564    pub fn save(
565        &self,
566        mut meta: PresetMeta,
567        params: &[(u32, f64)],
568        extra: &[u8],
569    ) -> Result<PresetRef, PresetError> {
570        let user_root = self
571            .user_root
572            .as_ref()
573            .ok_or(PresetError::NoUserDirectory)?;
574        let file_stem = safe_filename(&meta.name);
575        if file_stem.is_empty() {
576            return Err(PresetError::InvalidName);
577        }
578
579        let existing = self.enumerate().into_iter().find(|p| {
580            p.scope == PresetScope::User
581                && p.name == meta.name
582                && p.category.as_deref().unwrap_or_default()
583                    == if meta.category.is_empty() {
584                        ""
585                    } else {
586                        meta.category.as_str()
587                    }
588        });
589        let path = if let Some(existing) = &existing {
590            meta.uuid.clone_from(&existing.uuid);
591            existing.path.clone()
592        } else {
593            if meta.uuid.is_empty() {
594                meta.uuid = mint_uuid();
595            }
596            let dir = if meta.category.is_empty() {
597                user_root.clone()
598            } else {
599                user_root.join(safe_filename(&meta.category))
600            };
601            dir.join(format!("{file_stem}.{PRESET_FILE_EXT}"))
602        };
603
604        let ids: Vec<u32> = params.iter().map(|(id, _)| *id).collect();
605        let values: Vec<f64> = params.iter().map(|(_, v)| *v).collect();
606        let blob = serialize_state(self.plugin_id_hash, &ids, &values, extra);
607
608        if let Some(parent) = path.parent() {
609            std::fs::create_dir_all(parent)?;
610        }
611        std::fs::write(&path, write_preset_file(&meta, &blob))?;
612        read_preset_ref(
613            self.user_root.as_deref(),
614            &path,
615            PresetScope::User,
616            &self.vendor,
617            &self.plugin_name,
618        )
619        .ok_or(PresetError::InvalidState)
620    }
621
622    /// Rename a user preset's display name. The uuid (and therefore
623    /// the URI and any host-side reference) is unchanged; the file
624    /// keeps its on-disk name.
625    ///
626    /// # Errors
627    ///
628    /// [`PresetError::NotFound`], [`PresetError::ReadOnlyScope`] for
629    /// factory / pack presets, [`PresetError::InvalidState`] for a
630    /// file that no longer parses, [`PresetError::Io`].
631    pub fn rename(&self, uri_or_uuid: &str, new_name: &str) -> Result<(), PresetError> {
632        let preset = self.user_preset(uri_or_uuid)?;
633        rewrite_meta(&preset.path, |meta| new_name.clone_into(&mut meta.name))
634    }
635
636    /// Move a user preset to a different category. Rewrites the
637    /// explicit `category` metadata *and* moves the file into the
638    /// matching directory so the on-disk layout stays readable. The
639    /// uuid is unchanged.
640    ///
641    /// # Errors
642    ///
643    /// Same surface as [`Self::rename`], plus
644    /// [`PresetError::NoUserDirectory`].
645    pub fn recategorise(&self, uri_or_uuid: &str, new_category: &str) -> Result<(), PresetError> {
646        let user_root = self
647            .user_root
648            .as_ref()
649            .ok_or(PresetError::NoUserDirectory)?;
650        let preset = self.user_preset(uri_or_uuid)?;
651
652        rewrite_meta(&preset.path, |meta| {
653            new_category.clone_into(&mut meta.category);
654        })?;
655
656        let dir = if new_category.is_empty() {
657            user_root.clone()
658        } else {
659            user_root.join(safe_filename(new_category))
660        };
661        let file_name = preset
662            .path
663            .file_name()
664            .ok_or(PresetError::InvalidState)?
665            .to_os_string();
666        let dest = dir.join(file_name);
667        if dest != preset.path {
668            std::fs::create_dir_all(&dir)?;
669            std::fs::rename(&preset.path, &dest)?;
670        }
671        Ok(())
672    }
673
674    /// Delete a user preset.
675    ///
676    /// # Errors
677    ///
678    /// [`PresetError::NotFound`], [`PresetError::ReadOnlyScope`] for
679    /// factory / pack presets, [`PresetError::Io`].
680    pub fn delete(&self, uri_or_uuid: &str) -> Result<(), PresetError> {
681        let preset = self.user_preset(uri_or_uuid)?;
682        std::fs::remove_file(&preset.path)?;
683        Ok(())
684    }
685
686    fn user_preset(&self, uri_or_uuid: &str) -> Result<PresetRef, PresetError> {
687        let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
688        if preset.scope != PresetScope::User {
689            return Err(PresetError::ReadOnlyScope);
690        }
691        Ok(preset)
692    }
693}
694
695fn rewrite_meta(path: &Path, edit: impl FnOnce(&mut PresetMeta)) -> Result<(), PresetError> {
696    let bytes = std::fs::read(path)?;
697    let (mut meta, blob) = parse_preset_file(&bytes).ok_or(PresetError::InvalidState)?;
698    edit(&mut meta);
699    std::fs::write(path, write_preset_file(&meta, &blob))?;
700    Ok(())
701}
702
703fn scope_rank(scope: PresetScope) -> u8 {
704    match scope {
705        PresetScope::Factory => 0,
706        PresetScope::User => 1,
707        PresetScope::Pack => 2,
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    fn write_sample(dir: &Path, rel: &str, meta: &PresetMeta, blob: &[u8]) {
716        let path = dir.join(rel);
717        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
718        std::fs::write(path, write_preset_file(meta, blob)).unwrap();
719    }
720
721    fn temp_dir(name: &str) -> PathBuf {
722        let dir = std::env::temp_dir().join(format!("truce-presets-{name}"));
723        let _ = std::fs::remove_dir_all(&dir);
724        std::fs::create_dir_all(&dir).unwrap();
725        dir
726    }
727
728    fn store(name: &str) -> (PresetStore, PathBuf, PathBuf) {
729        let user = temp_dir(&format!("{name}-user"));
730        let factory = temp_dir(&format!("{name}-factory"));
731        let store = PresetStore::new("Acme", "Synth", 42, None)
732            .with_user_root(&user)
733            .with_factory_root(&factory);
734        (store, user, factory)
735    }
736
737    fn meta(uuid: &str, name: &str, category: &str) -> PresetMeta {
738        PresetMeta {
739            uuid: uuid.into(),
740            name: name.into(),
741            category: category.into(),
742            ..PresetMeta::default()
743        }
744    }
745
746    #[test]
747    fn enumerates_with_directory_category_fallback() {
748        let tmp = temp_dir("enum");
749        write_sample(&tmp, "pad/a.trucepreset", &meta("u1", "A", "Lead"), &[]);
750        write_sample(&tmp, "pad/b.trucepreset", &meta("u2", "B", ""), &[]);
751        write_sample(&tmp, "c.trucepreset", &meta("u3", "C", ""), &[]);
752        // Non-preset files are ignored.
753        std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
754
755        let refs = enumerate_scope(&tmp, PresetScope::Factory, "Acme", "Synth");
756        assert_eq!(refs.len(), 3);
757        let by_uuid = |u: &str| refs.iter().find(|r| r.uuid == u).unwrap();
758        assert_eq!(by_uuid("u1").category.as_deref(), Some("Lead"));
759        assert_eq!(by_uuid("u2").category.as_deref(), Some("pad"));
760        assert_eq!(by_uuid("u3").category, None);
761        assert_eq!(by_uuid("u1").uri, "truce-preset://Acme/Synth/u1");
762        assert_eq!(by_uuid("u1").scope, PresetScope::Factory);
763
764        let _ = std::fs::remove_dir_all(&tmp);
765    }
766
767    #[test]
768    fn missing_root_is_empty() {
769        let refs = enumerate_scope(
770            Path::new("/nonexistent/truce-presets"),
771            PresetScope::User,
772            "V",
773            "P",
774        );
775        assert!(refs.is_empty());
776    }
777
778    #[test]
779    fn load_validates_plugin_hash() {
780        let tmp = temp_dir("load");
781        let hash = crate::state::hash_plugin_id("com.acme.synth");
782        let blob = serialize_state(hash, &[0, 1], &[0.25, 8200.0], b"xs");
783        let path = tmp.join("loadable.trucepreset");
784        std::fs::write(&path, write_preset_file(&meta("u9", "Loadable", ""), &blob)).unwrap();
785
786        let state = load_preset_file(&path, hash).unwrap();
787        assert_eq!(state.params, vec![(0, 0.25), (1, 8200.0)]);
788        assert_eq!(state.extra.as_deref(), Some(&b"xs"[..]));
789        assert!(load_preset_file(&path, hash ^ 1).is_none());
790
791        let _ = std::fs::remove_dir_all(&tmp);
792    }
793
794    #[test]
795    fn sanitize_user_dir_rules() {
796        let ok = |raw: &str, want: &str| {
797            assert_eq!(
798                sanitize_preset_user_dir(raw),
799                Some(PathBuf::from(want)),
800                "{raw}"
801            );
802        };
803        ok("Acme/MySynth", "Acme/MySynth");
804        ok("Acme\\MySynth", "Acme/MySynth"); // windows separators
805        ok("/Acme/MySynth/", "Acme/MySynth"); // absolute neutralised
806        ok("Acme/./MySynth", "Acme/MySynth"); // `.` dropped
807        ok("C:/Acme", "C/Acme"); // drive colon collapses
808        ok(" Acme / My Synth ", "Acme/My Synth"); // trimmed, spaces kept
809
810        assert_eq!(sanitize_preset_user_dir("../escape"), None);
811        assert_eq!(sanitize_preset_user_dir("a/../b"), None);
812        assert_eq!(sanitize_preset_user_dir(""), None);
813        assert_eq!(sanitize_preset_user_dir("///"), None);
814        // `safe_filename` trims dot runs, leaving no usable segment.
815        assert_eq!(sanitize_preset_user_dir("..."), None);
816    }
817
818    #[test]
819    fn user_root_honours_override() {
820        let default = user_preset_root("Acme", "My Synth", None).unwrap();
821        let overridden = user_preset_root("Acme", "My Synth", Some("AcmeAudio/Synth")).unwrap();
822        let unusable = user_preset_root("Acme", "My Synth", Some("../nope")).unwrap();
823
824        assert!(
825            default.ends_with("truce/Acme/My Synth")
826                || default.ends_with("truce/Acme/My Synth/presets")
827        );
828        assert!(overridden.to_string_lossy().contains("AcmeAudio"));
829        assert!(overridden.ends_with("AcmeAudio/Synth"));
830        // Unusable override falls back to the default path.
831        assert_eq!(unusable, default);
832    }
833
834    #[test]
835    fn uri_round_trips() {
836        let uri = preset_uri("Acme Co", "My Synth", "u-1");
837        assert_eq!(parse_preset_uri(&uri), Some(("Acme Co", "My Synth", "u-1")));
838        assert_eq!(parse_preset_uri("nope://x/y/z"), None);
839        assert_eq!(parse_preset_uri("truce-preset://only/two"), None);
840    }
841
842    #[test]
843    fn mint_uuid_is_v4_shaped_and_distinct() {
844        let a = mint_uuid();
845        let b = mint_uuid();
846        assert_eq!(a.len(), 36);
847        assert_eq!(&a[14..15], "4");
848        assert_ne!(a, b);
849    }
850
851    #[test]
852    fn user_overrides_factory_and_packs_classify() {
853        let (store, user, factory) = store("dedup");
854        let blob = serialize_state(42, &[0], &[1.0], &[]);
855        write_sample(&factory, "lead/a.trucepreset", &meta("u1", "A", ""), &blob);
856        write_sample(&factory, "lead/b.trucepreset", &meta("u2", "B", ""), &blob);
857        // User override of u1 + a pack drop-in.
858        write_sample(
859            &user,
860            "my/a2.trucepreset",
861            &meta("u1", "A edited", ""),
862            &blob,
863        );
864        write_sample(
865            &user,
866            "packs/edm/lead/p.trucepreset",
867            &meta("u4", "P", ""),
868            &blob,
869        );
870
871        let refs = store.enumerate();
872        assert_eq!(refs.len(), 3);
873        let a = refs.iter().find(|r| r.uuid == "u1").unwrap();
874        assert_eq!(a.scope, PresetScope::User);
875        assert_eq!(a.name, "A edited");
876        assert_eq!(
877            refs.iter().find(|r| r.uuid == "u4").unwrap().scope,
878            PresetScope::Pack
879        );
880
881        let _ = std::fs::remove_dir_all(&user);
882        let _ = std::fs::remove_dir_all(&factory);
883    }
884
885    #[test]
886    fn stamps_missing_uuid_on_user_files() {
887        let (store, user, factory) = store("stamp");
888        let blob = serialize_state(42, &[], &[], &[]);
889        write_sample(&user, "x.trucepreset", &meta("", "Handmade", ""), &blob);
890
891        let refs = store.enumerate();
892        let stamped = &refs[0];
893        assert_eq!(stamped.uuid.len(), 36);
894        // Persisted: a second enumeration sees the same identity.
895        let again = store.enumerate();
896        assert_eq!(again[0].uuid, stamped.uuid);
897
898        let _ = std::fs::remove_dir_all(&user);
899        let _ = std::fs::remove_dir_all(&factory);
900    }
901
902    #[test]
903    fn save_load_rename_recategorise_delete() {
904        let (store, user, factory) = store("crud");
905
906        let saved = store
907            .save(meta("", "My Lead", "lead"), &[(0, 0.5), (1, 440.0)], b"x")
908            .unwrap();
909        assert_eq!(saved.scope, PresetScope::User);
910        assert!(saved.path.starts_with(user.join("lead")));
911        assert_eq!(saved.uuid.len(), 36);
912
913        let state = store.load(&saved.uri).unwrap();
914        assert_eq!(state.params, vec![(0, 0.5), (1, 440.0)]);
915
916        // Same (category, name) saves in place, keeping the uuid.
917        let resaved = store
918            .save(meta("", "My Lead", "lead"), &[(0, 0.9)], &[])
919            .unwrap();
920        assert_eq!(resaved.uuid, saved.uuid);
921        assert_eq!(store.enumerate().len(), 1);
922
923        store.rename(&saved.uuid, "Better Lead").unwrap();
924        let renamed = store.find(&saved.uuid).unwrap();
925        assert_eq!(renamed.name, "Better Lead");
926        assert_eq!(renamed.uri, saved.uri);
927
928        store.recategorise(&saved.uuid, "bass").unwrap();
929        let moved = store.find(&saved.uuid).unwrap();
930        assert_eq!(moved.category.as_deref(), Some("bass"));
931        assert!(moved.path.starts_with(user.join("bass")));
932
933        store.delete(&saved.uuid).unwrap();
934        assert!(store.find(&saved.uuid).is_none());
935        assert!(matches!(
936            store.delete(&saved.uuid),
937            Err(PresetError::NotFound)
938        ));
939
940        let _ = std::fs::remove_dir_all(&user);
941        let _ = std::fs::remove_dir_all(&factory);
942    }
943
944    #[test]
945    fn factory_presets_are_read_only() {
946        let (store, user, factory) = store("readonly");
947        let blob = serialize_state(42, &[], &[], &[]);
948        write_sample(&factory, "a.trucepreset", &meta("u1", "A", ""), &blob);
949
950        assert!(matches!(
951            store.rename("u1", "B"),
952            Err(PresetError::ReadOnlyScope)
953        ));
954        assert!(matches!(
955            store.delete("u1"),
956            Err(PresetError::ReadOnlyScope)
957        ));
958
959        let _ = std::fs::remove_dir_all(&user);
960        let _ = std::fs::remove_dir_all(&factory);
961    }
962}