use std::{
collections::HashSet,
sync::{LazyLock, OnceLock},
};
static DEFAULT_ACRONYMS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"s3", "ec2", "ipv4", "ipv6", "oauth1", "oauth2", "sha1", "sha256", "sha512", "md5",
"base64", "utf8", "p256", "p384",
]
.into_iter()
.collect()
});
static RUNTIME_ACRONYMS: OnceLock<HashSet<String>> = OnceLock::new();
pub fn set_runtime_acronyms(extra: &[String]) {
let mut set: HashSet<String> = DEFAULT_ACRONYMS.iter().map(|s| (*s).to_string()).collect();
for term in extra {
let term = term.trim().to_ascii_lowercase();
if !term.is_empty() {
set.insert(term);
}
}
let _ = RUNTIME_ACRONYMS.set(set);
}
fn is_registered_acronym(candidate: &str) -> bool {
match RUNTIME_ACRONYMS.get() {
Some(set) => set.contains(candidate),
None => DEFAULT_ACRONYMS.contains(candidate),
}
}
fn acronym_spans_digit(chars: &[char], word_start: usize, digit_start: usize) -> bool {
let mut end = digit_start;
while end < chars.len() && chars[end].is_ascii_digit() {
end += 1;
}
let candidate: String = chars[word_start..end].iter().collect::<String>().to_ascii_lowercase();
is_registered_acronym(&candidate)
}
#[must_use]
pub fn to_snake_case(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mut result = String::with_capacity(name.len() + 5);
let mut prev_was_upper = false;
let mut prev_was_lower = false;
let mut prev_was_digit = false;
let mut word_start = 0usize;
for (i, &c) in chars.iter().enumerate() {
if c.is_uppercase() {
let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
if i > 0 && (prev_was_lower || prev_was_digit || (prev_was_upper && next_is_lower)) {
result.push('_');
}
if !prev_was_upper || next_is_lower {
word_start = i;
}
result.push(c.to_ascii_lowercase());
prev_was_upper = true;
prev_was_lower = false;
prev_was_digit = false;
} else if c.is_ascii_digit() {
if prev_was_lower && !acronym_spans_digit(&chars, word_start, i) {
result.push('_');
}
result.push(c);
prev_was_upper = false;
prev_was_lower = false;
prev_was_digit = true;
} else {
if c.is_lowercase() {
if !prev_was_lower && !prev_was_upper {
word_start = i;
}
prev_was_lower = true;
} else {
prev_was_lower = false;
}
result.push(c);
prev_was_upper = false;
prev_was_digit = false;
}
}
result
}
pub fn validate_graphql_identifier(field: &str, context: &str) -> fraiseql_error::Result<()> {
let mut chars = field.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if first_ok && rest_ok {
Ok(())
} else {
Err(fraiseql_error::FraiseQLError::Validation {
message: format!(
"{context} field name '{field}' contains invalid characters; \
only [_A-Za-z][_0-9A-Za-z]* is allowed"
),
path: None,
})
}
}
#[cfg(test)]
mod tests;
#[must_use]
pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
const MAX_DISTANCE: usize = 2;
const MAX_SUGGESTIONS: usize = 3;
let mut ranked: Vec<(usize, &str)> = haystack
.iter()
.filter_map(|&candidate| {
let d = levenshtein(needle, candidate);
if d <= MAX_DISTANCE {
Some((d, candidate))
} else {
None
}
})
.collect();
ranked.sort_unstable_by_key(|&(d, _)| d);
ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
}
#[must_use]
pub fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let m = a.len();
let n = b.len();
if m.abs_diff(n) > 2 {
return m.abs_diff(n);
}
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr = vec![0usize; n + 1];
for i in 1..=m {
curr[0] = i;
for j in 1..=n {
curr[j] = if a[i - 1] == b[j - 1] {
prev[j - 1]
} else {
1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
};
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
pub fn vector_storage_column(field_name: &str) -> fraiseql_error::Result<String> {
let column = to_snake_case(field_name);
let valid = column.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c == '_')
&& column.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if valid {
Ok(column)
} else {
Err(fraiseql_error::FraiseQLError::validation(format!(
"vector field name '{field_name}' does not resolve to a bare SQL identifier"
)))
}
}