Skip to main content

wt/
slug.rs

1//! Branch-slug normalization (spec §3).
2//!
3//! A slug is a filesystem-safe rendering of a branch name, used only for
4//! directory names; the real branch name is always preserved in Git. The rules:
5//! (1) replace `/` and `\` with `-`; (2) replace any run of characters outside
6//! `[a-zA-Z0-9.-]` with `-`; (3) collapse consecutive `-` into one; (4) strip
7//! leading/trailing `-`; (5) if the result is empty, fall back to the short
8//! commit hash of the base ref (supplied by the caller via
9//! [`slugify_with_fallback`]).
10
11/// Normalizes `branch` into a slug, applying rules 1–4. May return an empty
12/// string (e.g. for a branch consisting only of separators); use
13/// [`slugify_with_fallback`] to apply rule 5.
14pub fn slugify(branch: &str) -> String {
15    let mut out = String::with_capacity(branch.len());
16    let mut prev_dash = false;
17    for ch in branch.chars() {
18        if ch.is_ascii_alphanumeric() || ch == '.' {
19            // Rule 2: kept characters (alphanumeric and `.`) pass through.
20            out.push(ch);
21            prev_dash = false;
22        } else if !prev_dash {
23            // Rules 1–3: `/`, `\`, a literal `-`, and any other disallowed
24            // character all become a dash, and consecutive dashes collapse.
25            out.push('-');
26            prev_dash = true;
27        }
28    }
29    // Rule 4: strip leading/trailing dashes.
30    out.trim_matches('-').to_string()
31}
32
33/// Like [`slugify`], but applies rule 5: when the normalized slug is empty,
34/// return `fallback` (the short commit hash of the base ref).
35pub fn slugify_with_fallback(branch: &str, fallback: &str) -> String {
36    let slug = slugify(branch);
37    if slug.is_empty() {
38        fallback.to_string()
39    } else {
40        slug
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn replaces_slashes_and_backslashes() {
50        assert_eq!(slugify("feature/login"), "feature-login");
51        assert_eq!(slugify("a\\b"), "a-b");
52        assert_eq!(slugify("a/b/c"), "a-b-c");
53    }
54
55    #[test]
56    fn replaces_disallowed_runs_with_single_dash() {
57        assert_eq!(slugify("feat@#login"), "feat-login");
58        assert_eq!(slugify("hello world"), "hello-world");
59        assert_eq!(slugify("a   b"), "a-b");
60    }
61
62    #[test]
63    fn keeps_dots_and_digits_and_case() {
64        assert_eq!(slugify("v1.2.3"), "v1.2.3");
65        assert_eq!(slugify("Feature-XYZ"), "Feature-XYZ");
66    }
67
68    #[test]
69    fn collapses_consecutive_dashes() {
70        assert_eq!(slugify("a--b"), "a-b");
71        assert_eq!(slugify("a//b"), "a-b");
72        assert_eq!(slugify("a-/-b"), "a-b");
73    }
74
75    #[test]
76    fn strips_leading_and_trailing_dashes() {
77        assert_eq!(slugify("/feature/"), "feature");
78        assert_eq!(slugify("---x---"), "x");
79        assert_eq!(slugify("@@@edge@@@"), "edge");
80    }
81
82    #[test]
83    fn non_ascii_becomes_dashes() {
84        assert_eq!(slugify("café"), "caf");
85        assert_eq!(slugify("中文branch"), "branch");
86    }
87
88    #[test]
89    fn empty_result_uses_fallback() {
90        assert_eq!(slugify(""), "");
91        assert_eq!(slugify("///"), "");
92        assert_eq!(slugify("@@@"), "");
93        assert_eq!(slugify_with_fallback("///", "abc1234"), "abc1234");
94        assert_eq!(slugify_with_fallback("feature/x", "abc1234"), "feature-x");
95    }
96}