1use bevy_ecs::prelude::*;
14use lumen_core::components::{LumenId, SliderValue, TextContent, Toggleable, Validation};
15use lumen_core::prelude::TickStage;
16use lumen_core::property_store::PropertyStore;
17
18pub 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#[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 if store.get_global_bool(&key) != Some(valid) {
70 store.set_global_bool(&key, valid);
71 }
72 }
73 }
74}
75
76pub 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
118pub 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
144fn 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
153fn is_iso_date(text: &str) -> bool {
155 let t = text.trim();
156 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
171fn 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 assert!(evaluate(&v, Some("2026-02-31"), None, None));
224 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}