1use gpui_kit::SharedString;
5
6use super::PathStyle;
7
8const MAX_NAME: usize = 255;
10
11#[derive(Clone, Debug, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum CreateFolderError {
20 Unsupported,
22 Exists,
24 InvalidName(SharedString),
27 PermissionDenied,
29 NotReady,
32 Other(SharedString),
34}
35
36impl CreateFolderError {
37 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
63pub(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
76pub(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
115fn 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}