Skip to main content

lumen_primitives/
validation.rs

1//! Form-field validation driver.
2//!
3//! Recomputes the `is_valid` flag on every entity carrying a [`Validation`] component and mirrors
4//! the result into [`PropertyStore`] under the `valid:<lumen-id>` global key.
5//!
6//! Supported matchers:
7//!
8//! - `required`: trimmed text must be non-empty; the slider value must be `> 0`; the toggle must be `checked`.
9//! - `pattern`: content must contain the configured literal substring, or match a
10//!   named shape when the pattern starts with `shape:` (see [`matches_pattern`]).
11//! - `min` / `max`: numeric range applied to slider values and to input text when it parses as a number.
12
13use bevy_ecs::prelude::*;
14use lumen_core::components::{LumenId, SliderValue, TextContent, Toggleable, Validation};
15use lumen_core::prelude::TickStage;
16use lumen_core::property_store::PropertyStore;
17
18/// Plugin: registers [`apply_validation`] in `TickStage::Systems` so
19/// validation runs after author-side tick logic has settled.
20pub struct ValidationPlugin;
21
22impl lumen_core::prelude::Plugin for ValidationPlugin {
23    fn build(self, app: &mut lumen_core::prelude::App) {
24        app.add_systems(TickStage::Systems, apply_validation);
25    }
26}
27
28/// Walk every entity whose validation input changed this tick. Pick the
29/// most relevant content source (TextContent -> slider -> toggle), apply
30/// the rules, write the result back to [`Validation::is_valid`], and
31/// mirror into `PropertyStore[Global("valid:<id>")]` when the entity has a stable
32/// [`LumenId`].
33///
34/// The `Or<(Changed<TextContent>, Changed<SliderValue>, Changed<Toggleable>, Added<Validation>)>`
35/// filter gates the walk so steady-state ticks (most ticks, in practice)
36/// do zero work. `Added<Validation>` keeps the freshly-spawned entity
37/// running through its rules on insert even if no source-of-truth
38/// component has mutated yet. Re-validation on `Validation` rule
39/// changes (e.g. an author swaps `min` via class flip) is handled by
40/// the Wave 1.5 / 1.6 PropertyStore notify side and not gated here.
41#[allow(clippy::type_complexity)]
42pub fn apply_validation(
43    mut q: Query<
44        (
45            &mut Validation,
46            Option<&TextContent>,
47            Option<&SliderValue>,
48            Option<&Toggleable>,
49            Option<&LumenId>,
50        ),
51        Or<(
52            Changed<TextContent>,
53            Changed<SliderValue>,
54            Changed<Toggleable>,
55            Added<Validation>,
56        )>,
57    >,
58    mut store: ResMut<PropertyStore>,
59) {
60    for (mut v, tc, sv, tg, id) in &mut q {
61        let valid = evaluate(&v, tc.map(|t| t.0.as_str()), sv.copied(), tg.copied());
62        if v.is_valid != valid {
63            v.is_valid = valid;
64        }
65        if let Some(id) = id {
66            let key = format!("valid:{}", id.0);
67            // `PropertyStore::set_global_bool` is internally idempotent - it skips the
68            // dirty-queue push when the cell is already at the target value.
69            if store.get_global_bool(&key) != Some(valid) {
70                store.set_global_bool(&key, valid);
71            }
72        }
73    }
74}
75
76/// Pure validation function - handy for tests and any author-side
77/// script-bridge that wants to validate a value without spawning an
78/// entity. Pass the relevant slot for the entity's kind; the other
79/// slots stay `None`.
80pub fn evaluate(
81    v: &Validation,
82    text: Option<&str>,
83    slider: Option<SliderValue>,
84    toggle: Option<Toggleable>,
85) -> bool {
86    if v.required {
87        let has_value = text
88            .map(|s| !s.trim().is_empty())
89            .or_else(|| toggle.map(|t| t.checked))
90            .or_else(|| slider.map(|s| s.value > 0.0))
91            .unwrap_or(false);
92        if !has_value {
93            return false;
94        }
95    }
96    if let (Some(pat), Some(t)) = (v.pattern.as_deref(), text)
97        && !matches_pattern(pat, t)
98    {
99        return false;
100    }
101    let numeric: Option<f32> = text.and_then(|t| t.trim().parse().ok());
102    let candidate: Option<f32> = numeric.or_else(|| slider.map(|s| s.value));
103    if let Some(n) = candidate {
104        if let Some(lo) = v.min
105            && n < lo
106        {
107            return false;
108        }
109        if let Some(hi) = v.max
110            && n > hi
111        {
112            return false;
113        }
114    }
115    true
116}
117
118/// Apply a `pattern` rule to one value.
119///
120/// A pattern starting with `shape:` names a built-in structural check;
121/// `<date-picker>` and `<time-picker>` compile to inputs carrying one.
122/// Any other pattern is the literal-substring rule.
123///
124/// | Pattern | Shape | Ranges |
125/// | --- | --- | --- |
126/// | `shape:date` | `YYYY-MM-DD` | month 01-12, day 01-31 |
127/// | `shape:time` | `HH:MM` | hour 00-23, minute 00-59 |
128///
129/// Both are shape checks, not calendar checks: `2026-02-31` passes.
130/// Surrounding whitespace is trimmed first. An unknown `shape:` name
131/// never matches, so a typo fails loudly instead of silently accepting
132/// everything.
133pub fn matches_pattern(pattern: &str, text: &str) -> bool {
134    match pattern {
135        "shape:date" => is_iso_date(text),
136        "shape:time" => is_clock_time(text),
137        other => match other.strip_prefix("shape:") {
138            Some(_) => false,
139            None => text.contains(other),
140        },
141    }
142}
143
144/// Parse a fixed-width run of ASCII digits into a number, rejecting any
145/// other byte. `slice` must already be the exact span to read.
146fn fixed_digits(slice: &str, width: usize) -> Option<u32> {
147    if slice.len() != width || !slice.bytes().all(|b| b.is_ascii_digit()) {
148        return None;
149    }
150    slice.parse().ok()
151}
152
153/// `YYYY-MM-DD` with month 01-12 and day 01-31.
154fn is_iso_date(text: &str) -> bool {
155    let t = text.trim();
156    // ASCII gate first: the byte slicing below is only char-boundary
157    // safe once every byte is a single-byte character.
158    if !t.is_ascii() || t.len() != 10 || t.as_bytes()[4] != b'-' || t.as_bytes()[7] != b'-' {
159        return false;
160    }
161    let (Some(_year), Some(month), Some(day)) = (
162        fixed_digits(&t[0..4], 4),
163        fixed_digits(&t[5..7], 2),
164        fixed_digits(&t[8..10], 2),
165    ) else {
166        return false;
167    };
168    (1..=12).contains(&month) && (1..=31).contains(&day)
169}
170
171/// 24-hour `HH:MM` with hour 00-23 and minute 00-59.
172fn is_clock_time(text: &str) -> bool {
173    let t = text.trim();
174    if !t.is_ascii() || t.len() != 5 || t.as_bytes()[2] != b':' {
175        return false;
176    }
177    let (Some(hour), Some(minute)) = (fixed_digits(&t[0..2], 2), fixed_digits(&t[3..5], 2)) else {
178        return false;
179    };
180    hour <= 23 && minute <= 59
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn validation(
188        required: bool,
189        pattern: Option<&str>,
190        min: Option<f32>,
191        max: Option<f32>,
192    ) -> Validation {
193        Validation {
194            required,
195            pattern: pattern.map(|s| s.to_string()),
196            min,
197            max,
198            is_valid: true,
199        }
200    }
201
202    #[test]
203    fn required_rejects_empty_text() {
204        let v = validation(true, None, None, None);
205        assert!(!evaluate(&v, Some(""), None, None));
206        assert!(!evaluate(&v, Some("   "), None, None));
207        assert!(evaluate(&v, Some("hi"), None, None));
208    }
209
210    #[test]
211    fn pattern_requires_substring() {
212        let v = validation(false, Some("@"), None, None);
213        assert!(!evaluate(&v, Some("plain"), None, None));
214        assert!(evaluate(&v, Some("name@host"), None, None));
215    }
216
217    #[test]
218    fn date_shape_checks_positions_and_ranges() {
219        let v = validation(false, Some("shape:date"), None, None);
220        assert!(evaluate(&v, Some("2026-08-10"), None, None));
221        assert!(evaluate(&v, Some("0001-01-01"), None, None));
222        // Shape, not calendar: day 31 of February passes.
223        assert!(evaluate(&v, Some("2026-02-31"), None, None));
224        // Surrounding whitespace is trimmed, as it is for `required`.
225        assert!(evaluate(&v, Some("  2026-08-10\t"), None, None));
226        for bad in [
227            "",
228            "-",
229            "not-a-date",
230            "2026-8-10",
231            "26-08-10",
232            "2026/08/10",
233            "2026-13-01",
234            "2026-00-01",
235            "2026-08-32",
236            "2026-08-00",
237            "2026-08-1o",
238            "2026-08-10x",
239            "2026-08-10-01",
240        ] {
241            assert!(
242                !evaluate(&v, Some(bad), None, None),
243                "{bad:?} must not pass the date shape"
244            );
245        }
246    }
247
248    #[test]
249    fn time_shape_checks_positions_and_ranges() {
250        let v = validation(false, Some("shape:time"), None, None);
251        assert!(evaluate(&v, Some("00:00"), None, None));
252        assert!(evaluate(&v, Some("23:59"), None, None));
253        for bad in [
254            "", ":", "9:05", "24:00", "23:60", "1:2", "12:5", "12-05", "aa:bb",
255        ] {
256            assert!(
257                !evaluate(&v, Some(bad), None, None),
258                "{bad:?} must not pass the time shape"
259            );
260        }
261    }
262
263    #[test]
264    fn unknown_shape_never_matches() {
265        assert!(!matches_pattern("shape:weekday", "monday"));
266    }
267
268    #[test]
269    fn non_ascii_input_does_not_panic() {
270        assert!(!matches_pattern("shape:date", "2026-08-1\u{00e9}"));
271        assert!(!matches_pattern("shape:time", "1\u{00e9}:00"));
272    }
273
274    #[test]
275    fn numeric_bounds_apply_to_text() {
276        let v = validation(false, None, Some(0.0), Some(100.0));
277        assert!(evaluate(&v, Some("50"), None, None));
278        assert!(!evaluate(&v, Some("-1"), None, None));
279        assert!(!evaluate(&v, Some("101"), None, None));
280    }
281
282    #[test]
283    fn slider_value_validated_against_bounds() {
284        let v = validation(false, None, Some(0.5), Some(1.0));
285        let lo = SliderValue {
286            value: 0.4,
287            min: 0.0,
288            max: 1.0,
289            step: None,
290        };
291        let hi = SliderValue {
292            value: 0.8,
293            min: 0.0,
294            max: 1.0,
295            step: None,
296        };
297        assert!(!evaluate(&v, None, Some(lo), None));
298        assert!(evaluate(&v, None, Some(hi), None));
299    }
300
301    #[test]
302    fn required_toggle_must_be_checked() {
303        let v = validation(true, None, None, None);
304        assert!(!evaluate(
305            &v,
306            None,
307            None,
308            Some(Toggleable { checked: false })
309        ));
310        assert!(evaluate(&v, None, None, Some(Toggleable { checked: true })));
311    }
312}