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