Skip to main content

rec/session/
normalize.rs

1//! Tag and alias normalization and validation pipeline.
2//!
3//! Normalizes tags to lowercase-hyphenated form and detects collisions
4//! between normalized variants of existing tags. Also provides validation
5//! functions for alias and tag names.
6
7use crate::error::RecError;
8
9/// Normalize a tag to lowercase-hyphenated form.
10///
11/// - Trims leading/trailing whitespace
12/// - Collapses internal whitespace to single hyphens
13/// - Lowercases all characters
14///
15/// # Examples
16/// ```
17/// use rec::session::normalize::normalize_tag;
18/// assert_eq!(normalize_tag("  My  Deploy  Tag  "), "my-deploy-tag");
19/// ```
20#[must_use]
21pub fn normalize_tag(tag: &str) -> String {
22    tag.split_whitespace()
23        .collect::<Vec<&str>>()
24        .join("-")
25        .to_lowercase()
26}
27
28/// Validate an alias name.
29///
30/// Alias names must be non-empty and contain only alphanumeric characters,
31/// dashes, and underscores. This prevents filesystem issues and injection attacks.
32///
33/// # Errors
34///
35/// Returns `RecError::InvalidAliasName` if the name is empty or contains
36/// characters other than alphanumeric, dash, or underscore.
37///
38/// # Examples
39/// ```
40/// use rec::session::normalize::validate_alias_name;
41/// assert!(validate_alias_name("my-alias").is_ok());
42/// assert!(validate_alias_name("alias_123").is_ok());
43/// assert!(validate_alias_name("").is_err());
44/// assert!(validate_alias_name("bad/alias").is_err());
45/// ```
46pub 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
62/// Validate a tag name after normalization.
63///
64/// Tag names must be non-empty and contain only alphanumeric characters,
65/// dashes, and underscores after normalization. This prevents injection attacks
66/// and ensures consistent tag storage.
67///
68/// # Errors
69///
70/// Returns `RecError::InvalidTagName` if the normalized tag is empty or contains
71/// characters other than alphanumeric, dash, or underscore.
72///
73/// # Examples
74/// ```
75/// use rec::session::normalize::validate_tag_name;
76/// assert!(validate_tag_name("deploy").is_ok());
77/// assert!(validate_tag_name("ci-cd").is_ok());
78/// assert!(validate_tag_name("tag_123").is_ok());
79/// assert!(validate_tag_name("").is_err());
80/// assert!(validate_tag_name("bad@tag").is_err());
81/// ```
82pub 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/// Detect when a normalized tag collides with an existing tag.
99///
100/// A collision occurs when an existing tag normalizes to the same form as
101/// `normalized`, but the original string differs from `normalized` (i.e.,
102/// not an exact match).
103///
104/// Returns the original (un-normalized) form of the first colliding tag,
105/// or `None` if no collision.
106///
107/// # Examples
108/// ```
109/// use rec::session::normalize::find_tag_collision;
110/// let existing = vec!["Deploy".to_string(), "rust".to_string()];
111/// assert_eq!(find_tag_collision("deploy", &existing), Some("Deploy".to_string()));
112/// ```
113#[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    // ── normalize_tag tests ──────────────────────────────────────────
130
131    #[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    // ── find_tag_collision tests ─────────────────────────────────────
167
168    #[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    // ── validate_alias_name tests ─────────────────────────────────────
205
206    #[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    // ── validate_tag_name tests ───────────────────────────────────────
271
272    #[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}