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). [`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}