Skip to main content

qcode/store/
identity.rs

1//! The identifier a workspace is known by on disk, in container names and in the config.
2//!
3//! A display name is whatever the user typed; an identifier is what every file system, every
4//! container engine and a case-insensitive comparison all accept. Turning one into the other
5//! must not depend on the machine's locale, which is why the folding here is written out by
6//! hand: in a Turkish locale `I` and `i` are not each other's pair, and `İ` lowercases to two
7//! characters in Unicode.
8
9use std::fmt;
10
11use qframe::t;
12
13/// Why a display name or a stored identifier cannot be used.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum WorkspaceIdError {
16    /// Nothing in the name can be part of an identifier.
17    Empty,
18    /// A character an identifier may not contain, and where it is.
19    Illegal {
20        /// The character's position in characters, starting at 0.
21        position: usize,
22        /// The character itself.
23        character: char,
24    },
25    /// Longer than [`WorkspaceId::MAX_LEN`].
26    TooLong {
27        /// The length that was found, in characters.
28        length: usize,
29    },
30    /// A name Windows reserves for a device and never gives to a file.
31    Reserved,
32}
33
34impl WorkspaceIdError {
35    /// The sentence for this reason a name cannot become an identifier, in the active language.
36    /// The position is counted from one, because that is how a person counts the letters of a
37    /// name.
38    #[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/// The name a workspace has on disk: lower-case ASCII letters and digits, separated by `-`.
58///
59/// Generated from a display name with [`from_display_name`](Self::from_display_name) and read
60/// back from disk with [`parse`](Self::parse). Every identifier is also a legal folder name, a
61/// legal container name and a legal volume name, so the same string names the workspace
62/// everywhere.
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct WorkspaceId(String);
65
66/// The names Windows keeps for devices. A file cannot carry one of these, with or without an
67/// extension, whatever its case.
68const 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
73/// What is appended to a reserved name to make it usable. It cannot itself be reserved, and it
74/// is not a number, so it never looks like the automatic `-2` this layer refuses to invent.
75const ESCAPE: &str = "-qcode";
76
77impl WorkspaceId {
78    /// The longest an identifier may be, in characters.
79    ///
80    /// The identifier is part of `qcode-home-<workspace>-<profile>`, of a folder name below the
81    /// store and of a volume name; 64 leaves room for all of them inside the 255 characters
82    /// the strictest of them allows.
83    pub const MAX_LEN: usize = 64;
84
85    /// Builds the identifier of a workspace the user named `name`.
86    ///
87    /// Letters fold to ASCII, everything else becomes a single `-`, and leading and trailing
88    /// separators — which is where a trailing dot or space ends up — are removed. A name a
89    /// device is called by is moved out of the way. The result never depends on the locale.
90    ///
91    /// # Errors
92    ///
93    /// [`WorkspaceIdError::Empty`] when nothing in `name` can be part of an identifier.
94    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    /// Reads an identifier that is already stored: a folder name, a key in the config or the
116    /// `id` of a `workspace.qcode`.
117    ///
118    /// # Errors
119    ///
120    /// Says which rule the text breaks, so the caller can point at it.
121    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    /// The identifier as it is written on disk.
143    #[must_use]
144    pub fn as_str(&self) -> &str {
145        &self.0
146    }
147
148    /// Whether a file system that ignores case would see `name` as this workspace's folder.
149    ///
150    /// Folding is ASCII only on purpose: an identifier holds nothing else, and a locale-aware
151    /// fold would answer differently in a Turkish locale.
152    #[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
164/// The ASCII letter or digit an identifier keeps `character` as, or `None` when it is a
165/// separator.
166///
167/// The Turkish letters are listed by hand: `İ` has no single-character lower case in Unicode,
168/// and `I` and `ı` must land on the same letter here however the machine is set up.
169fn 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        // The dotted capital I lowercases to two characters in Unicode; it must still be one.
197        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        // Windows silently strips them from a folder name, which would break the id on disk.
210        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        // Folding is ASCII only: a Turkish locale must not turn `I` into `ı` here.
263        assert!(!WorkspaceId::from_display_name("ilik").expect("usable").clashes_with("ILİK"));
264    }
265}