Skip to main content

gpui_cn/path_browser/
create.rs

1//! Making a folder from a picker: the typed error a source answers with, and
2//! the one check every name passes before a source is asked.
3
4use gpui_kit::SharedString;
5
6use super::PathStyle;
7
8/// The longest folder name the picker passes on, in characters.
9const MAX_NAME: usize = 255;
10
11/// Why a source could not make a folder. The picker shows the message under
12/// the row that names the folder, and keeps the row editable.
13///
14/// A source returns one from `create_folder`. A name that is empty, has a
15/// separator, or is `.` or `..` is refused by the picker before the source
16/// is asked, with [`InvalidName`](Self::InvalidName).
17#[derive(Clone, Debug, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum CreateFolderError {
20    /// The source cannot make folders.
21    Unsupported,
22    /// A folder or file with that name is already there.
23    Exists,
24    /// The name cannot be a folder name. The text says why, and is shown
25    /// to the user.
26    InvalidName(SharedString),
27    /// The user may not make folders there.
28    PermissionDenied,
29    /// The picker cannot make a folder yet: the directory is still
30    /// loading, or another folder is being made.
31    NotReady,
32    /// Any other failure. The text is shown to the user.
33    Other(SharedString),
34}
35
36impl CreateFolderError {
37    /// The text the picker shows for the folder called `name`.
38    pub fn message(&self, name: &str) -> SharedString {
39        match self {
40            Self::Unsupported => "This location cannot make folders".into(),
41            Self::NotReady => "Wait for the folder to load".into(),
42            Self::Exists => format!("A folder named {name} already exists").into(),
43            Self::PermissionDenied => "You cannot make folders here".into(),
44            Self::InvalidName(reason) | Self::Other(reason) => reason.clone(),
45        }
46    }
47}
48
49impl From<std::io::Error> for CreateFolderError {
50    fn from(error: std::io::Error) -> Self {
51        use std::io::ErrorKind;
52        match error.kind() {
53            ErrorKind::AlreadyExists => Self::Exists,
54            ErrorKind::PermissionDenied => Self::PermissionDenied,
55            ErrorKind::InvalidInput | ErrorKind::InvalidFilename => {
56                Self::InvalidName("That name is not allowed".into())
57            }
58            _ => Self::Other(error.to_string().into()),
59        }
60    }
61}
62
63/// Whether a listed name is one plain name: not empty, not `.` or `..`,
64/// with no separator of the style and no control character. A listing that
65/// names anything else is dropped, so a source cannot make a row that
66/// climbs out of the folder or reaches into a deeper one.
67pub(crate) fn is_plain_name(name: &str, style: &PathStyle) -> bool {
68    !name.is_empty()
69        && name != "."
70        && name != ".."
71        && !name
72            .chars()
73            .any(|c| style.is_separator(c) || c.is_control())
74}
75
76/// The name a folder would get, or why it cannot: surrounding spaces go,
77/// and what is left must be there, short, free of separators, nulls and
78/// control characters, not `.` or `..`, and in a Windows style without the
79/// characters Windows refuses.
80pub(crate) fn validate_folder_name(
81    name: &str,
82    style: &PathStyle,
83) -> Result<String, CreateFolderError> {
84    let invalid = |reason: &str| Err(CreateFolderError::InvalidName(reason.to_string().into()));
85    let name = name.trim();
86    if name.is_empty() {
87        return invalid("Enter a name for the folder");
88    }
89    if name == "." || name == ".." {
90        return invalid("A folder cannot be named . or ..");
91    }
92    if name.chars().count() > MAX_NAME {
93        return invalid("That name is too long");
94    }
95    if name.contains(['/', '\\']) {
96        return invalid("A folder name cannot contain / or \\");
97    }
98    if name.chars().any(char::is_control) {
99        return invalid("A folder name cannot contain control characters");
100    }
101    if style.is_windows() && name.contains(['<', '>', ':', '"', '|', '?', '*']) {
102        return invalid("A folder name cannot contain < > : \" | ? *");
103    }
104    if style.is_windows() {
105        if name.ends_with('.') {
106            return invalid("A folder name cannot end with a dot");
107        }
108        if is_reserved_windows_name(name) {
109            return invalid("That name is reserved by Windows");
110        }
111    }
112    Ok(name.to_string())
113}
114
115/// Whether Windows reserves `name`: a device name, with or without an
116/// extension, in any case.
117fn is_reserved_windows_name(name: &str) -> bool {
118    let stem = name
119        .split('.')
120        .next()
121        .unwrap_or(name)
122        .trim_end()
123        .to_ascii_uppercase();
124    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
125        || ["COM", "LPT"].iter().any(|prefix| {
126            stem.strip_prefix(prefix).is_some_and(|digit| {
127                digit.len() == 1 && digit != "0" && digit.as_bytes()[0].is_ascii_digit()
128            })
129        })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn a_listed_name_is_one_plain_name() {
138        let posix = PathStyle::posix();
139        let windows = PathStyle::windows();
140        for name in ["", ".", "..", "a/b", "../x", "a\0b", "tab\t"] {
141            assert!(!is_plain_name(name, &posix), "{name:?}");
142        }
143        assert!(
144            is_plain_name("a\\b", &posix),
145            "a backslash is a POSIX name character"
146        );
147        assert!(!is_plain_name("a\\b", &windows));
148        assert!(is_plain_name("...", &posix));
149        assert!(is_plain_name(".config", &posix));
150    }
151
152    #[test]
153    fn a_good_name_is_trimmed() {
154        let posix = PathStyle::posix();
155        assert_eq!(
156            validate_folder_name("  src  ", &posix).as_deref(),
157            Ok("src")
158        );
159        assert_eq!(
160            validate_folder_name(".config", &posix).as_deref(),
161            Ok(".config")
162        );
163        assert_eq!(
164            validate_folder_name("a b.c", &posix).as_deref(),
165            Ok("a b.c")
166        );
167        assert_eq!(validate_folder_name("a:b", &posix).as_deref(), Ok("a:b"));
168    }
169
170    #[test]
171    fn a_bad_name_says_why() {
172        let posix = PathStyle::posix();
173        for name in ["", "   ", ".", "..", "a/b", "a\\b", "a\0b", "a\nb"] {
174            assert!(
175                matches!(
176                    validate_folder_name(name, &posix),
177                    Err(CreateFolderError::InvalidName(_))
178                ),
179                "{name:?}"
180            );
181        }
182        let long = "x".repeat(256);
183        assert!(validate_folder_name(&long, &posix).is_err());
184        assert!(validate_folder_name(&"x".repeat(255), &posix).is_ok());
185    }
186
187    #[test]
188    fn windows_refuses_what_windows_refuses() {
189        let windows = PathStyle::windows();
190        for name in ["a:b", "a*b", "a?b", "a|b", "a<b", "a\"b"] {
191            assert!(validate_folder_name(name, &windows).is_err(), "{name}");
192        }
193        assert!(validate_folder_name("My Files", &windows).is_ok());
194        for name in [
195            "CON", "con", "Nul.txt", "COM1", "lpt9.log", "AUX.", "name.", "a..", "PRN.x.y",
196        ] {
197            assert!(validate_folder_name(name, &windows).is_err(), "{name}");
198        }
199        for name in ["COM0", "COM10", "CONSOLE", "com", "LPT", "a.con"] {
200            assert!(validate_folder_name(name, &windows).is_ok(), "{name}");
201        }
202        assert!(validate_folder_name("CON", &PathStyle::posix()).is_ok());
203        assert!(validate_folder_name("name.", &PathStyle::posix()).is_ok());
204    }
205
206    #[test]
207    fn the_message_names_the_folder() {
208        assert_eq!(
209            CreateFolderError::Exists.message("docs"),
210            "A folder named docs already exists"
211        );
212        assert_eq!(
213            CreateFolderError::Other("Disk full".into()).message("x"),
214            "Disk full"
215        );
216    }
217
218    #[test]
219    fn io_errors_map_to_the_matching_failure() {
220        use std::io::{Error, ErrorKind};
221        assert_eq!(
222            CreateFolderError::from(Error::from(ErrorKind::AlreadyExists)),
223            CreateFolderError::Exists
224        );
225        assert_eq!(
226            CreateFolderError::from(Error::from(ErrorKind::PermissionDenied)),
227            CreateFolderError::PermissionDenied
228        );
229    }
230}