Skip to main content

rlx_core/preset/
mod.rs

1//! The preset layer — ADR-0002 layers 1-2: TOML data binding built-in system
2//! parameters to a pure expression language over the audio analysis.
3//!
4//! *Pure* is a statement about the evaluator, not about the surface: a
5//! `[latch]` (ADR-0137) reads state the render layer holds between frames, and
6//! [`expr`]'s own header says how that is arranged without the evaluator
7//! learning it.
8//!
9//! [`expr`] compiles and evaluates expression strings; [`schema`] parses a
10//! TOML preset into compiled [`Binding`]s; [`path`] parses the one thing here
11//! that is not an expression, a `[path] d` string, into an authored silhouette
12//! (ADR-0107). This module also loads presets in
13//! bulk: [`default_presets`] embeds the shipped examples (so the C-ABI/foobar
14//! path always has visuals without a preset directory), [`seed_dir`] writes the
15//! embedded curated set into a per-user directory on first run (write-if-absent,
16//! so a user's edits survive), and [`load_dir`] reads a directory for the
17//! standalone's hot-reload path — a malformed file is reported, never fatal, so
18//! the caller keeps the last good set (NFR 10).
19
20pub mod expr;
21pub mod path;
22pub mod schema;
23
24use std::path::{Path, PathBuf};
25
26pub use expr::{
27    Expr, ExprError, GateFlag, GateKind, LATCH_CAP, NodeObservation, Observations,
28    SATURATED_OCCUPANCY, Variables, compile,
29};
30pub use schema::export;
31pub use schema::{
32    Binding, Easing, GLOBAL_PARAMS, HoldEdge, KeyDesc, KeyKind, Latch, Layer, LayerBlend,
33    LayerJoin, Preset, PresetError, Roster, SystemKind, TableDesc, is_known_param, kind_of_param,
34};
35
36// The shipped example presets, embedded at compile time so the C-ABI/foobar
37// path always has visuals without a preset directory (ADR-0006). This list is
38// **generated** — `core/build.rs` globs `presets/*.toml` and emits
39// `pub static EMBEDDED: &[(&str, &str)]` as `(filename, contents)` tuples,
40// sorted by filename, each embedded via `include_str!` (ADR-0022). Drop a
41// `.toml` in `presets/` at the repo root and rebuild — it ships, with no edit
42// here and no count to bump. (See `core/build.rs` for how the entries are
43// produced; they are not a literal array in this file.)
44include!(concat!(env!("OUT_DIR"), "/embedded_presets.rs"));
45
46/// Parse the embedded example presets. The shipped files are valid, so on the
47/// off chance one fails it is skipped rather than panicking — the caller still
48/// gets a usable set.
49pub fn default_presets() -> Vec<Preset> {
50    EMBEDDED
51        .iter()
52        .filter_map(|(_, src)| Preset::from_toml_str(src).ok())
53        .collect()
54}
55
56/// Write each embedded curated preset into `dir`, creating `dir` (and any
57/// missing parents) first, but **never overwriting** a file that already
58/// exists — a user's edits to a seeded preset survive re-seeding. Returns how
59/// many files were newly written. Idempotent: a second call on an
60/// already-seeded directory writes zero.
61///
62/// Because seeding never clobbers, a curated preset changed in a later release
63/// does **not** replace the copy a user already has on disk (a "refresh
64/// curated" affordance is a follow-up, not this function's job). Errors bubble
65/// up as `io::Result` so the caller can degrade to the embedded defaults rather
66/// than crash (NFR 10).
67pub fn seed_dir(dir: &Path) -> std::io::Result<usize> {
68    std::fs::create_dir_all(dir)?;
69    let mut written = 0;
70    for &(name, contents) in EMBEDDED {
71        let path = dir.join(name);
72        if !path.exists() {
73            std::fs::write(&path, contents)?;
74            written += 1;
75        }
76    }
77    Ok(written)
78}
79
80/// The outcome of loading a preset directory: the presets that compiled, in
81/// filename order, plus the files that failed and the non-fatal problems found
82/// in the ones that succeeded (so the caller can surface both).
83pub struct LoadReport {
84    /// Successfully compiled presets, sorted by filename for a stable cycle.
85    pub presets: Vec<Preset>,
86    /// `(path, error)` for each `.toml` that failed to read or compile.
87    pub errors: Vec<(PathBuf, PresetError)>,
88    /// `(path, warning)` for each non-fatal problem in a preset that **did**
89    /// load — today, a binding naming a parameter its system does not consume
90    /// (ADR-0020). Surfacing these is what stops a typo from failing silently;
91    /// the preset itself is in `presets` and renders normally.
92    pub warnings: Vec<(PathBuf, String)>,
93}
94
95/// Load every `*.toml` in `dir`, compiling each into a [`Preset`]. Missing or
96/// unreadable directories yield an empty report rather than an error; a bad
97/// file lands in `errors` and does not stop the others (degrade, never crash).
98pub fn load_dir(dir: &Path) -> LoadReport {
99    let mut presets = Vec::new();
100    let mut errors = Vec::new();
101    let mut warnings = Vec::new();
102
103    let mut paths: Vec<PathBuf> = match std::fs::read_dir(dir) {
104        Ok(entries) => entries
105            .filter_map(|e| e.ok().map(|e| e.path()))
106            .filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
107            .collect(),
108        Err(_) => {
109            return LoadReport {
110                presets,
111                errors,
112                warnings,
113            };
114        }
115    };
116    paths.sort();
117
118    for path in paths {
119        match std::fs::read_to_string(&path) {
120            Ok(src) => match Preset::from_toml_str(&src) {
121                Ok(mut preset) => {
122                    warnings.extend(preset.warnings.iter().map(|w| (path.clone(), w.clone())));
123                    // Absolute, because the consumers that want it — an editor
124                    // that writes the file back, a report that names it — do not
125                    // share this process's working directory. `absolute` is
126                    // lexical: it prepends the cwd and normalizes, touching no
127                    // filesystem and, unlike `canonicalize`, producing no `\?\`
128                    // prefix on Windows for a path that then has to be handed to
129                    // another program. A cwd that cannot be read leaves the path
130                    // as it was rather than dropping the preset (NFR 10).
131                    preset.source =
132                        Some(std::path::absolute(&path).unwrap_or_else(|_| path.clone()));
133                    presets.push(preset);
134                }
135                Err(err) => errors.push((path, err)),
136            },
137            Err(err) => errors.push((path, PresetError::Io(err.to_string()))),
138        }
139    }
140
141    LoadReport {
142        presets,
143        errors,
144        warnings,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn seed_dir_writes_all_then_nothing() {
154        let dir = std::env::temp_dir().join("rlx_seed_dir_test");
155        let _ = std::fs::remove_dir_all(&dir);
156
157        // First seed into an empty dir: every embedded preset is written.
158        let written = seed_dir(&dir).expect("seed into fresh temp dir");
159        assert_eq!(
160            written,
161            EMBEDDED.len(),
162            "first seed writes every embedded preset"
163        );
164        for &(name, _) in EMBEDDED {
165            assert!(dir.join(name).exists(), "{name} was seeded");
166        }
167
168        // Second seed: write-if-absent means nothing is written and nothing is
169        // clobbered.
170        let again = seed_dir(&dir).expect("re-seed already-seeded dir");
171        assert_eq!(
172            again, 0,
173            "re-seeding writes zero (idempotent, no overwrite)"
174        );
175
176        // Deleting one seeded file re-seeds only that file.
177        let (victim, _) = EMBEDDED[0];
178        std::fs::remove_file(dir.join(victim)).expect("remove one seeded file");
179        let refill = seed_dir(&dir).expect("re-seed after deletion");
180        assert_eq!(refill, 1, "only the missing file is re-written");
181
182        let _ = std::fs::remove_dir_all(&dir);
183    }
184}