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}