1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(transparent)]
11pub struct ProjectSlug(String);
12
13impl ProjectSlug {
14 #[must_use]
17 pub fn parse(s: &str) -> Option<Self> {
18 if s.is_empty() || s.len() > 64 || s == "." || s == ".." {
19 return None;
20 }
21 if s.bytes()
22 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
23 {
24 Some(Self(s.to_owned()))
25 } else {
26 None
27 }
28 }
29
30 #[must_use]
32 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35}
36
37impl std::fmt::Display for ProjectSlug {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(&self.0)
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Project {
46 pub slug: ProjectSlug,
48 pub name: String,
50 #[serde(default)]
52 pub description: String,
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 #[test]
60 fn slug_accepts_safe_chars() {
61 assert!(ProjectSlug::parse("demo").is_some());
62 assert!(ProjectSlug::parse("PROJ-123").is_some());
63 assert!(ProjectSlug::parse("my.project_v2").is_some());
64 }
65
66 #[test]
67 fn slug_rejects_path_traversal() {
68 assert!(ProjectSlug::parse("..").is_none());
69 assert!(ProjectSlug::parse("a/b").is_none());
70 assert!(ProjectSlug::parse("a\0b").is_none());
71 assert!(ProjectSlug::parse("").is_none());
72 }
73}