use std::collections::HashSet;
use std::sync::LazyLock;
static RESERVED_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"alias", "history", "help", "version", "search", "query", "index", "export", "import",
"config", "init", "list", "show", "delete", "rename", "clear", "run", "save",
]
.into_iter()
.collect()
});
pub const MIN_ALIAS_LENGTH: usize = 1;
pub const MAX_ALIAS_LENGTH: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AliasNameError {
Empty,
TooLong { length: usize, max: usize },
InvalidStart { char: char },
InvalidChar { char: char, position: usize },
Reserved { word: String },
}
impl std::fmt::Display for AliasNameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "alias name cannot be empty"),
Self::TooLong { length, max } => {
write!(f, "alias name is too long ({length} chars, max {max})")
}
Self::InvalidStart { char } => {
write!(f, "alias name must start with a letter, found '{char}'")
}
Self::InvalidChar { char, position } => {
write!(
f,
"alias name contains invalid character '{char}' at position {position}"
)
}
Self::Reserved { word } => {
write!(
f,
"'{word}' is a reserved word and cannot be used as an alias name"
)
}
}
}
}
impl std::error::Error for AliasNameError {}
pub fn validate_alias_name(name: &str) -> Result<(), AliasNameError> {
if name.is_empty() {
return Err(AliasNameError::Empty);
}
if name.len() > MAX_ALIAS_LENGTH {
return Err(AliasNameError::TooLong {
length: name.len(),
max: MAX_ALIAS_LENGTH,
});
}
let Some(first_char) = name.chars().next() else {
return Err(AliasNameError::Empty);
};
if !first_char.is_ascii_alphabetic() {
return Err(AliasNameError::InvalidStart { char: first_char });
}
for (i, c) in name.chars().enumerate() {
if !is_valid_alias_char(c) {
return Err(AliasNameError::InvalidChar {
char: c,
position: i,
});
}
}
let lower = name.to_lowercase();
if RESERVED_WORDS.contains(lower.as_str()) {
return Err(AliasNameError::Reserved {
word: name.to_string(),
});
}
Ok(())
}
#[inline]
fn is_valid_alias_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_'
}
#[must_use]
pub fn suggest_alias_name(input: &str) -> Option<String> {
if input.is_empty() {
return None;
}
let mut suggestion = String::with_capacity(input.len());
for (i, c) in input.chars().enumerate() {
append_suggestion_char(&mut suggestion, c, i == 0);
}
normalize_suggestion(&mut suggestion, input)?;
Some(suggestion)
}
fn append_suggestion_char(buffer: &mut String, c: char, first: bool) {
if first {
if c.is_ascii_alphabetic() {
buffer.push(c);
} else if c.is_ascii_digit() {
buffer.push('q');
buffer.push(c);
}
return;
}
if is_valid_alias_char(c) {
buffer.push(c);
} else if c == ' ' || c == '.' {
buffer.push('-');
}
}
fn normalize_suggestion(suggestion: &mut String, input: &str) -> Option<()> {
if suggestion.len() > MAX_ALIAS_LENGTH {
suggestion.truncate(MAX_ALIAS_LENGTH);
}
if suggestion.is_empty() || suggestion == input {
return None;
}
let lower = suggestion.to_lowercase();
if RESERVED_WORDS.contains(lower.as_str()) {
suggestion.push_str("-query");
if suggestion.len() > MAX_ALIAS_LENGTH {
return None;
}
}
validate_alias_name(suggestion).ok()?;
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_names() {
let valid = [
"a",
"test",
"my-query",
"find_functions",
"Query1",
"test123",
"a-b-c",
"a_b_c",
"A",
"MyQuery",
];
for name in valid {
assert!(
validate_alias_name(name).is_ok(),
"expected '{name}' to be valid"
);
}
}
#[test]
fn test_empty_name() {
assert_eq!(validate_alias_name(""), Err(AliasNameError::Empty));
}
#[test]
fn test_too_long_name() {
let long_name = "a".repeat(65);
assert_eq!(
validate_alias_name(&long_name),
Err(AliasNameError::TooLong {
length: 65,
max: 64
})
);
let max_name = "a".repeat(64);
assert!(validate_alias_name(&max_name).is_ok());
}
#[test]
fn test_invalid_start() {
let invalid_starts = ["1test", "-test", "_test", "0query", ".foo"];
for name in invalid_starts {
let result = validate_alias_name(name);
assert!(
matches!(result, Err(AliasNameError::InvalidStart { .. })),
"expected InvalidStart for '{name}', got {result:?}"
);
}
}
#[test]
fn test_invalid_chars() {
let invalid = [
("test query", ' ', 4),
("test.query", '.', 4),
("test@query", '@', 4),
("test/query", '/', 4),
("test$query", '$', 4),
];
for (name, expected_char, expected_pos) in invalid {
let result = validate_alias_name(name);
assert_eq!(
result,
Err(AliasNameError::InvalidChar {
char: expected_char,
position: expected_pos
}),
"unexpected result for '{name}'"
);
}
}
#[test]
fn test_reserved_words() {
let reserved = ["help", "HELP", "Help", "alias", "history", "version"];
for name in reserved {
let result = validate_alias_name(name);
assert!(
matches!(result, Err(AliasNameError::Reserved { .. })),
"expected Reserved for '{name}', got {result:?}"
);
}
}
#[test]
fn test_suggest_alias_name() {
assert_eq!(suggest_alias_name("123test"), Some("q123test".to_string()));
assert_eq!(suggest_alias_name("my query"), Some("my-query".to_string()));
assert_eq!(
suggest_alias_name("test.query"),
Some("test-query".to_string())
);
assert_eq!(suggest_alias_name(""), None);
assert_eq!(suggest_alias_name("valid"), None);
}
#[test]
fn test_error_display() {
assert_eq!(
AliasNameError::Empty.to_string(),
"alias name cannot be empty"
);
assert_eq!(
AliasNameError::TooLong {
length: 65,
max: 64
}
.to_string(),
"alias name is too long (65 chars, max 64)"
);
assert_eq!(
AliasNameError::InvalidStart { char: '1' }.to_string(),
"alias name must start with a letter, found '1'"
);
assert_eq!(
AliasNameError::InvalidChar {
char: ' ',
position: 4
}
.to_string(),
"alias name contains invalid character ' ' at position 4"
);
assert_eq!(
AliasNameError::Reserved {
word: "help".to_string()
}
.to_string(),
"'help' is a reserved word and cannot be used as an alias name"
);
}
}