Skip to main content

qcode/profile/
name.rs

1//! Names that are safe as a file name and as a container object name at the same time.
2
3use std::fmt;
4
5/// A name that is safe everywhere QCode puts it: as a file name in the store, as part of an
6/// image name, and as a container or volume name.
7///
8/// It holds lowercase ASCII letters, digits and single hyphens, and begins and ends with a
9/// letter or digit. Folding is written out character by character rather than left to
10/// [`str::to_lowercase`], so that the same text gives the same name on every machine: in Turkish
11/// `I` and `İ` do not fold the way the rest of the world folds them, and a name is a file name.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct SafeName(String);
14
15impl SafeName {
16    /// The longest a name may be, in characters. Container engines take far longer names; the
17    /// limit keeps a name readable in a list and leaves room for the prefixes QCode adds.
18    pub const MAX_LENGTH: usize = 64;
19
20    /// Makes a name out of text a person typed, such as a workspace title. Letters fold to ASCII,
21    /// everything else becomes a single hyphen, and the result is cut to [`Self::MAX_LENGTH`].
22    /// Text without a single usable character has no name.
23    #[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    /// Reads a name that is already safe, such as one from a definition file. Text that is not
43    /// exactly what [`Self::from_display`] would have produced is refused rather than repaired,
44    /// so that a file and the name in it can never disagree.
45    #[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    /// The name as text.
57    #[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
69/// The ASCII a letter outside ASCII folds to. Letters QCode has no folding for become a
70/// separator, so a name never carries a character an engine or a file system might refuse.
71fn 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        // The locale must not decide: `I` and `İ` and `ı` all end up as the ASCII `i`.
100        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}