1use std::fmt;
10
11use qframe::t;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum WorkspaceIdError {
16 Empty,
18 Illegal {
20 position: usize,
22 character: char,
24 },
25 TooLong {
27 length: usize,
29 },
30 Reserved,
32}
33
34impl WorkspaceIdError {
35 #[must_use]
39 pub fn said(self) -> String {
40 match self {
41 Self::Empty => t!("workspaces.name-empty"),
42 Self::Illegal { position, character } => t!(
43 "workspaces.name-illegal",
44 character = character.to_string(),
45 position = i64::try_from(position.saturating_add(1)).unwrap_or(i64::MAX),
46 ),
47 Self::TooLong { length } => t!(
48 "workspaces.name-too-long",
49 length = i64::try_from(length).unwrap_or(i64::MAX),
50 max = i64::try_from(WorkspaceId::MAX_LEN).unwrap_or(i64::MAX),
51 ),
52 Self::Reserved => t!("workspaces.name-reserved"),
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct WorkspaceId(String);
65
66const RESERVED: [&str; 22] = [
69 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2",
70 "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
71];
72
73const ESCAPE: &str = "-qcode";
76
77impl WorkspaceId {
78 pub const MAX_LEN: usize = 64;
84
85 pub fn from_display_name(name: &str) -> Result<Self, WorkspaceIdError> {
95 let mut out = String::with_capacity(name.len());
96 for character in name.chars() {
97 match fold(character) {
98 Some(folded) => out.push(folded),
99 None if out.ends_with('-') => {}
100 None => out.push('-'),
101 }
102 }
103 out.truncate(out.char_indices().nth(Self::MAX_LEN).map_or(out.len(), |(at, _)| at));
104 let trimmed = out.trim_matches('-');
105 if trimmed.is_empty() {
106 return Err(WorkspaceIdError::Empty);
107 }
108 let mut id = trimmed.to_owned();
109 if RESERVED.contains(&id.as_str()) {
110 id.push_str(ESCAPE);
111 }
112 Ok(Self(id))
113 }
114
115 pub fn parse(text: &str) -> Result<Self, WorkspaceIdError> {
122 let length = text.chars().count();
123 if length == 0 {
124 return Err(WorkspaceIdError::Empty);
125 }
126 if length > Self::MAX_LEN {
127 return Err(WorkspaceIdError::TooLong { length });
128 }
129 for (position, character) in text.chars().enumerate() {
130 let edge = position == 0 || position + 1 == length;
131 let legal = character.is_ascii_lowercase() || character.is_ascii_digit() || (character == '-' && !edge);
132 if !legal {
133 return Err(WorkspaceIdError::Illegal { position, character });
134 }
135 }
136 if RESERVED.contains(&text) {
137 return Err(WorkspaceIdError::Reserved);
138 }
139 Ok(Self(text.to_owned()))
140 }
141
142 #[must_use]
144 pub fn as_str(&self) -> &str {
145 &self.0
146 }
147
148 #[must_use]
153 pub fn clashes_with(&self, name: &str) -> bool {
154 self.0.eq_ignore_ascii_case(name)
155 }
156}
157
158impl fmt::Display for WorkspaceId {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str(&self.0)
161 }
162}
163
164fn fold(character: char) -> Option<char> {
170 Some(match character {
171 'i' | 'ı' | 'İ' | 'I' => 'i',
172 'ç' | 'Ç' => 'c',
173 'ğ' | 'Ğ' => 'g',
174 'ö' | 'Ö' => 'o',
175 'ş' | 'Ş' => 's',
176 'ü' | 'Ü' => 'u',
177 letter if letter.is_ascii_alphanumeric() => letter.to_ascii_lowercase(),
178 _ => return None,
179 })
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn id(name: &str) -> String {
187 WorkspaceId::from_display_name(name).expect("a usable name").as_str().to_owned()
188 }
189
190 #[test]
191 fn turkish_letters_fold_the_same_way_in_every_locale() {
192 assert_eq!(id("İstanbul Şehri"), "istanbul-sehri");
193 assert_eq!(id("Iğdır ılık"), "igdir-ilik");
194 assert_eq!(id("ÇÖĞÜŞİI"), "cogusii");
195 assert_eq!(id("çöğüşıi"), "cogusii");
196 assert_eq!(id("İ"), "i");
198 }
199
200 #[test]
201 fn spaces_dots_and_case_become_one_separator() {
202 assert_eq!(id(" My.Workspace v2 "), "my-workspace-v2");
203 assert_eq!(id("a---b"), "a-b");
204 assert_eq!(id("snake_case"), "snake-case");
205 }
206
207 #[test]
208 fn trailing_dots_and_spaces_never_survive() {
209 assert_eq!(id("Proje."), "proje");
211 assert_eq!(id("Proje .. "), "proje");
212 assert_eq!(id("...Proje..."), "proje");
213 }
214
215 #[test]
216 fn windows_device_names_are_moved_out_of_the_way() {
217 for reserved in ["con", "CON", "Prn", "aux", "NUL", "com1", "COM9", "lpt1", "LPT9"] {
218 let made = id(reserved);
219 assert!(made.ends_with("-qcode"), "{reserved} became {made}");
220 assert!(WorkspaceId::parse(&made).is_ok(), "{made} is a legal id");
221 }
222 for fine in ["con3", "com", "com10", "console", "lpt"] {
223 assert!(!id(fine).ends_with("-qcode"), "{fine} is not a device name");
224 }
225 }
226
227 #[test]
228 fn a_name_with_nothing_usable_in_it_is_an_error() {
229 for empty in ["", " ", "...", "///", "。。"] {
230 assert_eq!(WorkspaceId::from_display_name(empty), Err(WorkspaceIdError::Empty), "{empty:?}");
231 }
232 }
233
234 #[test]
235 fn a_long_name_is_cut_without_leaving_a_dangling_separator() {
236 let made = id(&format!("{} {}", "a".repeat(WorkspaceId::MAX_LEN - 1), "bbbb"));
237 assert_eq!(made.len(), WorkspaceId::MAX_LEN - 1, "cut at the separator, which is then trimmed");
238 let long = id(&"ş".repeat(WorkspaceId::MAX_LEN * 2));
239 assert_eq!(long.len(), WorkspaceId::MAX_LEN);
240 assert!(WorkspaceId::parse(&long).is_ok());
241 }
242
243 #[test]
244 fn parsing_accepts_only_what_generating_produces() {
245 assert_eq!(WorkspaceId::parse("my-workspace").map(|id| id.as_str().to_owned()), Ok("my-workspace".to_owned()));
246 assert_eq!(WorkspaceId::parse(""), Err(WorkspaceIdError::Empty));
247 assert_eq!(WorkspaceId::parse("My-Workspace"), Err(WorkspaceIdError::Illegal { position: 0, character: 'M' }));
248 assert_eq!(WorkspaceId::parse("a b"), Err(WorkspaceIdError::Illegal { position: 1, character: ' ' }));
249 assert_eq!(WorkspaceId::parse("-a"), Err(WorkspaceIdError::Illegal { position: 0, character: '-' }));
250 assert_eq!(WorkspaceId::parse("a-"), Err(WorkspaceIdError::Illegal { position: 1, character: '-' }));
251 assert_eq!(WorkspaceId::parse("con"), Err(WorkspaceIdError::Reserved));
252 let long = "a".repeat(WorkspaceId::MAX_LEN + 1);
253 assert_eq!(WorkspaceId::parse(&long), Err(WorkspaceIdError::TooLong { length: WorkspaceId::MAX_LEN + 1 }));
254 }
255
256 #[test]
257 fn a_clash_ignores_case_because_some_file_systems_do() {
258 let made = WorkspaceId::from_display_name("Belgeler").expect("usable");
259 assert!(made.clashes_with("BELGELER"));
260 assert!(made.clashes_with("Belgeler"));
261 assert!(!made.clashes_with("belgelerim"));
262 assert!(!WorkspaceId::from_display_name("ilik").expect("usable").clashes_with("ILİK"));
264 }
265}