Skip to main content

tilepad_manifest/
validation.rs

1use crate::plugin::{ActionId, ActionMap};
2use garde::{
3    Path, Report, Validate,
4    error::{Kind, PathComponentKind},
5};
6
7/// Separators allowed in names
8static NAME_SEPARATORS: [char; 2] = ['-', '_'];
9
10/// Validate an ID (plugin ID or icon pack ID)
11pub fn validate_id(value: &str, _context: &()) -> garde::Result {
12    let parts = value.split('.');
13
14    for part in parts {
15        // Must start with a letter
16        if !part.starts_with(|char: char| char.is_ascii_alphabetic()) {
17            return Err(garde::Error::new(
18                "segment must start with a ascii alphabetic character",
19            ));
20        }
21
22        // Must only contain a-zA-Z0-9_-
23        if !part
24            .chars()
25            .all(|char| char.is_alphanumeric() || NAME_SEPARATORS.contains(&char))
26        {
27            return Err(garde::Error::new(
28                "name domain segment must only contain alpha numeric values and _ or -",
29            ));
30        }
31
32        // Must not end with - or _
33        if part.ends_with(NAME_SEPARATORS) {
34            return Err(garde::Error::new(
35                "name domain segment must not end with _ or -",
36            ));
37        }
38    }
39
40    Ok(())
41}
42
43// Validates that a action name is valid
44pub fn validate_name(value: &str, _context: &()) -> garde::Result {
45    // Must start with a letter
46    if !value.starts_with(|char: char| char.is_ascii_alphabetic()) {
47        return Err(garde::Error::new(
48            "name must start with a ascii alphabetic character",
49        ));
50    }
51
52    // Must only contain a-zA-Z0-9_-
53    if !value
54        .chars()
55        .all(|char| char.is_alphanumeric() || NAME_SEPARATORS.contains(&char))
56    {
57        return Err(garde::Error::new(
58            "name must only contain alpha numeric values and _ or -",
59        ));
60    }
61
62    // Must not end with - or _
63    if value.ends_with(NAME_SEPARATORS) {
64        return Err(garde::Error::new("name must not end with _ or -"));
65    }
66
67    Ok(())
68}
69
70impl Validate for ActionMap {
71    type Context = ();
72
73    fn validate_into(&self, ctx: &(), mut parent: &mut dyn FnMut() -> Path, report: &mut Report) {
74        for (key, value) in self.0.iter() {
75            let mut path = garde::util::nested_path!(parent, key);
76            value.validate_into(ctx, &mut path, report);
77        }
78    }
79}
80
81impl PathComponentKind for ActionId {
82    fn component_kind() -> Kind {
83        Kind::Key
84    }
85}
86
87/// Validates that a string is a valid color value supports:
88/// - hex
89/// - rgb/rgba
90/// - hsl/hsla
91///
92/// Does not check for named colors, we don't really want those anyway
93/// as they aren't really useful
94pub fn validate_color(value: &str, _context: &()) -> garde::Result {
95    let value = value.trim().to_lowercase();
96
97    // Hex
98    if value.starts_with('#') {
99        return validate_hex_color(&value);
100    }
101
102    // RGB
103    if value.starts_with("rgb(") {
104        return validate_rgb_color(&value);
105    }
106
107    // RGBA
108    if value.starts_with("rgba(") {
109        return validate_rgba_color(&value);
110    }
111
112    // HSL
113    if value.starts_with("hsl(") {
114        return validate_hsl_color(&value);
115    }
116
117    // HSLA
118    if value.starts_with("hsla(") {
119        return validate_hsla_color(&value);
120    }
121
122    Err(garde::Error::new("invalid color value"))
123}
124
125/// Validate a hex color
126fn validate_hex_color(value: &str) -> garde::Result {
127    let value = value
128        .strip_prefix('#')
129        .ok_or_else(|| garde::Error::new("hex color must start with #"))?;
130
131    match value.len() {
132        3 | 4 | 6 | 8 => {}
133        _ => {
134            return Err(garde::Error::new(
135                "hex color must be 3, 4, 6, or 8 hex digits",
136            ));
137        }
138    }
139
140    if !value.chars().all(|c| c.is_ascii_hexdigit()) {
141        return Err(garde::Error::new("hex color contains invalid characters"));
142    }
143
144    Ok(())
145}
146
147/// Validate a rgb() color
148fn validate_rgb_color(value: &str) -> garde::Result {
149    // Strip opening
150    let value = value
151        .strip_prefix("rgb(")
152        .ok_or_else(|| garde::Error::new("rgb color must start with rgb("))?;
153
154    // Strip closing
155    let value = value
156        .strip_suffix(")")
157        .ok_or_else(|| garde::Error::new("unclosed rgb color"))?;
158
159    let parts: Vec<&str> = value.split(',').map(|s| s.trim()).collect();
160
161    if parts.len() != 3 {
162        return Err(garde::Error::new("invalid rgb color"));
163    }
164
165    for part in parts {
166        parse_rgb_component(part)?;
167    }
168
169    Ok(())
170}
171
172/// Validate a rgba() color
173fn validate_rgba_color(value: &str) -> garde::Result {
174    // Strip opening
175    let value = value
176        .strip_prefix("rgba(")
177        .ok_or_else(|| garde::Error::new("rgba color must start with rgba("))?;
178
179    // Strip closing
180    let value = value
181        .strip_suffix(")")
182        .ok_or_else(|| garde::Error::new("unclosed rgba color"))?;
183
184    let parts: Vec<&str> = value.split(',').map(|s| s.trim()).collect();
185
186    if parts.len() != 4 {
187        return Err(garde::Error::new("invalid rgba color"));
188    }
189
190    // RGB components
191    for part in &parts[..3] {
192        parse_rgb_component(part)?;
193    }
194
195    // Alpha component
196    parse_alpha(parts[3])?;
197
198    Ok(())
199}
200
201/// Validate a hsl() color
202fn validate_hsl_color(value: &str) -> garde::Result {
203    // Strip opening
204    let value = value
205        .strip_prefix("hsl(")
206        .ok_or_else(|| garde::Error::new("hsl color must start with hsl("))?;
207
208    // Strip closing
209    let value = value
210        .strip_suffix(")")
211        .ok_or_else(|| garde::Error::new("unclosed hsl color"))?;
212
213    let parts: Vec<&str> = value.split(',').map(|s| s.trim()).collect();
214
215    if parts.len() != 3 {
216        return Err(garde::Error::new("invalid hsl color"));
217    }
218
219    parse_hue(parts[0])?;
220    parse_percentage(parts[1])?;
221    parse_percentage(parts[2])?;
222
223    Ok(())
224}
225
226/// Validate a hsla() color
227fn validate_hsla_color(value: &str) -> garde::Result {
228    // Strip opening
229    let value = value
230        .strip_prefix("hsla(")
231        .ok_or_else(|| garde::Error::new("hsla color must start with hsla("))?;
232
233    // Strip closing
234    let value = value
235        .strip_suffix(")")
236        .ok_or_else(|| garde::Error::new("unclosed hsla color"))?;
237
238    let parts: Vec<&str> = value.split(',').map(|s| s.trim()).collect();
239
240    if parts.len() != 4 {
241        return Err(garde::Error::new("invalid hsla color"));
242    }
243
244    parse_hue(parts[0])?;
245    parse_percentage(parts[1])?;
246    parse_percentage(parts[2])?;
247    parse_alpha(parts[3])?;
248
249    Ok(())
250}
251
252/// Parse an RGB component (0–255 or 0–100%)
253fn parse_rgb_component(s: &str) -> garde::Result {
254    if s.ends_with('%') {
255        return parse_percentage(s);
256    }
257
258    let v: u16 = s.parse().map_err(|_| garde::Error::new("invalid number"))?;
259    if v > 255 {
260        return Err(garde::Error::new("rgb exceeded 255 bound"));
261    }
262
263    Ok(())
264}
265
266/// Parse an alpha channel (0–1)
267fn parse_alpha(s: &str) -> garde::Result {
268    if s.parse::<f64>()
269        .is_ok_and(|value| (0.0..=1.0).contains(&value))
270    {
271        return Ok(());
272    }
273
274    Err(garde::Error::new("invalid alpha"))
275}
276
277/// Parse hue (0–360)
278fn parse_hue(s: &str) -> garde::Result {
279    let value: u16 = s.parse().map_err(|_| garde::Error::new("invalid hue"))?;
280    if value > 360 {
281        return Err(garde::Error::new("hue must not be greater than 360"));
282    }
283
284    Ok(())
285}
286
287/// Parse percentage (0–100%)
288fn parse_percentage(s: &str) -> garde::Result {
289    let number = s
290        .strip_suffix('%')
291        .ok_or_else(|| garde::Error::new("missing % sign"))?;
292
293    let value: u8 = number
294        .parse()
295        .map_err(|_| garde::Error::new("invalid percent"))?;
296
297    if value > 100 {
298        return Err(garde::Error::new("percent > 100"));
299    }
300
301    Ok(())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn validate_id_allows_simple_valid_id() {
310        assert!(validate_id("plugin.test", &()).is_ok());
311        assert!(validate_id("abc.def123", &()).is_ok());
312        assert!(validate_id("abc-def.ghi_jkl", &()).is_ok());
313    }
314
315    #[test]
316    fn validate_id_fails_if_segment_does_not_start_with_letter() {
317        assert!(validate_id("1plugin.test", &()).is_err());
318        assert!(validate_id("plugin.1test", &()).is_err());
319        assert!(validate_id(".test", &()).is_err());
320    }
321
322    #[test]
323    fn validate_id_fails_if_segment_contains_invalid_characters() {
324        assert!(validate_id("plugin.te$t", &()).is_err());
325        assert!(validate_id("plugin.te st", &()).is_err());
326        assert!(validate_id("plugin.te+st", &()).is_err());
327    }
328
329    #[test]
330    fn validate_id_fails_if_segment_ends_with_separator() {
331        assert!(validate_id("plugin.test_", &()).is_err());
332        assert!(validate_id("plugin.test-", &()).is_err());
333        assert!(validate_id("abc_.def", &()).is_err());
334    }
335
336    #[test]
337    fn validate_name_allows_valid_name() {
338        assert!(validate_name("ActionName", &()).is_ok());
339        assert!(validate_name("my_action-1", &()).is_ok());
340    }
341
342    #[test]
343    fn validate_name_fails_if_not_starting_with_letter() {
344        assert!(validate_name("1Action", &()).is_err());
345        assert!(validate_name("_Action", &()).is_err());
346        assert!(validate_name("-Action", &()).is_err());
347    }
348
349    #[test]
350    fn validate_name_fails_on_invalid_characters() {
351        assert!(validate_name("Action!", &()).is_err());
352        assert!(validate_name("Action Name", &()).is_err());
353        assert!(validate_name("Action@", &()).is_err());
354    }
355
356    #[test]
357    fn validate_name_fails_if_ends_with_separator() {
358        assert!(validate_name("Action_", &()).is_err());
359        assert!(validate_name("Action-", &()).is_err());
360    }
361
362    fn color_ok(value: &str) {
363        assert!(
364            validate_color(value, &()).is_ok(),
365            "Expected OK for {value}"
366        );
367    }
368
369    fn color_err(value: &str) {
370        assert!(
371            validate_color(value, &()).is_err(),
372            "Expected ERR for {value}"
373        );
374    }
375
376    #[test]
377    fn test_valid_hex_colors() {
378        color_ok("#fff");
379        color_ok("#ffff");
380        color_ok("#ffffff");
381        color_ok("#ffffffff");
382        color_ok("#ABC"); // uppercase allowed
383        color_ok("  #123456  "); // trimming
384    }
385
386    #[test]
387    fn test_invalid_hex_colors() {
388        color_err("fff"); // missing #
389        color_err("#ff"); // wrong length
390        color_err("#fffff"); // wrong length
391        color_err("#ggg"); // invalid hex digit
392    }
393
394    #[test]
395    fn test_valid_rgb_colors() {
396        color_ok("rgb(0,0,0)");
397        color_ok("rgb(255, 255, 255)");
398        color_ok("rgb(50%, 20%, 100%)");
399    }
400
401    #[test]
402    fn test_invalid_rgb_colors() {
403        color_err("rgb()");
404        color_err("rgb(255,255)"); // not enough parts
405        color_err("rgb(255,255,255,0)"); // too many parts
406        color_err("rgb(300,0,0)"); // out of range
407    }
408
409    #[test]
410    fn test_valid_rgba_colors() {
411        color_ok("rgba(0,0,0,0)");
412        color_ok("rgba(255,255,255,1)");
413        color_ok("rgba(100, 150, 200, 0.5)");
414        color_ok("rgba(10%,20%,30%,0.75)");
415    }
416
417    #[test]
418    fn test_invalid_rgba_colors() {
419        color_err("rgba(255,255,255)"); // missing alpha
420        color_err("rgba(255,255,255,1,0)"); // too many parts
421        color_err("rgba(255,255,255,2)"); // alpha > 1
422        color_err("rgba(255,255,255,-0.1)"); // alpha < 0
423    }
424
425    #[test]
426    fn test_valid_hsl_colors() {
427        color_ok("hsl(0,0%,0%)");
428        color_ok("hsl(360,100%,50%)");
429        color_ok("hsl(180, 50%, 25%)");
430    }
431
432    #[test]
433    fn test_invalid_hsl_colors() {
434        color_err("hsl()");
435        color_err("hsl(361,50%,50%)"); // hue too high
436        color_err("hsl(180,101%,50%)"); // percent > 100
437        color_err("hsl(180,50,50)"); // missing %
438    }
439
440    #[test]
441    fn test_valid_hsla_colors() {
442        color_ok("hsla(0,0%,0%,0)");
443        color_ok("hsla(360,100%,50%,1)");
444        color_ok("hsla(180, 50%, 25%, 0.75)");
445    }
446
447    #[test]
448    fn test_invalid_hsla_colors() {
449        color_err("hsla(180,50%,50%)"); // missing alpha
450        color_err("hsla(180,50%,50%,2)"); // alpha too big
451        color_err("hsla(361,50%,50%,0.5)"); // hue too big
452        color_err("hsla(180,50,50%,0.5)"); // missing % in second arg
453    }
454
455    #[test]
456    fn test_invalid_general_cases() {
457        color_err("blue"); // named colors not supported
458        color_err(""); // empty string
459        color_err("123"); // junk input
460    }
461}