1use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct SafeName(String);
14
15impl SafeName {
16 pub const MAX_LENGTH: usize = 64;
19
20 #[must_use]
24 pub fn from_display(text: &str) -> Option<Self> {
25 let mut folded = String::new();
26 for character in text.chars() {
27 if character.is_ascii_alphanumeric() {
28 folded.push(character.to_ascii_lowercase());
29 } else if let Some(letter) = fold(character) {
30 folded.push_str(letter);
31 } else if !folded.is_empty() && !folded.ends_with('-') {
32 folded.push('-');
33 }
34 }
35 folded.truncate(Self::MAX_LENGTH);
36 while folded.ends_with('-') {
37 folded.pop();
38 }
39 (!folded.is_empty()).then_some(Self(folded))
40 }
41
42 #[must_use]
46 pub fn parse(text: &str) -> Option<Self> {
47 let safe = !text.is_empty()
48 && text.len() <= Self::MAX_LENGTH
49 && text.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
50 && text.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
51 && text.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
52 && !text.contains("--");
53 safe.then(|| Self(text.to_owned()))
54 }
55
56 #[must_use]
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63impl fmt::Display for SafeName {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.write_str(&self.0)
66 }
67}
68
69fn fold(character: char) -> Option<&'static str> {
72 match character {
73 'ç' | 'Ç' => Some("c"),
74 'ğ' | 'Ğ' => Some("g"),
75 'ı' | 'İ' => Some("i"),
76 'î' | 'Î' => Some("i"),
77 'ö' | 'Ö' => Some("o"),
78 'ş' | 'Ş' => Some("s"),
79 'ü' | 'Ü' => Some("u"),
80 'â' | 'Â' => Some("a"),
81 'û' | 'Û' => Some("u"),
82 _ => None,
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn display_text_becomes_a_lowercase_ascii_name() {
92 assert_eq!(SafeName::from_display("Claude Sub").expect("has letters").as_str(), "claude-sub");
93 assert_eq!(SafeName::from_display(" My Workspace ").expect("has letters").as_str(), "my-workspace");
94 assert_eq!(SafeName::from_display("v2.0/build").expect("has letters").as_str(), "v2-0-build");
95 }
96
97 #[test]
98 fn turkish_letters_fold_the_same_way_in_every_locale() {
99 assert_eq!(SafeName::from_display("İstanbul Projesi").expect("has letters").as_str(), "istanbul-projesi");
101 assert_eq!(SafeName::from_display("IŞIK").expect("has letters").as_str(), "isik");
102 assert_eq!(SafeName::from_display("ışık").expect("has letters").as_str(), "isik");
103 assert_eq!(SafeName::from_display("Iı İi").expect("has letters").as_str(), "ii-ii");
104 assert_eq!(SafeName::from_display("Öğün Çöp Şüphe").expect("has letters").as_str(), "ogun-cop-suphe");
105 }
106
107 #[test]
108 fn a_name_never_starts_or_ends_with_a_separator() {
109 assert_eq!(SafeName::from_display("--x--").expect("has letters").as_str(), "x");
110 assert_eq!(SafeName::from_display(".hidden.").expect("has letters").as_str(), "hidden");
111 assert_eq!(SafeName::from_display("_ _ a _ _").expect("has letters").as_str(), "a");
112 }
113
114 #[test]
115 fn text_without_a_usable_character_has_no_name() {
116 assert_eq!(SafeName::from_display(""), None);
117 assert_eq!(SafeName::from_display(" "), None);
118 assert_eq!(SafeName::from_display("///"), None);
119 assert_eq!(SafeName::from_display("日本語"), None);
120 }
121
122 #[test]
123 fn long_text_is_cut_to_the_limit_and_still_ends_in_a_letter() {
124 let name = SafeName::from_display(&"ab ".repeat(50)).expect("has letters");
125 assert!(name.as_str().len() <= SafeName::MAX_LENGTH, "{}", name.as_str());
126 assert!(name.as_str().ends_with(|c: char| c.is_ascii_alphanumeric()), "{}", name.as_str());
127 }
128
129 #[test]
130 fn parsing_accepts_only_an_already_safe_name() {
131 assert_eq!(SafeName::parse("claude-sub").expect("safe").as_str(), "claude-sub");
132 assert_eq!(SafeName::parse("a1"), SafeName::from_display("a1"));
133 for text in ["Claude", "claude sub", "-claude", "claude-", "cl--aude", "claude_sub", "", &"a".repeat(65)] {
134 assert_eq!(SafeName::parse(text), None, "{text:?}");
135 }
136 }
137
138 #[test]
139 fn a_name_displays_as_its_text() {
140 let name = SafeName::from_display("Claude Sub").expect("has letters");
141 assert_eq!(name.to_string(), "claude-sub");
142 }
143}