use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ModuleName(String);
impl ModuleName {
const MAX_LEN: usize = 32;
pub fn new(raw: impl Into<String>) -> Result<Self> {
let raw = raw.into();
let invalid = |reason: &'static str| Error::InvalidName {
kind: "module name",
value: raw.clone(),
reason,
};
let mut chars = raw.chars();
let Some(first) = chars.next() else {
return Err(invalid("must not be empty"));
};
if raw.len() > Self::MAX_LEN {
return Err(invalid("must be at most 32 bytes"));
}
if !first.is_ascii_lowercase() {
return Err(invalid("must start with a lowercase ASCII letter"));
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
return Err(invalid(
"must contain only lowercase ASCII letters, digits and underscores",
));
}
Ok(Self(raw))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl core::fmt::Display for ModuleName {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ModuleName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::ModuleName;
#[test]
fn accepts_the_names_the_standard_library_uses() {
for name in [
"fs", "json", "path", "proc", "re", "hash", "time", "rand", "glob", "log",
] {
assert!(ModuleName::new(name).is_ok(), "{name} should be valid");
}
}
#[test]
fn accepts_digits_and_underscores_after_the_first_character() {
assert_eq!(ModuleName::new("base64_v2").unwrap().as_str(), "base64_v2");
}
#[test]
fn rejects_empty_names() {
assert!(ModuleName::new("").is_err());
}
#[test]
fn rejects_names_that_do_not_start_with_a_lowercase_letter() {
for name in ["1fs", "_fs", "Fs"] {
assert!(ModuleName::new(name).is_err(), "{name} should be rejected");
}
}
#[test]
fn rejects_characters_that_break_lua_dot_syntax() {
for name in ["fs-utils", "fs.utils", "fs utils", "fsé"] {
assert!(ModuleName::new(name).is_err(), "{name} should be rejected");
}
}
#[test]
fn rejects_names_longer_than_the_limit() {
assert!(ModuleName::new("a".repeat(32)).is_ok());
assert!(ModuleName::new("a".repeat(33)).is_err());
}
#[test]
fn error_message_names_the_offending_value() {
let err = ModuleName::new("Fs").unwrap_err();
assert!(err.to_string().contains("`Fs`"), "{err}");
}
}