147 lines
3.5 KiB
Rust
147 lines
3.5 KiB
Rust
//! Slug type and validation.
|
||
//!
|
||
//! Syntax (agent-skills compatible):
|
||
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||
//! - 1–64 chars
|
||
//! - lowercase ASCII alphanumerics and `-`
|
||
//! - cannot start or end with `-`
|
||
//! - no consecutive `--`
|
||
|
||
use std::fmt;
|
||
use std::str::FromStr;
|
||
|
||
use serde::{Deserialize, Deserializer, Serialize};
|
||
|
||
use crate::RecordLintError;
|
||
|
||
const MIN_LEN: usize = 1;
|
||
const MAX_LEN: usize = 64;
|
||
|
||
/// Validated slug. Constructible only via [`Slug::parse`].
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||
#[serde(transparent)]
|
||
pub struct Slug(String);
|
||
|
||
impl Slug {
|
||
/// Parse and validate. Returns [`RecordLintError::InvalidSlug`] on rejection.
|
||
pub fn parse(s: impl Into<String>) -> Result<Self, RecordLintError> {
|
||
let s = s.into();
|
||
if is_valid_slug(&s) {
|
||
Ok(Self(s))
|
||
} else {
|
||
Err(RecordLintError::InvalidSlug(s))
|
||
}
|
||
}
|
||
|
||
pub fn as_str(&self) -> &str {
|
||
&self.0
|
||
}
|
||
|
||
pub fn into_string(self) -> String {
|
||
self.0
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for Slug {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(&self.0)
|
||
}
|
||
}
|
||
|
||
impl AsRef<str> for Slug {
|
||
fn as_ref(&self) -> &str {
|
||
&self.0
|
||
}
|
||
}
|
||
|
||
impl FromStr for Slug {
|
||
type Err = RecordLintError;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
Self::parse(s)
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for Slug {
|
||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||
where
|
||
D: Deserializer<'de>,
|
||
{
|
||
let raw = String::deserialize(deserializer)?;
|
||
Self::parse(raw).map_err(serde::de::Error::custom)
|
||
}
|
||
}
|
||
|
||
/// Pure-fn predicate matching the agent-skills slug regex without
|
||
/// pulling in the `regex` crate.
|
||
pub fn is_valid_slug(s: &str) -> bool {
|
||
let bytes = s.as_bytes();
|
||
let len = bytes.len();
|
||
if len < MIN_LEN || len > MAX_LEN {
|
||
return false;
|
||
}
|
||
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||
return false;
|
||
}
|
||
let mut prev_dash = false;
|
||
for &b in bytes {
|
||
if b == b'-' {
|
||
if prev_dash {
|
||
return false;
|
||
}
|
||
prev_dash = true;
|
||
} else if is_alnum_lower(b) {
|
||
prev_dash = false;
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
fn is_alnum_lower(b: u8) -> bool {
|
||
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn accepts_basic_slugs() {
|
||
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||
assert!(Slug::parse(s).is_ok());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_bad_slugs() {
|
||
for s in [
|
||
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||
] {
|
||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||
assert!(Slug::parse(s).is_err());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn enforces_length_bounds() {
|
||
let too_long = "a".repeat(MAX_LEN + 1);
|
||
assert!(!is_valid_slug(&too_long));
|
||
let max = "a".repeat(MAX_LEN);
|
||
assert!(is_valid_slug(&max));
|
||
}
|
||
|
||
#[test]
|
||
fn deserializes_via_serde() {
|
||
let json = "\"valid-slug\"";
|
||
let slug: Slug = serde_json::from_str(json).unwrap();
|
||
assert_eq!(slug.as_str(), "valid-slug");
|
||
|
||
let bad = "\"BAD\"";
|
||
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||
assert!(err.is_err());
|
||
}
|
||
}
|