1use std::collections::HashSet;
10use std::sync::LazyLock;
11
12static RESERVED_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
14 [
15 "alias", "history", "help", "version", "search", "query", "index", "export", "import",
17 "config", "init", "list", "show", "delete", "rename", "clear", "run", "save",
19 ]
20 .into_iter()
21 .collect()
22});
23
24pub const MIN_ALIAS_LENGTH: usize = 1;
26
27pub const MAX_ALIAS_LENGTH: usize = 64;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AliasNameError {
33 Empty,
35 TooLong { length: usize, max: usize },
37 InvalidStart { char: char },
39 InvalidChar { char: char, position: usize },
41 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
73pub fn validate_alias_name(name: &str) -> Result<(), AliasNameError> {
86 if name.is_empty() {
88 return Err(AliasNameError::Empty);
89 }
90
91 if name.len() > MAX_ALIAS_LENGTH {
93 return Err(AliasNameError::TooLong {
94 length: name.len(),
95 max: MAX_ALIAS_LENGTH,
96 });
97 }
98
99 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 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 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#[inline]
136fn is_valid_alias_char(c: char) -> bool {
137 c.is_ascii_alphanumeric() || c == '-' || c == '_'
138}
139
140#[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 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 buffer.push('-');
176 }
177}
178
179fn normalize_suggestion(suggestion: &mut String, input: &str) -> Option<()> {
180 if suggestion.len() > MAX_ALIAS_LENGTH {
182 suggestion.truncate(MAX_ALIAS_LENGTH);
183 }
184
185 if suggestion.is_empty() || suggestion == input {
187 return None;
188 }
189
190 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 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 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 assert_eq!(suggest_alias_name("123test"), Some("q123test".to_string()));
305
306 assert_eq!(suggest_alias_name("my query"), Some("my-query".to_string()));
308
309 assert_eq!(
311 suggest_alias_name("test.query"),
312 Some("test-query".to_string())
313 );
314
315 assert_eq!(suggest_alias_name(""), None);
317
318 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}