1use thiserror::Error;
5
6pub const PREFIX: &str = "xianyun";
7const LEGACY_PREFIX: &str = "caproni";
14pub const STRATEGY_STATIC: &str = "static";
15pub const STRATEGY_DIR_HASH: &str = "directory-hash";
16
17const MAX_LABEL_LENGTH: usize = 32;
18const HASH_LENGTH: usize = 6;
19const SEPARATOR_COUNT: usize = 2;
20const RESERVED_FOR_SUFFIX: usize = HASH_LENGTH + SEPARATOR_COUNT;
21
22#[derive(Debug, Error, PartialEq, Eq)]
23pub enum NamingError {
24 #[error("unknown naming strategy: {0}")]
25 UnknownStrategy(String),
26}
27
28pub fn resolve(strategy: &str, config_dir: &str) -> Result<String, NamingError> {
31 match strategy {
32 STRATEGY_STATIC | "" => Ok(PREFIX.to_string()),
33 STRATEGY_DIR_HASH => Ok(create_directory_hash(config_dir)),
34 other => Err(NamingError::UnknownStrategy(other.to_string())),
35 }
36}
37
38pub fn context_name(profile: &str) -> String {
40 if profile.is_empty() {
41 return PREFIX.to_string();
42 }
43
44 if profile == PREFIX
45 || profile.starts_with(&format!("{PREFIX}-"))
46 || profile == LEGACY_PREFIX
47 || profile.starts_with(&format!("{LEGACY_PREFIX}-"))
48 {
49 return profile.to_string();
50 }
51
52 format!("{PREFIX}-{profile}")
53}
54
55fn create_directory_hash(config_dir: &str) -> String {
56 let dir_name = base_name(config_dir);
57 let hash = &sha256sum(config_dir)[..HASH_LENGTH];
58
59 let mut sanitized = to_rfc1123_label(dir_name);
60
61 if let Some(stripped) = sanitized
62 .strip_prefix(PREFIX)
63 .or_else(|| sanitized.strip_prefix(LEGACY_PREFIX))
64 {
65 sanitized = stripped.to_string();
66 }
67
68 let max_dir_len = MAX_LABEL_LENGTH - PREFIX.len() - RESERVED_FOR_SUFFIX;
69 if sanitized.len() > max_dir_len {
70 sanitized.truncate(max_dir_len);
71 }
72 sanitized = sanitized.trim_matches('-').to_string();
73
74 if sanitized.is_empty() {
75 return format!("{PREFIX}-{hash}");
76 }
77
78 format!("{PREFIX}-{sanitized}-{hash}")
79}
80
81fn base_name(path: &str) -> &str {
82 let trimmed = path.trim_end_matches('/');
83 if trimmed.is_empty() {
84 return "/";
85 }
86 trimmed.rsplit('/').next().unwrap_or(trimmed)
87}
88
89fn to_rfc1123_label(s: &str) -> String {
90 let lower = s.to_lowercase();
91 let mut out = String::with_capacity(lower.len());
92 for c in lower.chars() {
93 if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
94 out.push(c);
95 } else {
96 out.push('-');
97 }
98 }
99
100 let mut collapsed = String::with_capacity(out.len());
102 let mut prev_hyphen = false;
103 for c in out.chars() {
104 if c == '-' {
105 if !prev_hyphen {
106 collapsed.push(c);
107 }
108 prev_hyphen = true;
109 } else {
110 collapsed.push(c);
111 prev_hyphen = false;
112 }
113 }
114
115 collapsed.trim_matches('-').to_string()
116}
117
118fn sha256sum(s: &str) -> String {
119 use sha2::{Digest, Sha256};
120 let mut hasher = Sha256::new();
121 hasher.update(s.as_bytes());
122 let digest = hasher.finalize();
123 digest.iter().map(|b| format!("{b:02x}")).collect()
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
132 fn resolve_cases() {
133 let rfc1123 = |s: &str| -> bool {
134 !s.is_empty()
135 && s.chars()
136 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
137 && !s.starts_with('-')
138 && !s.ends_with('-')
139 };
140
141 let cases: &[(&str, &str, &str, Option<&str>)] = &[
142 (
143 "static strategy",
144 STRATEGY_STATIC,
145 "/any/path",
146 Some("xianyun"),
147 ),
148 (
149 "empty strategy defaults to static",
150 "",
151 "/any/path",
152 Some("xianyun"),
153 ),
154 (
155 "unknown strategy returns error",
156 "random",
157 "/any/path",
158 None,
159 ),
160 (
161 "directory-hash basic",
162 STRATEGY_DIR_HASH,
163 "/home/user/demo",
164 Some("xianyun-demo-9fd39f"),
165 ),
166 (
167 "directory-hash uppercase coerced",
168 STRATEGY_DIR_HASH,
169 "/home/user/UPPER",
170 Some("xianyun-upper-ce5dc1"),
171 ),
172 (
173 "directory-hash special chars",
174 STRATEGY_DIR_HASH,
175 "/home/user/My Project!",
176 Some("xianyun-my-project-148160"),
177 ),
178 (
179 "directory-hash deterministic",
180 STRATEGY_DIR_HASH,
181 "/home/user/demo",
182 Some("xianyun-demo-9fd39f"),
183 ),
184 (
185 "directory-hash different path same dirname",
186 STRATEGY_DIR_HASH,
187 "/other/path/demo",
188 Some("xianyun-demo-d2c668"),
189 ),
190 (
191 "directory-hash with dashes",
192 STRATEGY_DIR_HASH,
193 "/other/path/-demo-",
194 Some("xianyun-demo-2a331e"),
195 ),
196 (
197 "directory-hash caproni prefix (legacy project name, still stripped)",
198 STRATEGY_DIR_HASH,
199 "/other/path/caproni-demo",
200 Some("xianyun-demo-5a3655"),
201 ),
202 (
203 "directory-hash xianyun prefix",
204 STRATEGY_DIR_HASH,
205 "/other/path/xianyun-demo",
206 Some("xianyun-demo-a0b29d"),
207 ),
208 (
209 "directory-hash all special chars dir",
210 STRATEGY_DIR_HASH,
211 "/home/user/---special---",
212 Some("xianyun-special-e1a6cf"),
213 ),
214 (
215 "directory-hash root dir",
216 STRATEGY_DIR_HASH,
217 "/",
218 Some("xianyun-8a5eda"),
219 ),
220 ];
221
222 for (name, strategy, dir, want) in cases {
223 let got = resolve(strategy, dir);
224 match want {
225 Some(expected) => {
226 let result = got.unwrap_or_else(|e| panic!("{name}: unexpected error {e}"));
227 assert_eq!(&result, expected, "{name}");
228 assert!(result.len() <= MAX_LABEL_LENGTH, "{name}");
229 assert!(rfc1123(&result), "{name}");
230 }
231 None => {
232 assert!(got.is_err(), "{name}: expected error");
233 }
234 }
235 }
236 }
237
238 #[test]
241 fn resolve_long_dirname_is_truncated() {
242 let dir = format!("/home/user/{}", "a".repeat(100));
243 let want = format!("xianyun-{}-a90ac6", "a".repeat(17));
244 assert_eq!(resolve(STRATEGY_DIR_HASH, &dir).unwrap(), want);
245 }
246
247 #[test]
249 fn to_rfc1123_label_cases() {
250 let cases = [
251 ("demo", "demo"),
252 ("UPPER", "upper"),
253 ("My Project!", "my-project"),
254 ("---hyphens---", "hyphens"),
255 ("hello_world", "hello-world"),
256 ("a..b", "a-b"),
257 ("/", ""),
258 ("", ""),
259 ];
260
261 for (input, expected) in cases {
262 assert_eq!(to_rfc1123_label(input), expected, "{input}");
263 }
264 }
265
266 #[test]
268 fn context_name_cases() {
269 let cases = [
270 ("xianyun", "xianyun"),
271 ("xianyun-demo-d06f00d", "xianyun-demo-d06f00d"),
272 ("xianyun-x", "xianyun-x"),
273 ("demo", "xianyun-demo"),
274 ("", "xianyun"),
275 ("xianyunify", "xianyun-xianyunify"),
276 ("xianyunix", "xianyun-xianyunix"),
277 ("caproni", "caproni"),
281 ("caproni-demo-d06f00d", "caproni-demo-d06f00d"),
282 ("caproni-x", "caproni-x"),
283 ("capronify", "xianyun-capronify"),
284 ("capronix", "xianyun-capronix"),
285 ];
286
287 for (input, expected) in cases {
288 assert_eq!(context_name(input), expected, "{input}");
289 }
290 }
291}