Skip to main content

uptrakit_surfaces/
validation.rs

1//! Shared icon-name validation used by the wire layer and by
2//! `InteractionDescriptor::validate_for_provider`.
3
4use thiserror::Error;
5
6/// Maximum length (in bytes) of a Lucide-canonical icon name.
7pub const MAX_ICON_NAME_LEN: usize = 64;
8
9/// Errors reported by [`validate_icon_name`].
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Eq, Error)]
12pub enum IconNameError {
13    #[error("icon name must not be empty")]
14    Empty,
15    #[error("icon name exceeds {MAX_ICON_NAME_LEN} characters")]
16    TooLong,
17    #[error("icon name must match lucide kebab-case (lowercase letters, digits, hyphens)")]
18    InvalidFormat,
19}
20
21/// Validates a candidate Lucide icon name.
22///
23/// Accepts ASCII kebab-case identifiers matching the regex
24/// `^[a-z][a-z0-9-]*[a-z0-9]$` (≥ 2 chars, no leading/trailing dash).
25///
26/// # Errors
27///
28/// Returns [`IconNameError::Empty`] for empty strings,
29/// [`IconNameError::TooLong`] for inputs longer than [`MAX_ICON_NAME_LEN`],
30/// and [`IconNameError::InvalidFormat`] for any other shape mismatch.
31pub fn validate_icon_name(name: &str) -> Result<(), IconNameError> {
32    if name.is_empty() {
33        return Err(IconNameError::Empty);
34    }
35    if name.len() > MAX_ICON_NAME_LEN {
36        return Err(IconNameError::TooLong);
37    }
38    // Slice destructuring satisfies clippy::unwrap_used = deny and
39    // clippy::indexing_slicing = deny without an #[expect(...)] escape:
40    // the empty- and single-element cases fall through to the `else`.
41    let bytes = name.as_bytes();
42    let [first, middle @ .., last] = bytes else {
43        return Err(IconNameError::InvalidFormat);
44    };
45    if !first.is_ascii_lowercase() {
46        return Err(IconNameError::InvalidFormat);
47    }
48    if !(last.is_ascii_lowercase() || last.is_ascii_digit()) {
49        return Err(IconNameError::InvalidFormat);
50    }
51    for &byte in middle {
52        let allowed = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-';
53        if !allowed {
54            return Err(IconNameError::InvalidFormat);
55        }
56    }
57    Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn validate_icon_name_accepts_kebab_case() {
66        validate_icon_name("trash-2").unwrap();
67        validate_icon_name("refresh-cw").unwrap();
68        validate_icon_name("server-cog").unwrap();
69        validate_icon_name("plug-zap").unwrap();
70        validate_icon_name("box").unwrap();
71        validate_icon_name("ab").unwrap();
72    }
73
74    #[test]
75    fn validate_icon_name_rejects_empty() {
76        assert_eq!(validate_icon_name(""), Err(IconNameError::Empty));
77    }
78
79    #[test]
80    fn validate_icon_name_rejects_oversized() {
81        let long = "a".repeat(MAX_ICON_NAME_LEN + 1);
82        assert_eq!(validate_icon_name(&long), Err(IconNameError::TooLong));
83    }
84
85    #[test]
86    fn validate_icon_name_rejects_pascal_case() {
87        assert_eq!(
88            validate_icon_name("Trash2"),
89            Err(IconNameError::InvalidFormat)
90        );
91        assert_eq!(
92            validate_icon_name("Package"),
93            Err(IconNameError::InvalidFormat)
94        );
95    }
96
97    #[test]
98    fn validate_icon_name_rejects_underscore() {
99        assert_eq!(
100            validate_icon_name("trash_2"),
101            Err(IconNameError::InvalidFormat)
102        );
103    }
104
105    #[test]
106    fn validate_icon_name_rejects_leading_or_trailing_dash() {
107        assert_eq!(
108            validate_icon_name("-trash"),
109            Err(IconNameError::InvalidFormat)
110        );
111        assert_eq!(
112            validate_icon_name("trash-"),
113            Err(IconNameError::InvalidFormat)
114        );
115    }
116
117    #[test]
118    fn validate_icon_name_rejects_single_char() {
119        assert_eq!(validate_icon_name("x"), Err(IconNameError::InvalidFormat));
120    }
121
122    #[test]
123    fn validate_icon_name_rejects_uppercase_or_punctuation_in_middle() {
124        assert_eq!(
125            validate_icon_name("traSh-2"),
126            Err(IconNameError::InvalidFormat)
127        );
128        assert_eq!(
129            validate_icon_name("trash 2"),
130            Err(IconNameError::InvalidFormat)
131        );
132    }
133}