1use crate::error::RecError;
8
9#[must_use]
21pub fn normalize_tag(tag: &str) -> String {
22 tag.split_whitespace()
23 .collect::<Vec<&str>>()
24 .join("-")
25 .to_lowercase()
26}
27
28pub fn validate_alias_name(name: &str) -> crate::error::Result<()> {
47 if name.is_empty() {
48 return Err(RecError::InvalidAliasName(
49 "alias name cannot be empty".to_string(),
50 ));
51 }
52 if name
53 .chars()
54 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
55 {
56 Ok(())
57 } else {
58 Err(RecError::InvalidAliasName(name.to_string()))
59 }
60}
61
62pub fn validate_tag_name(tag: &str) -> crate::error::Result<()> {
83 if tag.is_empty() {
84 return Err(RecError::InvalidTagName(
85 "tag name cannot be empty".to_string(),
86 ));
87 }
88 if tag
89 .chars()
90 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
91 {
92 Ok(())
93 } else {
94 Err(RecError::InvalidTagName(tag.to_string()))
95 }
96}
97
98#[must_use]
114pub fn find_tag_collision(normalized: &str, existing_tags: &[String]) -> Option<String> {
115 existing_tags.iter().find_map(|existing| {
116 let existing_normalized = normalize_tag(existing);
117 if existing_normalized == normalized && existing != normalized {
118 Some(existing.clone())
119 } else {
120 None
121 }
122 })
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
132 fn normalize_tag_lowercases() {
133 assert_eq!(normalize_tag("Deploy"), "deploy");
134 }
135
136 #[test]
137 fn normalize_tag_trims_and_collapses_whitespace() {
138 assert_eq!(normalize_tag(" My Deploy Tag "), "my-deploy-tag");
139 }
140
141 #[test]
142 fn normalize_tag_already_normalized() {
143 assert_eq!(normalize_tag("rust"), "rust");
144 }
145
146 #[test]
147 fn normalize_tag_empty_after_trim() {
148 assert_eq!(normalize_tag(" "), "");
149 }
150
151 #[test]
152 fn normalize_tag_preserves_hyphens_and_lowercases() {
153 assert_eq!(normalize_tag("CI-CD"), "ci-cd");
154 }
155
156 #[test]
157 fn normalize_tag_trims_only() {
158 assert_eq!(normalize_tag(" HELLO "), "hello");
159 }
160
161 #[test]
162 fn normalize_tag_no_change_when_already_hyphenated() {
163 assert_eq!(normalize_tag("my-tag"), "my-tag");
164 }
165
166 #[test]
169 fn collision_detected_case_variant() {
170 let existing = vec!["Deploy".to_string(), "rust".to_string()];
171 assert_eq!(
172 find_tag_collision("deploy", &existing),
173 Some("Deploy".to_string())
174 );
175 }
176
177 #[test]
178 fn no_collision_on_exact_match() {
179 let existing = vec!["deploy".to_string(), "rust".to_string()];
180 assert_eq!(find_tag_collision("deploy", &existing), None);
181 }
182
183 #[test]
184 fn collision_detected_whitespace_variant() {
185 let existing = vec!["My Deploy Tag".to_string()];
186 assert_eq!(
187 find_tag_collision("my-deploy-tag", &existing),
188 Some("My Deploy Tag".to_string())
189 );
190 }
191
192 #[test]
193 fn no_collision_when_tag_absent() {
194 let existing = vec!["deploy".to_string(), "rust".to_string()];
195 assert_eq!(find_tag_collision("new-tag", &existing), None);
196 }
197
198 #[test]
199 fn no_collision_with_empty_list() {
200 let existing: Vec<String> = vec![];
201 assert_eq!(find_tag_collision("deploy", &existing), None);
202 }
203
204 #[test]
207 fn validate_alias_name_valid_alphanumeric() {
208 assert!(validate_alias_name("deploy").is_ok());
209 assert!(validate_alias_name("myAlias123").is_ok());
210 assert!(validate_alias_name("a").is_ok());
211 }
212
213 #[test]
214 fn validate_alias_name_valid_with_dash() {
215 assert!(validate_alias_name("my-alias").is_ok());
216 assert!(validate_alias_name("deploy-prod").is_ok());
217 assert!(validate_alias_name("ci-cd-2026").is_ok());
218 }
219
220 #[test]
221 fn validate_alias_name_valid_with_underscore() {
222 assert!(validate_alias_name("my_alias").is_ok());
223 assert!(validate_alias_name("deploy_prod").is_ok());
224 assert!(validate_alias_name("ci_cd_2026").is_ok());
225 }
226
227 #[test]
228 fn validate_alias_name_valid_mixed() {
229 assert!(validate_alias_name("my-alias_123").is_ok());
230 assert!(validate_alias_name("Deploy_Prod-v2").is_ok());
231 }
232
233 #[test]
234 fn validate_alias_name_empty() {
235 let result = validate_alias_name("");
236 assert!(result.is_err());
237 match result.unwrap_err() {
238 RecError::InvalidAliasName(msg) => {
239 assert!(msg.contains("empty"));
240 }
241 _ => panic!("Expected InvalidAliasName error"),
242 }
243 }
244
245 #[test]
246 fn validate_alias_name_invalid_space() {
247 let result = validate_alias_name("my alias");
248 assert!(result.is_err());
249 match result.unwrap_err() {
250 RecError::InvalidAliasName(name) => {
251 assert_eq!(name, "my alias");
252 }
253 _ => panic!("Expected InvalidAliasName error"),
254 }
255 }
256
257 #[test]
258 fn validate_alias_name_invalid_special_chars() {
259 assert!(validate_alias_name("bad/alias").is_err());
260 assert!(validate_alias_name("alias@home").is_err());
261 assert!(validate_alias_name("alias.ext").is_err());
262 assert!(validate_alias_name("alias$var").is_err());
263 assert!(validate_alias_name("alias;cmd").is_err());
264 assert!(validate_alias_name("alias|pipe").is_err());
265 assert!(validate_alias_name("alias&bg").is_err());
266 assert!(validate_alias_name("alias<redirect").is_err());
267 assert!(validate_alias_name("alias>redirect").is_err());
268 }
269
270 #[test]
273 fn validate_tag_name_valid_alphanumeric() {
274 assert!(validate_tag_name("deploy").is_ok());
275 assert!(validate_tag_name("rust").is_ok());
276 assert!(validate_tag_name("v1").is_ok());
277 }
278
279 #[test]
280 fn validate_tag_name_valid_with_dash() {
281 assert!(validate_tag_name("ci-cd").is_ok());
282 assert!(validate_tag_name("my-tag").is_ok());
283 assert!(validate_tag_name("deploy-2026").is_ok());
284 }
285
286 #[test]
287 fn validate_tag_name_valid_with_underscore() {
288 assert!(validate_tag_name("ci_cd").is_ok());
289 assert!(validate_tag_name("my_tag").is_ok());
290 assert!(validate_tag_name("deploy_2026").is_ok());
291 }
292
293 #[test]
294 fn validate_tag_name_valid_mixed() {
295 assert!(validate_tag_name("ci-cd_2026").is_ok());
296 assert!(validate_tag_name("my_tag-v2").is_ok());
297 }
298
299 #[test]
300 fn validate_tag_name_empty() {
301 let result = validate_tag_name("");
302 assert!(result.is_err());
303 match result.unwrap_err() {
304 RecError::InvalidTagName(msg) => {
305 assert!(msg.contains("empty"));
306 }
307 _ => panic!("Expected InvalidTagName error"),
308 }
309 }
310
311 #[test]
312 fn validate_tag_name_invalid_special_chars() {
313 assert!(validate_tag_name("bad/tag").is_err());
314 assert!(validate_tag_name("tag@home").is_err());
315 assert!(validate_tag_name("tag.ext").is_err());
316 assert!(validate_tag_name("tag$var").is_err());
317 assert!(validate_tag_name("tag;cmd").is_err());
318 assert!(validate_tag_name("tag|pipe").is_err());
319 assert!(validate_tag_name("tag&bg").is_err());
320 }
321}