1use sha2::{Digest, Sha256};
2use thiserror::Error;
3
4#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct ProjectNames {
7 pub directory_name: String,
9 pub crate_name: String,
11 pub display_name: String,
13 pub application_identifier: String,
15}
16
17#[derive(Debug, Error, Eq, PartialEq)]
19pub enum NamingError {
20 #[error("invalid project name: {reason}")]
22 InvalidProjectName {
23 reason: String,
25 },
26 #[error("invalid application identifier `{identifier}`: {reason}")]
28 InvalidApplicationIdentifier {
29 identifier: String,
31 reason: String,
33 },
34}
35
36pub fn derive_project_names(
42 requested_name: &str,
43 identifier: Option<&str>,
44) -> Result<ProjectNames, NamingError> {
45 validate_project_name(requested_name)?;
46
47 let crate_name = crate_name(requested_name);
48 let display_name = display_name(requested_name);
49 let application_identifier = identifier.map_or_else(
50 || {
51 let suffix = crate_name
52 .chars()
53 .filter(char::is_ascii_alphanumeric)
54 .collect::<String>();
55 format!("org.rustferry.{suffix}")
56 },
57 ToOwned::to_owned,
58 );
59 validate_application_identifier(&application_identifier)?;
60
61 Ok(ProjectNames {
62 directory_name: requested_name.to_owned(),
63 crate_name,
64 display_name,
65 application_identifier,
66 })
67}
68
69fn validate_project_name(name: &str) -> Result<(), NamingError> {
70 if name.is_empty() {
71 return Err(invalid_name("the name is empty"));
72 }
73 if name.trim() != name {
74 return Err(invalid_name(
75 "leading or trailing whitespace is not allowed",
76 ));
77 }
78 if matches!(name, "." | "..") {
79 return Err(invalid_name("`.` and `..` are not project names"));
80 }
81 if name.contains(['/', '\\']) {
82 return Err(invalid_name("path separators are not allowed"));
83 }
84 if name.chars().any(char::is_control) {
85 return Err(invalid_name("control characters are not allowed"));
86 }
87 if name.contains(['<', '>', ':', '"', '|', '?', '*']) || name.ends_with('.') {
88 return Err(invalid_name(
89 "the name contains characters that are unsafe on supported filesystems",
90 ));
91 }
92 let windows_stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
93 if matches!(windows_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
94 || windows_stem
95 .strip_prefix("COM")
96 .or_else(|| windows_stem.strip_prefix("LPT"))
97 .is_some_and(|number| {
98 matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
99 })
100 {
101 return Err(invalid_name("the name is reserved by Windows"));
102 }
103 Ok(())
104}
105
106fn invalid_name(reason: &str) -> NamingError {
107 NamingError::InvalidProjectName {
108 reason: reason.to_owned(),
109 }
110}
111
112fn crate_name(name: &str) -> String {
113 let mut output = String::with_capacity(name.len());
114 let mut separator = false;
115 for character in name.chars() {
116 if character.is_ascii_alphanumeric() {
117 if separator && !output.is_empty() {
118 output.push('-');
119 }
120 separator = false;
121 output.push(character.to_ascii_lowercase());
122 } else {
123 separator = true;
124 }
125 }
126 while output.ends_with('-') {
127 output.pop();
128 }
129 if output.is_empty() {
130 let digest = Sha256::digest(name.as_bytes());
131 output = format!("app-{}", hex::encode(&digest[..4]));
132 }
133 if output.as_bytes()[0].is_ascii_digit() {
134 output.insert_str(0, "app-");
135 }
136 if rust_keyword(&output) {
137 output.insert_str(0, "app-");
138 }
139 output
140}
141
142fn rust_keyword(value: &str) -> bool {
143 matches!(
144 value,
145 "as" | "break"
146 | "const"
147 | "continue"
148 | "crate"
149 | "else"
150 | "enum"
151 | "extern"
152 | "false"
153 | "fn"
154 | "for"
155 | "if"
156 | "impl"
157 | "in"
158 | "let"
159 | "loop"
160 | "match"
161 | "mod"
162 | "move"
163 | "mut"
164 | "pub"
165 | "ref"
166 | "return"
167 | "self"
168 | "Self"
169 | "static"
170 | "struct"
171 | "super"
172 | "trait"
173 | "true"
174 | "type"
175 | "unsafe"
176 | "use"
177 | "where"
178 | "while"
179 | "async"
180 | "await"
181 | "dyn"
182 | "abstract"
183 | "become"
184 | "box"
185 | "do"
186 | "final"
187 | "macro"
188 | "override"
189 | "priv"
190 | "typeof"
191 | "unsized"
192 | "virtual"
193 | "yield"
194 | "try"
195 | "gen"
196 )
197}
198
199fn display_name(name: &str) -> String {
200 let mut words = name
201 .split(['-', '_'])
202 .filter(|word| !word.is_empty())
203 .map(|word| {
204 let mut characters = word.chars();
205 characters.next().map_or_else(String::new, |first| {
206 first.to_uppercase().collect::<String>() + characters.as_str()
207 })
208 })
209 .collect::<Vec<_>>();
210 if words.is_empty() {
211 name.to_owned()
212 } else {
213 words.retain(|word| !word.is_empty());
214 words.join(" ")
215 }
216}
217
218pub fn validate_application_identifier(identifier: &str) -> Result<(), NamingError> {
224 if identifier.len() > 255 {
225 return Err(invalid_identifier(identifier, "it exceeds 255 bytes"));
226 }
227 let segments = identifier.split('.').collect::<Vec<_>>();
228 if segments.len() < 3 {
229 return Err(invalid_identifier(
230 identifier,
231 "use at least three dot-separated segments, for example `com.example.app`",
232 ));
233 }
234 for segment in segments {
235 let mut characters = segment.chars();
236 let Some(first) = characters.next() else {
237 return Err(invalid_identifier(
238 identifier,
239 "empty segments are not allowed",
240 ));
241 };
242 if !first.is_ascii_lowercase() {
243 return Err(invalid_identifier(
244 identifier,
245 "each segment must start with a lowercase ASCII letter",
246 ));
247 }
248 if !characters.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
249 {
250 return Err(invalid_identifier(
251 identifier,
252 "segments may contain only lowercase ASCII letters and digits",
253 ));
254 }
255 }
256 Ok(())
257}
258
259fn invalid_identifier(identifier: &str, reason: &str) -> NamingError {
260 NamingError::InvalidApplicationIdentifier {
261 identifier: identifier.to_owned(),
262 reason: reason.to_owned(),
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn derives_safe_names() {
272 let names = derive_project_names("Weather App", None).unwrap();
273 assert_eq!(names.crate_name, "weather-app");
274 assert_eq!(names.display_name, "Weather App");
275 assert_eq!(names.application_identifier, "org.rustferry.weatherapp");
276 }
277
278 #[test]
279 fn unicode_names_are_deterministic() {
280 let first = derive_project_names("Погода", None).unwrap();
281 let second = derive_project_names("Погода", None).unwrap();
282 assert_eq!(first.crate_name, second.crate_name);
283 assert!(first.crate_name.starts_with("app-"));
284 }
285
286 #[test]
287 fn rejects_path_traversal() {
288 assert!(derive_project_names("../weather", None).is_err());
289 assert!(derive_project_names("..", None).is_err());
290 }
291
292 #[test]
293 fn rejects_cross_platform_reserved_names() {
294 assert!(derive_project_names("NUL", None).is_err());
295 assert!(derive_project_names("weather?", None).is_err());
296 assert_eq!(
297 derive_project_names("fn", None).unwrap().crate_name,
298 "app-fn"
299 );
300 }
301
302 #[test]
303 fn rejects_unsafe_identifier() {
304 assert!(validate_application_identifier("com.Example.weather").is_err());
305 assert!(validate_application_identifier("com.example.weather_app").is_err());
306 assert!(validate_application_identifier("com.example.weather-app").is_err());
307 assert!(validate_application_identifier("example").is_err());
308 }
309}