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 `truce-preset://` URI, bare uuid, or
520    /// display name. A URI for a different vendor / plugin returns
521    /// `None`. Name matching is a fallback after uuid (names are
522    /// unique per category by library validation; on a cross-category
523    /// name clash the first in enumeration order wins).
524    #[must_use]
525    pub fn find(&self, sel: &str) -> Option<PresetRef> {
526        let uuid = if let Some((vendor, plugin, uuid)) = parse_preset_uri(sel) {
527            if vendor != safe_filename(&self.vendor) || plugin != safe_filename(&self.plugin_name) {
528                return None;
529            }
530            uuid
531        } else {
532            sel
533        };
534        if uuid.is_empty() {
535            return None;
536        }
537        let presets = self.enumerate();
538        presets
539            .iter()
540            .find(|p| p.uuid == uuid)
541            .or_else(|| presets.iter().find(|p| p.name == sel))
542            .cloned()
543    }
544
545    /// Load a preset's state, validating it against this plugin's
546    /// identity hash.
547    ///
548    /// # Errors
549    ///
550    /// [`PresetError::NotFound`] for an unknown URI;
551    /// [`PresetError::InvalidState`] when the file's envelope doesn't
552    /// parse or belongs to a different plugin.
553    pub fn load(&self, uri_or_uuid: &str) -> Result<DeserializedState, PresetError> {
554        let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
555        load_preset_file(&preset.path, self.plugin_id_hash).ok_or(PresetError::InvalidState)
556    }
557
558    /// Save a preset into the user scope.
559    ///
560    /// A user preset with the same `(category, name)` is overwritten
561    /// in place, keeping its uuid - the natural "Save" gesture.
562    /// Otherwise a new file is created with a minted uuid (or
563    /// `meta.uuid` when the caller pre-assigned one). `meta.name` is
564    /// the display name; the file lands at
565    /// `<user root>/<category>/<name>.trucepreset`.
566    ///
567    /// # Errors
568    ///
569    /// [`PresetError::NoUserDirectory`] when the per-OS root can't be
570    /// resolved, [`PresetError::InvalidName`] for a name that
571    /// sanitizes to nothing, [`PresetError::Io`] on write failure.
572    pub fn save(
573        &self,
574        mut meta: PresetMeta,
575        params: &[(u32, f64)],
576        extra: &[u8],
577    ) -> Result<PresetRef, PresetError> {
578        let user_root = self
579            .user_root
580            .as_ref()
581            .ok_or(PresetError::NoUserDirectory)?;
582        let file_stem = safe_filename(&meta.name);
583        if file_stem.is_empty() {
584            return Err(PresetError::InvalidName);
585        }
586
587        let existing = self.enumerate().into_iter().find(|p| {
588            p.scope == PresetScope::User
589                && p.name == meta.name
590                && p.category.as_deref().unwrap_or_default()
591                    == if meta.category.is_empty() {
592                        ""
593                    } else {
594                        meta.category.as_str()
595                    }
596        });
597        let path = if let Some(existing) = &existing {
598            meta.uuid.clone_from(&existing.uuid);
599            existing.path.clone()
600        } else {
601            if meta.uuid.is_empty() {
602                meta.uuid = mint_uuid();
603            }
604            let dir = if meta.category.is_empty() {
605                user_root.clone()
606            } else {
607                user_root.join(safe_filename(&meta.category))
608            };
609            dir.join(format!("{file_stem}.{PRESET_FILE_EXT}"))
610        };
611
612        let ids: Vec<u32> = params.iter().map(|(id, _)| *id).collect();
613        let values: Vec<f64> = params.iter().map(|(_, v)| *v).collect();
614        let blob = serialize_state(self.plugin_id_hash, &ids, &values, extra);
615
616        if let Some(parent) = path.parent() {
617            std::fs::create_dir_all(parent)?;
618        }
619        std::fs::write(&path, write_preset_file(&meta, &blob))?;
620        read_preset_ref(
621            self.user_root.as_deref(),
622            &path,
623            PresetScope::User,
624            &self.vendor,
625            &self.plugin_name,
626        )
627        .ok_or(PresetError::InvalidState)
628    }
629
630    /// Rename a user preset's display name. The uuid (and therefore
631    /// the URI and any host-side reference) is unchanged; the file
632    /// keeps its on-disk name.
633    ///
634    /// # Errors
635    ///
636    /// [`PresetError::NotFound`], [`PresetError::ReadOnlyScope`] for
637    /// factory / pack presets, [`PresetError::InvalidState`] for a
638    /// file that no longer parses, [`PresetError::Io`].
639    pub fn rename(&self, uri_or_uuid: &str, new_name: &str) -> Result<(), PresetError> {
640        let preset = self.user_preset(uri_or_uuid)?;
641        rewrite_meta(&preset.path, |meta| new_name.clone_into(&mut meta.name))
642    }
643
644    /// Move a user preset to a different category. Rewrites the
645    /// explicit `category` metadata *and* moves the file into the
646    /// matching directory so the on-disk layout stays readable. The
647    /// uuid is unchanged.
648    ///
649    /// # Errors
650    ///
651    /// Same surface as [`Self::rename`], plus
652    /// [`PresetError::NoUserDirectory`].
653    pub fn recategorise(&self, uri_or_uuid: &str, new_category: &str) -> Result<(), PresetError> {
654        let user_root = self
655            .user_root
656            .as_ref()
657            .ok_or(PresetError::NoUserDirectory)?;
658        let preset = self.user_preset(uri_or_uuid)?;
659
660        rewrite_meta(&preset.path, |meta| {
661            new_category.clone_into(&mut meta.category);
662        })?;
663
664        let dir = if new_category.is_empty() {
665            user_root.clone()
666        } else {
667            user_root.join(safe_filename(new_category))
668        };
669        let file_name = preset
670            .path
671            .file_name()
672            .ok_or(PresetError::InvalidState)?
673            .to_os_string();
674        let dest = dir.join(file_name);
675        if dest != preset.path {
676            std::fs::create_dir_all(&dir)?;
677            std::fs::rename(&preset.path, &dest)?;
678        }
679        Ok(())
680    }
681
682    /// Delete a user preset.
683    ///
684    /// # Errors
685    ///
686    /// [`PresetError::NotFound`], [`PresetError::ReadOnlyScope`] for
687    /// factory / pack presets, [`PresetError::Io`].
688    pub fn delete(&self, uri_or_uuid: &str) -> Result<(), PresetError> {
689        let preset = self.user_preset(uri_or_uuid)?;
690        std::fs::remove_file(&preset.path)?;
691        Ok(())
692    }
693
694    fn user_preset(&self, uri_or_uuid: &str) -> Result<PresetRef, PresetError> {
695        let preset = self.find(uri_or_uuid).ok_or(PresetError::NotFound)?;
696        if preset.scope != PresetScope::User {
697            return Err(PresetError::ReadOnlyScope);
698        }
699        Ok(preset)
700    }
701}
702
703fn rewrite_meta(path: &Path, edit: impl FnOnce(&mut PresetMeta)) -> Result<(), PresetError> {
704    let bytes = std::fs::read(path)?;
705    let (mut meta, blob) = parse_preset_file(&bytes).ok_or(PresetError::InvalidState)?;
706    edit(&mut meta);
707    std::fs::write(path, write_preset_file(&meta, &blob))?;
708    Ok(())
709}
710
711fn scope_rank(scope: PresetScope) -> u8 {
712    match scope {
713        PresetScope::Factory => 0,
714        PresetScope::User => 1,
715        PresetScope::Pack => 2,
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    fn write_sample(dir: &Path, rel: &str, meta: &PresetMeta, blob: &[u8]) {
724        let path = dir.join(rel);
725        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
726        std::fs::write(path, write_preset_file(meta, blob)).unwrap();
727    }
728
729    fn temp_dir(name: &str) -> PathBuf {
730        let dir = std::env::temp_dir().join(format!("truce-presets-{name}"));
731        let _ = std::fs::remove_dir_all(&dir);
732        std::fs::create_dir_all(&dir).unwrap();
733        dir
734    }
735
736    fn store(name: &str) -> (PresetStore, PathBuf, PathBuf) {
737        let user = temp_dir(&format!("{name}-user"));
738        let factory = temp_dir(&format!("{name}-factory"));
739        let store = PresetStore::new("Acme", "Synth", 42, None)
740            .with_user_root(&user)
741            .with_factory_root(&factory);
742        (store, user, factory)
743    }
744
745    fn meta(uuid: &str, name: &str, category: &str) -> PresetMeta {
746        PresetMeta {
747            uuid: uuid.into(),
748            name: name.into(),
749            category: category.into(),
750            ..PresetMeta::default()
751        }
752    }
753
754    #[test]
755    fn enumerates_with_directory_category_fallback() {
756        let tmp = temp_dir("enum");
757        write_sample(&tmp, "pad/a.trucepreset", &meta("u1", "A", "Lead"), &[]);
758        write_sample(&tmp, "pad/b.trucepreset", &meta("u2", "B", ""), &[]);
759        write_sample(&tmp, "c.trucepreset", &meta("u3", "C", ""), &[]);
760        // Non-preset files are ignored.
761        std::fs::write(tmp.join("pad/readme.txt"), "x").unwrap();
762
763        let refs = enumerate_scope(&tmp, PresetScope::Factory, "Acme", "Synth");
764        assert_eq!(refs.len(), 3);
765        let by_uuid = |u: &str| refs.iter().find(|r| r.uuid == u).unwrap();
766        assert_eq!(by_uuid("u1").category.as_deref(), Some("Lead"));
767        assert_eq!(by_uuid("u2").category.as_deref(), Some("pad"));
768        assert_eq!(by_uuid("u3").category, None);
769        assert_eq!(by_uuid("u1").uri, "truce-preset://Acme/Synth/u1");
770        assert_eq!(by_uuid("u1").scope, PresetScope::Factory);
771
772        let _ = std::fs::remove_dir_all(&tmp);
773    }
774
775    #[test]
776    fn missing_root_is_empty() {
777        let refs = enumerate_scope(
778            Path::new("/nonexistent/truce-presets"),
779            PresetScope::User,
780            "V",
781            "P",
782        );
783        assert!(refs.is_empty());
784    }
785
786    #[test]
787    fn load_validates_plugin_hash() {
788        let tmp = temp_dir("load");
789        let hash = crate::state::hash_plugin_id("com.acme.synth");
790        let blob = serialize_state(hash, &[0, 1], &[0.25, 8200.0], b"xs");
791        let path = tmp.join("loadable.trucepreset");
792        std::fs::write(&path, write_preset_file(&meta("u9", "Loadable", ""), &blob)).unwrap();
793
794        let state = load_preset_file(&path, hash).unwrap();
795        assert_eq!(state.params, vec![(0, 0.25), (1, 8200.0)]);
796        assert_eq!(state.extra.as_deref(), Some(&b"xs"[..]));
797        assert!(load_preset_file(&path, hash ^ 1).is_none());
798
799        let _ = std::fs::remove_dir_all(&tmp);
800    }
801
802    #[test]
803    fn sanitize_user_dir_rules() {
804        let ok = |raw: &str, want: &str| {
805            assert_eq!(
806                sanitize_preset_user_dir(raw),
807                Some(PathBuf::from(want)),
808                "{raw}"
809            );
810        };
811        ok("Acme/MySynth", "Acme/MySynth");
812        ok("Acme\\MySynth", "Acme/MySynth"); // windows separators
813        ok("/Acme/MySynth/", "Acme/MySynth"); // absolute neutralised
814        ok("Acme/./MySynth", "Acme/MySynth"); // `.` dropped
815        ok("C:/Acme", "C/Acme"); // drive colon collapses
816        ok(" Acme / My Synth ", "Acme/My Synth"); // trimmed, spaces kept
817
818        assert_eq!(sanitize_preset_user_dir("../escape"), None);
819        assert_eq!(sanitize_preset_user_dir("a/../b"), None);
820        assert_eq!(sanitize_preset_user_dir(""), None);
821        assert_eq!(sanitize_preset_user_dir("///"), None);
822        // `safe_filename` trims dot runs, leaving no usable segment.
823        assert_eq!(sanitize_preset_user_dir("..."), None);
824    }
825
826    #[test]
827    fn user_root_honours_override() {
828        let default = user_preset_root("Acme", "My Synth", None).unwrap();
829        let overridden = user_preset_root("Acme", "My Synth", Some("AcmeAudio/Synth")).unwrap();
830        let unusable = user_preset_root("Acme", "My Synth", Some("../nope")).unwrap();
831
832        assert!(
833            default.ends_with("truce/Acme/My Synth")
834                || default.ends_with("truce/Acme/My Synth/presets")
835        );
836        assert!(overridden.to_string_lossy().contains("AcmeAudio"));
837        assert!(overridden.ends_with("AcmeAudio/Synth"));
838        // Unusable override falls back to the default path.
839        assert_eq!(unusable, default);
840    }
841
842    #[test]
843    fn uri_round_trips() {
844        let uri = preset_uri("Acme Co", "My Synth", "u-1");
845        assert_eq!(parse_preset_uri(&uri), Some(("Acme Co", "My Synth", "u-1")));
846        assert_eq!(parse_preset_uri("nope://x/y/z"), None);
847        assert_eq!(parse_preset_uri("truce-preset://only/two"), None);
848    }
849
850    #[test]
851    fn mint_uuid_is_v4_shaped_and_distinct() {
852        let a = mint_uuid();
853        let b = mint_uuid();
854        assert_eq!(a.len(), 36);
855        assert_eq!(&a[14..15], "4");
856        assert_ne!(a, b);
857    }
858
859    #[test]
860    fn user_overrides_factory_and_packs_classify() {
861        let (store, user, factory) = store("dedup");
862        let blob = serialize_state(42, &[0], &[1.0], &[]);
863        write_sample(&factory, "lead/a.trucepreset", &meta("u1", "A", ""), &blob);
864        write_sample(&factory, "lead/b.trucepreset", &meta("u2", "B", ""), &blob);
865        // User override of u1 + a pack drop-in.
866        write_sample(
867            &user,
868            "my/a2.trucepreset",
869            &meta("u1", "A edited", ""),
870            &blob,
871        );
872        write_sample(
873            &user,
874            "packs/edm/lead/p.trucepreset",
875            &meta("u4", "P", ""),
876            &blob,
877        );
878
879        let refs = store.enumerate();
880        assert_eq!(refs.len(), 3);
881        let a = refs.iter().find(|r| r.uuid == "u1").unwrap();
882        assert_eq!(a.scope, PresetScope::User);
883        assert_eq!(a.name, "A edited");
884        assert_eq!(
885            refs.iter().find(|r| r.uuid == "u4").unwrap().scope,
886            PresetScope::Pack
887        );
888
889        let _ = std::fs::remove_dir_all(&user);
890        let _ = std::fs::remove_dir_all(&factory);
891    }
892
893    #[test]
894    fn stamps_missing_uuid_on_user_files() {
895        let (store, user, factory) = store("stamp");
896        let blob = serialize_state(42, &[], &[], &[]);
897        write_sample(&user, "x.trucepreset", &meta("", "Handmade", ""), &blob);
898
899        let refs = store.enumerate();
900        let stamped = &refs[0];
901        assert_eq!(stamped.uuid.len(), 36);
902        // Persisted: a second enumeration sees the same identity.
903        let again = store.enumerate();
904        assert_eq!(again[0].uuid, stamped.uuid);
905
906        let _ = std::fs::remove_dir_all(&user);
907        let _ = std::fs::remove_dir_all(&factory);
908    }
909
910    #[test]
911    fn save_load_rename_recategorise_delete() {
912        let (store, user, factory) = store("crud");
913
914        let saved = store
915            .save(meta("", "My Lead", "lead"), &[(0, 0.5), (1, 440.0)], b"x")
916            .unwrap();
917        assert_eq!(saved.scope, PresetScope::User);
918        assert!(saved.path.starts_with(user.join("lead")));
919        assert_eq!(saved.uuid.len(), 36);
920
921        let state = store.load(&saved.uri).unwrap();
922        assert_eq!(state.params, vec![(0, 0.5), (1, 440.0)]);
923
924        // find resolves by uri, uuid, and display name.
925        assert_eq!(store.find(&saved.uri).unwrap().uuid, saved.uuid);
926        assert_eq!(store.find(&saved.uuid).unwrap().uuid, saved.uuid);
927        assert_eq!(store.find("My Lead").unwrap().uuid, saved.uuid);
928        assert!(store.find("nonexistent").is_none());
929
930        // Same (category, name) saves in place, keeping the uuid.
931        let resaved = store
932            .save(meta("", "My Lead", "lead"), &[(0, 0.9)], &[])
933            .unwrap();
934        assert_eq!(resaved.uuid, saved.uuid);
935        assert_eq!(store.enumerate().len(), 1);
936
937        store.rename(&saved.uuid, "Better Lead").unwrap();
938        let renamed = store.find(&saved.uuid).unwrap();
939        assert_eq!(renamed.name, "Better Lead");
940        assert_eq!(renamed.uri, saved.uri);
941
942        store.recategorise(&saved.uuid, "bass").unwrap();
943        let moved = store.find(&saved.uuid).unwrap();
944        assert_eq!(moved.category.as_deref(), Some("bass"));
945        assert!(moved.path.starts_with(user.join("bass")));
946
947        store.delete(&saved.uuid).unwrap();
948        assert!(store.find(&saved.uuid).is_none());
949        assert!(matches!(
950            store.delete(&saved.uuid),
951            Err(PresetError::NotFound)
952        ));
953
954        let _ = std::fs::remove_dir_all(&user);
955        let _ = std::fs::remove_dir_all(&factory);
956    }
957
958    #[test]
959    fn factory_presets_are_read_only() {
960        let (store, user, factory) = store("readonly");
961        let blob = serialize_state(42, &[], &[], &[]);
962        write_sample(&factory, "a.trucepreset", &meta("u1", "A", ""), &blob);
963
964        assert!(matches!(
965            store.rename("u1", "B"),
966            Err(PresetError::ReadOnlyScope)
967        ));
968        assert!(matches!(
969            store.delete("u1"),
970            Err(PresetError::ReadOnlyScope)
971        ));
972
973        let _ = std::fs::remove_dir_all(&user);
974        let _ = std::fs::remove_dir_all(&factory);
975    }
976}