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). [`drift`] reads that same
19//! directory against the embedded set without changing it, because seeding
20//! writes if-absent and never prunes: what an operator keeps diverges, and a
21//! shell has to be able to say how.
22
23pub mod expr;
24pub mod path;
25pub mod schema;
26
27use std::path::{Path, PathBuf};
28
29pub use expr::{
30 Expr, ExprError, GateFlag, GateKind, LATCH_CAP, NodeObservation, Observations,
31 SATURATED_OCCUPANCY, Variables, compile,
32};
33pub use schema::export;
34pub use schema::{
35 Binding, Easing, GLOBAL_PARAMS, HoldEdge, KeyDesc, KeyKind, Latch, Layer, LayerBlend,
36 LayerJoin, Preset, PresetError, PresetWarning, Roster, SystemKind, TableDesc, is_known_param,
37 kind_of_param,
38};
39
40// The shipped example presets, embedded at compile time so the C-ABI/foobar
41// path always has visuals without a preset directory (ADR-0006). This list is
42// **generated** — `core/build.rs` globs `presets/*.toml` and emits
43// `pub static EMBEDDED: &[(&str, &str)]` as `(filename, contents)` tuples,
44// sorted by filename, each embedded via `include_str!` (ADR-0022). Drop a
45// `.toml` in `presets/` at the repo root and rebuild — it ships, with no edit
46// here and no count to bump. (See `core/build.rs` for how the entries are
47// produced; they are not a literal array in this file.)
48include!(concat!(env!("OUT_DIR"), "/embedded_presets.rs"));
49
50/// Parse the embedded example presets. The shipped files are valid, so on the
51/// off chance one fails it is skipped rather than panicking — the caller still
52/// gets a usable set.
53pub fn default_presets() -> Vec<Preset> {
54 EMBEDDED
55 .iter()
56 .filter_map(|(_, src)| Preset::from_toml_str(src).ok())
57 .collect()
58}
59
60/// Write each embedded curated preset into `dir`, creating `dir` (and any
61/// missing parents) first, but **never overwriting** a file that already
62/// exists — a user's edits to a seeded preset survive re-seeding. Returns how
63/// many files were newly written. Idempotent: a second call on an
64/// already-seeded directory writes zero.
65///
66/// Because seeding never clobbers, a curated preset changed in a later release
67/// does **not** replace the copy a user already has on disk (a "refresh
68/// curated" affordance is a follow-up, not this function's job). Errors bubble
69/// up as `io::Result` so the caller can degrade to the embedded defaults rather
70/// than crash (NFR 10).
71pub fn seed_dir(dir: &Path) -> std::io::Result<usize> {
72 std::fs::create_dir_all(dir)?;
73 let mut written = 0;
74 for &(name, contents) in EMBEDDED {
75 let path = dir.join(name);
76 if !path.exists() {
77 std::fs::write(&path, contents)?;
78 written += 1;
79 }
80 }
81 Ok(written)
82}
83
84/// How one file in a preset directory stands against the set this build ships.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum DriftStatus {
87 /// The filename is in [`EMBEDDED`] and the bytes are equal.
88 Shipped,
89 /// The filename is in [`EMBEDDED`] and the bytes differ.
90 ///
91 /// **An operator's edit and an older release's copy are indistinguishable
92 /// here.** [`seed_dir`] never overwrites, so a preset retuned upstream
93 /// leaves the copy already on disk in place, and nothing on disk records
94 /// which shipped version that copy came from. Telling the two apart would
95 /// need a manifest of every past release's hashes, which no build carries.
96 Differs,
97 /// The filename is not in [`EMBEDDED`] — an operator's own preset, or one a
98 /// later release retired. Seeding never removes a file, so both linger.
99 NotShipped,
100}
101
102impl DriftStatus {
103 /// The word a report row prints for this status.
104 pub fn as_str(self) -> &'static str {
105 match self {
106 DriftStatus::Shipped => "shipped",
107 DriftStatus::Differs => "differs",
108 DriftStatus::NotShipped => "not shipped",
109 }
110 }
111}
112
113/// One `*.toml` in a preset directory, as [`drift`] judged it.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct DriftEntry {
116 /// The file name alone, without the directory.
117 pub file: String,
118 /// Its standing against the shipped set.
119 pub status: DriftStatus,
120 /// The display name the file compiles to, or `None` when it does not
121 /// compile — a file that cannot be read is judged by its name alone.
122 pub name: Option<String>,
123}
124
125/// A display name claimed by more than one file that compiles.
126///
127/// Only the first file is reachable: `load_dir` sorts by filename and
128/// `Renderer::select_preset_by_name` takes the first exact match, so every
129/// later claimant is loaded, rotated through, and unreachable by name.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct DuplicateName {
132 /// The contested display name.
133 pub name: String,
134 /// The files claiming it, in filename order. Never shorter than two.
135 pub files: Vec<String>,
136}
137
138impl DuplicateName {
139 /// The file a lookup by this name resolves to: the first in filename order.
140 pub fn winner(&self) -> &str {
141 // The constructor never builds an empty claim list, so the index holds.
142 &self.files[0]
143 }
144}
145
146/// What a preset directory holds beyond the set this build ships.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct DriftReport {
149 /// One row per `*.toml`, in filename order.
150 pub entries: Vec<DriftEntry>,
151 /// Every display name claimed twice or more, in first-claim order.
152 pub duplicates: Vec<DuplicateName>,
153}
154
155impl DriftReport {
156 /// How many files carry a shipped name with different bytes.
157 pub fn differs(&self) -> usize {
158 self.count(DriftStatus::Differs)
159 }
160
161 /// How many files carry a name the shipped set does not have.
162 pub fn not_shipped(&self) -> usize {
163 self.count(DriftStatus::NotShipped)
164 }
165
166 fn count(&self, status: DriftStatus) -> usize {
167 self.entries
168 .iter()
169 .filter(|entry| entry.status == status)
170 .count()
171 }
172
173 /// Whether anything in the directory is not exactly what this build ships.
174 pub fn has_drift(&self) -> bool {
175 self.differs() > 0 || self.not_shipped() > 0 || !self.duplicates.is_empty()
176 }
177
178 /// The one-line summary a shell prints after seeding, or `None` when the
179 /// directory is exactly the shipped set — which is the normal case and must
180 /// stay silent.
181 ///
182 /// User-visible text: a shell prints it verbatim, after naming the subject
183 /// itself. The subject is the caller's because only the caller knows which
184 /// directory resolved, and because a diagnostic the shell writes must not
185 /// begin with an interpolation — a parent splits its stream on the first
186 /// byte (ADR-0176).
187 pub fn line(&self) -> Option<String> {
188 if !self.has_drift() {
189 return None;
190 }
191 let mut parts = Vec::new();
192 let differs = self.differs();
193 if differs > 0 {
194 parts.push(format!(
195 "{differs} file(s) differ from the copy this build ships (an edit, or an \
196 older release's copy - they cannot be told apart)"
197 ));
198 }
199 let not_shipped = self.not_shipped();
200 if not_shipped > 0 {
201 parts.push(format!("{not_shipped} file(s) are not in the shipped set"));
202 }
203 if !self.duplicates.is_empty() {
204 let claimed: Vec<String> = self
205 .duplicates
206 .iter()
207 .map(|duplicate| {
208 format!("'{}' (reached as {})", duplicate.name, duplicate.winner())
209 })
210 .collect();
211 parts.push(format!(
212 "{} display name(s) claimed by more than one file, of which only the first \
213 is reachable by name: {}",
214 self.duplicates.len(),
215 claimed.join(", ")
216 ));
217 }
218 Some(format!(
219 "{}. Run --list-presets for the per-file rows.",
220 parts.join("; ")
221 ))
222 }
223}
224
225/// Judge every `*.toml` in `dir` against [`EMBEDDED`], without changing
226/// anything: seeding writes if-absent and never prunes, so a directory an
227/// operator keeps drifts from the shipped set in three ways at once, and this
228/// is the read that names them.
229///
230/// Pure over the directory listing and the embedded set. A missing or
231/// unreadable directory yields an empty report rather than an error, which
232/// reads as "no drift" — the caller has nothing to say about a directory that
233/// is not there (NFR 10).
234pub fn drift(dir: &Path) -> DriftReport {
235 let mut paths: Vec<PathBuf> = match std::fs::read_dir(dir) {
236 Ok(entries) => entries
237 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
238 .filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
239 .collect(),
240 Err(_) => return DriftReport::default(),
241 };
242 // Filename order, which is the order `load_dir` loads in and therefore the
243 // order that decides which of two files claiming one name is reachable.
244 paths.sort();
245
246 let mut entries = Vec::new();
247 // Display name -> the files claiming it, both kept in first-seen order so
248 // the report is stable across runs.
249 let mut claims: Vec<(String, Vec<String>)> = Vec::new();
250
251 for path in paths {
252 let Some(file) = path.file_name().and_then(|name| name.to_str()) else {
253 continue;
254 };
255 let file = file.to_owned();
256 let source = std::fs::read_to_string(&path).ok();
257 let shipped = EMBEDDED
258 .iter()
259 .find(|&&(name, _)| name == file)
260 .map(|&(_, contents)| contents);
261 // A file whose name is shipped but which cannot be read is `Differs`:
262 // the bytes are not known to be equal, and claiming they are would be
263 // the silent case this function exists to end.
264 let status = match (shipped, source.as_deref()) {
265 (Some(contents), Some(src)) if contents == src => DriftStatus::Shipped,
266 (Some(_), _) => DriftStatus::Differs,
267 (None, _) => DriftStatus::NotShipped,
268 };
269 let name = source
270 .as_deref()
271 .and_then(|src| Preset::from_toml_str(src).ok())
272 .map(|preset| preset.name);
273 if let Some(name) = &name {
274 match claims.iter_mut().find(|(claimed, _)| claimed == name) {
275 Some((_, files)) => files.push(file.clone()),
276 None => claims.push((name.clone(), vec![file.clone()])),
277 }
278 }
279 entries.push(DriftEntry { file, status, name });
280 }
281
282 let duplicates = claims
283 .into_iter()
284 .filter(|(_, files)| files.len() > 1)
285 .map(|(name, files)| DuplicateName { name, files })
286 .collect();
287
288 DriftReport {
289 entries,
290 duplicates,
291 }
292}
293
294/// The outcome of loading a preset directory: the presets that compiled, in
295/// filename order, plus the files that failed and the non-fatal problems found
296/// in the ones that succeeded (so the caller can surface both).
297pub struct LoadReport {
298 /// Successfully compiled presets, sorted by filename for a stable cycle.
299 pub presets: Vec<Preset>,
300 /// `(path, error)` for each `.toml` that failed to read or compile.
301 pub errors: Vec<(PathBuf, PresetError)>,
302 /// `(path, warning)` for each non-fatal problem in a preset that **did**
303 /// load — a binding naming a parameter its system does not consume
304 /// (ADR-0020), among others. Surfacing these is what stops a typo from
305 /// failing silently; the preset itself is in `presets` and renders normally.
306 /// Each warning keeps the binding label it carries (ADR-0192).
307 pub warnings: Vec<(PathBuf, PresetWarning)>,
308}
309
310/// Load every `*.toml` in `dir`, compiling each into a [`Preset`]. Missing or
311/// unreadable directories yield an empty report rather than an error; a bad
312/// file lands in `errors` and does not stop the others (degrade, never crash).
313pub fn load_dir(dir: &Path) -> LoadReport {
314 let mut presets = Vec::new();
315 let mut errors = Vec::new();
316 let mut warnings = Vec::new();
317
318 let mut paths: Vec<PathBuf> = match std::fs::read_dir(dir) {
319 Ok(entries) => entries
320 .filter_map(|e| e.ok().map(|e| e.path()))
321 .filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
322 .collect(),
323 Err(_) => {
324 return LoadReport {
325 presets,
326 errors,
327 warnings,
328 };
329 }
330 };
331 paths.sort();
332
333 for path in paths {
334 match std::fs::read_to_string(&path) {
335 Ok(src) => match Preset::from_toml_str(&src) {
336 Ok(mut preset) => {
337 warnings.extend(preset.warnings.iter().map(|w| (path.clone(), w.clone())));
338 // Absolute, because the consumers that want it — an editor
339 // that writes the file back, a report that names it — do not
340 // share this process's working directory. `absolute` is
341 // lexical: it prepends the cwd and normalizes, touching no
342 // filesystem and, unlike `canonicalize`, producing no `\?\`
343 // prefix on Windows for a path that then has to be handed to
344 // another program. A cwd that cannot be read leaves the path
345 // as it was rather than dropping the preset (NFR 10).
346 preset.source =
347 Some(std::path::absolute(&path).unwrap_or_else(|_| path.clone()));
348 presets.push(preset);
349 }
350 Err(err) => errors.push((path, err)),
351 },
352 Err(err) => errors.push((path, PresetError::Io(err.to_string()))),
353 }
354 }
355
356 LoadReport {
357 presets,
358 errors,
359 warnings,
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn seed_dir_writes_all_then_nothing() {
369 let dir = std::env::temp_dir().join("rlx_seed_dir_test");
370 let _ = std::fs::remove_dir_all(&dir);
371
372 // First seed into an empty dir: every embedded preset is written.
373 let written = seed_dir(&dir).expect("seed into fresh temp dir");
374 assert_eq!(
375 written,
376 EMBEDDED.len(),
377 "first seed writes every embedded preset"
378 );
379 for &(name, _) in EMBEDDED {
380 assert!(dir.join(name).exists(), "{name} was seeded");
381 }
382
383 // Second seed: write-if-absent means nothing is written and nothing is
384 // clobbered.
385 let again = seed_dir(&dir).expect("re-seed already-seeded dir");
386 assert_eq!(
387 again, 0,
388 "re-seeding writes zero (idempotent, no overwrite)"
389 );
390
391 // Deleting one seeded file re-seeds only that file.
392 let (victim, _) = EMBEDDED[0];
393 std::fs::remove_file(dir.join(victim)).expect("remove one seeded file");
394 let refill = seed_dir(&dir).expect("re-seed after deletion");
395 assert_eq!(refill, 1, "only the missing file is re-written");
396
397 let _ = std::fs::remove_dir_all(&dir);
398 }
399
400 /// **A directory that is exactly the shipped set says nothing.** The line is
401 /// printed on every launch, so a false positive on an untouched install
402 /// would be permanent noise; the formatter returning `None` is what the
403 /// shell's silence rests on.
404 #[test]
405 fn a_freshly_seeded_dir_has_no_drift_and_no_line() {
406 let dir = std::env::temp_dir().join("rlx_drift_clean_test");
407 let _ = std::fs::remove_dir_all(&dir);
408 seed_dir(&dir).expect("seed into fresh temp dir");
409
410 let report = drift(&dir);
411 assert_eq!(
412 report.entries.len(),
413 EMBEDDED.len(),
414 "every seeded file is judged"
415 );
416 assert!(
417 report
418 .entries
419 .iter()
420 .all(|entry| entry.status == DriftStatus::Shipped),
421 "a seeded file that is not `Shipped`: {:?}",
422 report
423 .entries
424 .iter()
425 .find(|entry| entry.status != DriftStatus::Shipped)
426 );
427 assert!(
428 report.duplicates.is_empty(),
429 "the shipped set claims a display name twice: {:?}",
430 report.duplicates
431 );
432 assert!(!report.has_drift());
433 assert_eq!(report.line(), None, "an undrifted directory prints no line");
434
435 let _ = std::fs::remove_dir_all(&dir);
436 }
437
438 /// **The three kinds of drift, each reported once.** An edited shipped file,
439 /// a file the shipped set does not have, and a display name a second file
440 /// claims — which is the case that costs an operator a preset, since only
441 /// the first file in filename order is reachable by name.
442 #[test]
443 fn drift_reports_an_edit_an_extra_file_and_a_duplicate_name() {
444 let dir = std::env::temp_dir().join("rlx_drift_test");
445 let _ = std::fs::remove_dir_all(&dir);
446 seed_dir(&dir).expect("seed into fresh temp dir");
447
448 // An edit to a shipped file: still compiles, still its own name, and no
449 // longer the bytes this build carries.
450 let (edited, edited_src) = EMBEDDED[1];
451 std::fs::write(
452 dir.join(edited),
453 format!("{edited_src}\n# an operator's note\n"),
454 )
455 .expect("edit one seeded file");
456
457 // A file the shipped set does not have, carrying a display name a
458 // shipped preset already claims. `zz_` sorts after every seeded name,
459 // so the shipped file is the one a lookup reaches.
460 let (shadowed, duplicate_src) = EMBEDDED[0];
461 std::fs::write(dir.join("zz_duplicate.toml"), duplicate_src)
462 .expect("write the duplicate-name file");
463 let duplicated = Preset::from_toml_str(duplicate_src)
464 .expect("a shipped preset compiles")
465 .name;
466
467 let report = drift(&dir);
468 assert_eq!(report.differs(), 1, "exactly the edited file differs");
469 assert_eq!(
470 report.not_shipped(),
471 1,
472 "exactly the added file is not shipped"
473 );
474 assert_eq!(
475 report.entries.len(),
476 EMBEDDED.len() + 1,
477 "every file is judged, the added one included"
478 );
479 assert_eq!(
480 report
481 .entries
482 .iter()
483 .find(|entry| entry.file == edited)
484 .map(|entry| entry.status),
485 Some(DriftStatus::Differs)
486 );
487 assert_eq!(
488 report
489 .entries
490 .iter()
491 .find(|entry| entry.file == "zz_duplicate.toml")
492 .map(|entry| entry.status),
493 Some(DriftStatus::NotShipped)
494 );
495
496 assert_eq!(report.duplicates.len(), 1, "one contested display name");
497 let duplicate = &report.duplicates[0];
498 assert_eq!(duplicate.name, duplicated);
499 assert_eq!(
500 duplicate.files,
501 vec![shadowed.to_owned(), "zz_duplicate.toml".to_owned()]
502 );
503 assert_eq!(
504 duplicate.winner(),
505 shadowed,
506 "filename order decides which file the name reaches"
507 );
508
509 assert!(report.has_drift());
510 let line = report.line().expect("a drifted directory prints a line");
511 assert!(
512 line.contains(&duplicated) && line.contains(shadowed),
513 "the line names the contested name and the file that wins it: {line}"
514 );
515 assert!(
516 line.contains("--list-presets"),
517 "the line says where the rows are: {line}"
518 );
519
520 let _ = std::fs::remove_dir_all(&dir);
521 }
522}