use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectSlug(String);
impl ProjectSlug {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
if s.is_empty() || s.len() > 64 || s == "." || s == ".." {
return None;
}
if s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
{
Some(Self(s.to_owned()))
} else {
None
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ProjectSlug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub slug: ProjectSlug,
pub name: String,
#[serde(default)]
pub description: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_accepts_safe_chars() {
assert!(ProjectSlug::parse("demo").is_some());
assert!(ProjectSlug::parse("PROJ-123").is_some());
assert!(ProjectSlug::parse("my.project_v2").is_some());
}
#[test]
fn slug_rejects_path_traversal() {
assert!(ProjectSlug::parse("..").is_none());
assert!(ProjectSlug::parse("a/b").is_none());
assert!(ProjectSlug::parse("a\0b").is_none());
assert!(ProjectSlug::parse("").is_none());
}
}