Skip to main content

sqry_cli/persistence/
validation.rs

1//! Alias name validation.
2//!
3//! Validates alias names according to the specification:
4//! - Must start with a letter (a-z, A-Z)
5//! - Can contain only letters, numbers, dashes, and underscores
6//! - Must be 1-64 characters long
7//! - Cannot be a reserved word
8
9use std::collections::HashSet;
10use std::sync::LazyLock;
11
12/// Reserved words that cannot be used as alias names.
13static RESERVED_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
14    [
15        // Subcommands
16        "alias", "history", "help", "version", "search", "query", "index", "export", "import",
17        // Common CLI terms
18        "config", "init", "list", "show", "delete", "rename", "clear", "run", "save",
19    ]
20    .into_iter()
21    .collect()
22});
23
24/// Minimum alias name length.
25pub const MIN_ALIAS_LENGTH: usize = 1;
26
27/// Maximum alias name length.
28pub const MAX_ALIAS_LENGTH: usize = 64;
29
30/// Error type for alias name validation.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AliasNameError {
33    /// Name is empty.
34    Empty,
35    /// Name is too long.
36    TooLong { length: usize, max: usize },
37    /// Name doesn't start with a letter.
38    InvalidStart { char: char },
39    /// Name contains invalid character.
40    InvalidChar { char: char, position: usize },
41    /// Name is a reserved word.
42    Reserved { word: String },
43}
44
45impl std::fmt::Display for AliasNameError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Empty => write!(f, "alias name cannot be empty"),
49            Self::TooLong { length, max } => {
50                write!(f, "alias name is too long ({length} chars, max {max})")
51            }
52            Self::InvalidStart { char } => {
53                write!(f, "alias name must start with a letter, found '{char}'")
54            }
55            Self::InvalidChar { char, position } => {
56                write!(
57                    f,
58                    "alias name contains invalid character '{char}' at position {position}"
59                )
60            }
61            Self::Reserved { word } => {
62                write!(
63                    f,
64                    "'{word}' is a reserved word and cannot be used as an alias name"
65                )
66            }
67        }
68    }
69}
70
71impl std::error::Error for AliasNameError {}
72
73/// Validate an alias name according to the specification.
74///
75/// # Rules
76///
77/// - Must start with a letter (a-z, A-Z)
78/// - Can contain only letters, numbers, dashes, and underscores
79/// - Must be 1-64 characters long
80/// - Cannot be a reserved word
81///
82/// # Errors
83///
84/// Returns an error if the name violates any of the validation rules.
85pub fn validate_alias_name(name: &str) -> Result<(), AliasNameError> {
86    // Check empty
87    if name.is_empty() {
88        return Err(AliasNameError::Empty);
89    }
90
91    // Check length
92    if name.len() > MAX_ALIAS_LENGTH {
93        return Err(AliasNameError::TooLong {
94            length: name.len(),
95            max: MAX_ALIAS_LENGTH,
96        });
97    }
98
99    // Check first character is a letter
100    let Some(first_char) = name.chars().next() else {
101        return Err(AliasNameError::Empty);
102    };
103    if !first_char.is_ascii_alphabetic() {
104        return Err(AliasNameError::InvalidStart { char: first_char });
105    }
106
107    // Check all characters are valid
108    for (i, c) in name.chars().enumerate() {
109        if !is_valid_alias_char(c) {
110            return Err(AliasNameError::InvalidChar {
111                char: c,
112                position: i,
113            });
114        }
115    }
116
117    // Check reserved words (case-insensitive)
118    let lower = name.to_lowercase();
119    if RESERVED_WORDS.contains(lower.as_str()) {
120        return Err(AliasNameError::Reserved {
121            word: name.to_string(),
122        });
123    }
124
125    Ok(())
126}
127
128/// Check if a character is valid in an alias name.
129///
130/// Valid characters are:
131/// - ASCII letters (a-z, A-Z)
132/// - ASCII digits (0-9)
133/// - Dash (-)
134/// - Underscore (_)
135#[inline]
136fn is_valid_alias_char(c: char) -> bool {
137    c.is_ascii_alphanumeric() || c == '-' || c == '_'
138}
139
140/// Suggest a valid alias name based on an invalid input.
141///
142/// This is used to provide helpful error messages.
143#[must_use]
144pub fn suggest_alias_name(input: &str) -> Option<String> {
145    if input.is_empty() {
146        return None;
147    }
148
149    let mut suggestion = String::with_capacity(input.len());
150
151    for (i, c) in input.chars().enumerate() {
152        append_suggestion_char(&mut suggestion, c, i == 0);
153    }
154
155    normalize_suggestion(&mut suggestion, input)?;
156    Some(suggestion)
157}
158
159fn append_suggestion_char(buffer: &mut String, c: char, first: bool) {
160    if first {
161        if c.is_ascii_alphabetic() {
162            buffer.push(c);
163        } else if c.is_ascii_digit() {
164            // Prefix with 'q' for numbers
165            buffer.push('q');
166            buffer.push(c);
167        }
168        return;
169    }
170
171    if is_valid_alias_char(c) {
172        buffer.push(c);
173    } else if c == ' ' || c == '.' {
174        // Replace spaces and dots with dashes
175        buffer.push('-');
176    }
177}
178
179fn normalize_suggestion(suggestion: &mut String, input: &str) -> Option<()> {
180    // Truncate if too long
181    if suggestion.len() > MAX_ALIAS_LENGTH {
182        suggestion.truncate(MAX_ALIAS_LENGTH);
183    }
184
185    // Check if suggestion is valid and different from input
186    if suggestion.is_empty() || suggestion == input {
187        return None;
188    }
189
190    // Check if it's a reserved word
191    let lower = suggestion.to_lowercase();
192    if RESERVED_WORDS.contains(lower.as_str()) {
193        suggestion.push_str("-query");
194        if suggestion.len() > MAX_ALIAS_LENGTH {
195            return None;
196        }
197    }
198
199    // Final validation
200    validate_alias_name(suggestion).ok()?;
201    Some(())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_valid_names() {
210        let valid = [
211            "a",
212            "test",
213            "my-query",
214            "find_functions",
215            "Query1",
216            "test123",
217            "a-b-c",
218            "a_b_c",
219            "A",
220            "MyQuery",
221        ];
222
223        for name in valid {
224            assert!(
225                validate_alias_name(name).is_ok(),
226                "expected '{name}' to be valid"
227            );
228        }
229    }
230
231    #[test]
232    fn test_empty_name() {
233        assert_eq!(validate_alias_name(""), Err(AliasNameError::Empty));
234    }
235
236    #[test]
237    fn test_too_long_name() {
238        let long_name = "a".repeat(65);
239        assert_eq!(
240            validate_alias_name(&long_name),
241            Err(AliasNameError::TooLong {
242                length: 65,
243                max: 64
244            })
245        );
246
247        // Exactly 64 should be fine
248        let max_name = "a".repeat(64);
249        assert!(validate_alias_name(&max_name).is_ok());
250    }
251
252    #[test]
253    fn test_invalid_start() {
254        let invalid_starts = ["1test", "-test", "_test", "0query", ".foo"];
255
256        for name in invalid_starts {
257            let result = validate_alias_name(name);
258            assert!(
259                matches!(result, Err(AliasNameError::InvalidStart { .. })),
260                "expected InvalidStart for '{name}', got {result:?}"
261            );
262        }
263    }
264
265    #[test]
266    fn test_invalid_chars() {
267        let invalid = [
268            ("test query", ' ', 4),
269            ("test.query", '.', 4),
270            ("test@query", '@', 4),
271            ("test/query", '/', 4),
272            ("test$query", '$', 4),
273        ];
274
275        for (name, expected_char, expected_pos) in invalid {
276            let result = validate_alias_name(name);
277            assert_eq!(
278                result,
279                Err(AliasNameError::InvalidChar {
280                    char: expected_char,
281                    position: expected_pos
282                }),
283                "unexpected result for '{name}'"
284            );
285        }
286    }
287
288    #[test]
289    fn test_reserved_words() {
290        let reserved = ["help", "HELP", "Help", "alias", "history", "version"];
291
292        for name in reserved {
293            let result = validate_alias_name(name);
294            assert!(
295                matches!(result, Err(AliasNameError::Reserved { .. })),
296                "expected Reserved for '{name}', got {result:?}"
297            );
298        }
299    }
300
301    #[test]
302    fn test_suggest_alias_name() {
303        // Numbers at start get prefixed
304        assert_eq!(suggest_alias_name("123test"), Some("q123test".to_string()));
305
306        // Spaces become dashes
307        assert_eq!(suggest_alias_name("my query"), Some("my-query".to_string()));
308
309        // Dots become dashes
310        assert_eq!(
311            suggest_alias_name("test.query"),
312            Some("test-query".to_string())
313        );
314
315        // Empty returns None
316        assert_eq!(suggest_alias_name(""), None);
317
318        // Already valid returns None
319        assert_eq!(suggest_alias_name("valid"), None);
320    }
321
322    #[test]
323    fn test_error_display() {
324        assert_eq!(
325            AliasNameError::Empty.to_string(),
326            "alias name cannot be empty"
327        );
328
329        assert_eq!(
330            AliasNameError::TooLong {
331                length: 65,
332                max: 64
333            }
334            .to_string(),
335            "alias name is too long (65 chars, max 64)"
336        );
337
338        assert_eq!(
339            AliasNameError::InvalidStart { char: '1' }.to_string(),
340            "alias name must start with a letter, found '1'"
341        );
342
343        assert_eq!(
344            AliasNameError::InvalidChar {
345                char: ' ',
346                position: 4
347            }
348            .to_string(),
349            "alias name contains invalid character ' ' at position 4"
350        );
351
352        assert_eq!(
353            AliasNameError::Reserved {
354                word: "help".to_string()
355            }
356            .to_string(),
357            "'help' is a reserved word and cannot be used as an alias name"
358        );
359    }
360}