Skip to main content

wt/
naming.rs

1//! The branch-name contract for issue-driven worktrees (issue #96): the
2//! conventional `TYPE/{number}-SLUG` form, its prompt fragment, its validator,
3//! and a deterministic fallback.
4//!
5//! Everything here is pure — no I/O, no [`Cx`](crate::cx::Cx), no subprocesses —
6//! so the same contract can be exercised by `wt`'s own issue flow and by
7//! embedders (karet) without an agent or a network. The prompt fragment and the
8//! validator live side by side so the rule a model is asked to follow and the
9//! rule its output is checked against cannot drift.
10//!
11//! This module names **branches**. [`crate::slug`] is a different concept — it
12//! normalizes a branch name into a filesystem-safe *directory* name.
13
14use std::fmt;
15
16use crate::error::{Error, Result};
17
18/// The nine conventional branch types accepted in `TYPE/{number}-SLUG`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum BranchKind {
21    /// A new feature (`feat`).
22    Feat,
23    /// A bug fix (`fix`).
24    Fix,
25    /// Documentation only (`docs`).
26    Docs,
27    /// A refactor with no behavior change (`refactor`).
28    Refactor,
29    /// Test-only changes (`test`).
30    Test,
31    /// Build system or dependency changes (`build`).
32    Build,
33    /// CI configuration changes (`ci`).
34    Ci,
35    /// A performance improvement (`perf`).
36    Perf,
37    /// Maintenance that fits no other type (`chore`).
38    Chore,
39}
40
41impl BranchKind {
42    /// Every kind, in the order the contract lists them.
43    pub const ALL: [BranchKind; 9] = [
44        BranchKind::Feat,
45        BranchKind::Fix,
46        BranchKind::Docs,
47        BranchKind::Refactor,
48        BranchKind::Test,
49        BranchKind::Build,
50        BranchKind::Ci,
51        BranchKind::Perf,
52        BranchKind::Chore,
53    ];
54
55    /// The lowercase identifier used in branch names (e.g. `"feat"`).
56    pub fn as_str(self) -> &'static str {
57        match self {
58            BranchKind::Feat => "feat",
59            BranchKind::Fix => "fix",
60            BranchKind::Docs => "docs",
61            BranchKind::Refactor => "refactor",
62            BranchKind::Test => "test",
63            BranchKind::Build => "build",
64            BranchKind::Ci => "ci",
65            BranchKind::Perf => "perf",
66            BranchKind::Chore => "chore",
67        }
68    }
69
70    /// Parses an exact kind identifier (`"feat"`, `"fix"`, …).
71    pub fn parse(text: &str) -> Option<BranchKind> {
72        BranchKind::ALL.into_iter().find(|k| k.as_str() == text)
73    }
74
75    /// Maps a GitHub issue label or issue-type name to a kind, case-insensitively:
76    /// the exact kind identifiers plus the common defaults (`bug` → `fix`,
77    /// `enhancement`/`feature` → `feat`, `documentation` → `docs`,
78    /// `performance` → `perf`, `tests` → `test`). `None` for anything else, so a
79    /// caller can fall through to its own default (issue #98 uses `feat`).
80    pub fn from_label(label: &str) -> Option<BranchKind> {
81        let lower = label.to_ascii_lowercase();
82        if let Some(kind) = BranchKind::parse(&lower) {
83            return Some(kind);
84        }
85        match lower.as_str() {
86            "bug" => Some(BranchKind::Fix),
87            "enhancement" | "feature" => Some(BranchKind::Feat),
88            "documentation" => Some(BranchKind::Docs),
89            "performance" => Some(BranchKind::Perf),
90            "tests" => Some(BranchKind::Test),
91            _ => None,
92        }
93    }
94}
95
96impl fmt::Display for BranchKind {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(self.as_str())
99    }
100}
101
102/// A validated `TYPE/{number}-SLUG` branch name.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct BranchName {
105    kind: BranchKind,
106    number: u64,
107    slug: String,
108}
109
110impl BranchName {
111    /// The conventional type prefix.
112    pub fn kind(&self) -> BranchKind {
113        self.kind
114    }
115
116    /// The issue number embedded in the name.
117    pub fn number(&self) -> u64 {
118        self.number
119    }
120
121    /// The lowercase kebab-case slug after the issue number.
122    pub fn slug(&self) -> &str {
123        &self.slug
124    }
125}
126
127impl fmt::Display for BranchName {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(f, "{}/{}-{}", self.kind, self.number, self.slug)
130    }
131}
132
133/// The comma-separated list of kind identifiers, for prompts and errors.
134fn kind_list() -> String {
135    BranchKind::ALL
136        .iter()
137        .map(|k| k.as_str())
138        .collect::<Vec<_>>()
139        .join(", ")
140}
141
142/// The prompt fragment describing the branch contract for `issue_number`, used
143/// verbatim in generation requests so the rule a model is asked to follow is the
144/// same rule [`parse_and_validate`] enforces.
145pub fn branch_rule(issue_number: u64) -> String {
146    format!(
147        "Choose a branch in the exact form TYPE/{issue_number}-SLUG. TYPE must be one of {}. SLUG must be lowercase kebab-case.",
148        kind_list()
149    )
150}
151
152/// Whether `slug` is non-empty lowercase kebab-case: `[a-z0-9-]` only, with no
153/// leading, trailing, or doubled `-`.
154fn is_valid_slug(slug: &str) -> bool {
155    !slug.is_empty()
156        && !slug.starts_with('-')
157        && !slug.ends_with('-')
158        && !slug.contains("--")
159        && slug
160            .bytes()
161            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
162}
163
164/// Validates a generated branch name against the `TYPE/{issue_number}-SLUG`
165/// contract: a legal git branch name, a single `/` separating a known
166/// [`BranchKind`], an `{issue_number}-` prefix, and a non-empty lowercase
167/// kebab-case slug. Returns [`Error::Usage`] naming the violated rule.
168pub fn parse_and_validate(generated: &str, issue_number: u64) -> Result<BranchName> {
169    crate::git::validate_branch_name(generated).map_err(Error::usage)?;
170    let (kind, suffix) = generated
171        .split_once('/')
172        .ok_or_else(|| Error::usage("generated branch must have a conventional type prefix"))?;
173    let kind = BranchKind::parse(kind)
174        .ok_or_else(|| Error::usage(format!("generated branch type {kind:?} is not supported")))?;
175    let prefix = format!("{issue_number}-");
176    let slug = suffix
177        .strip_prefix(&prefix)
178        .ok_or_else(|| Error::usage(format!("generated branch must contain {prefix:?}")))?;
179    if !is_valid_slug(slug) {
180        return Err(Error::usage(
181            "generated branch slug must be non-empty lowercase kebab-case",
182        ));
183    }
184    Ok(BranchName {
185        kind,
186        number: issue_number,
187        slug: slug.to_string(),
188    })
189}
190
191/// Builds the deterministic fallback branch name for an issue: `kind`, the issue
192/// number, and the title reduced to a lowercase kebab-case slug via
193/// [`crate::slug::slugify`] (`"issue"` when the title yields nothing usable).
194/// The result always satisfies [`parse_and_validate`], so worktree creation can
195/// proceed no matter what a generation step produced (issue #98).
196pub fn fallback(kind: BranchKind, issue_number: u64, title: &str) -> BranchName {
197    // `slugify` keeps case and dots (both fine for directories, both illegal
198    // here), so lowercase first and fold dots into dashes after.
199    let mut slug = crate::slug::slugify(&title.to_lowercase()).replace('.', "-");
200    while slug.contains("--") {
201        slug = slug.replace("--", "-");
202    }
203    let slug = slug.trim_matches('-');
204    let slug = if slug.is_empty() { "issue" } else { slug };
205    BranchName {
206        kind,
207        number: issue_number,
208        slug: slug.to_string(),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn kind_parse_display_round_trip() {
218        for kind in BranchKind::ALL {
219            assert_eq!(BranchKind::parse(kind.as_str()), Some(kind));
220            assert_eq!(kind.to_string(), kind.as_str());
221        }
222        assert_eq!(BranchKind::parse("feature"), None);
223        assert_eq!(BranchKind::parse("FEAT"), None);
224        assert_eq!(BranchKind::parse(""), None);
225    }
226
227    #[test]
228    fn kind_from_label_maps_common_hints() {
229        assert_eq!(BranchKind::from_label("bug"), Some(BranchKind::Fix));
230        assert_eq!(BranchKind::from_label("Bug"), Some(BranchKind::Fix));
231        assert_eq!(
232            BranchKind::from_label("enhancement"),
233            Some(BranchKind::Feat)
234        );
235        assert_eq!(BranchKind::from_label("Feature"), Some(BranchKind::Feat));
236        assert_eq!(
237            BranchKind::from_label("documentation"),
238            Some(BranchKind::Docs)
239        );
240        assert_eq!(
241            BranchKind::from_label("performance"),
242            Some(BranchKind::Perf)
243        );
244        assert_eq!(BranchKind::from_label("tests"), Some(BranchKind::Test));
245        // Exact kind identifiers pass through.
246        assert_eq!(BranchKind::from_label("chore"), Some(BranchKind::Chore));
247        // Unknown labels leave the choice to the caller.
248        assert_eq!(BranchKind::from_label("help wanted"), None);
249    }
250
251    #[test]
252    fn branch_rule_names_every_kind_and_the_number() {
253        let rule = branch_rule(42);
254        assert!(rule.contains("TYPE/42-SLUG"));
255        for kind in BranchKind::ALL {
256            assert!(rule.contains(kind.as_str()), "missing {kind}");
257        }
258        assert!(rule.contains("lowercase kebab-case"));
259    }
260
261    #[test]
262    fn valid_branch_parses_into_parts() {
263        let name = parse_and_validate("feat/12-add-login", 12).unwrap();
264        assert_eq!(name.kind(), BranchKind::Feat);
265        assert_eq!(name.number(), 12);
266        assert_eq!(name.slug(), "add-login");
267        assert_eq!(name.to_string(), "feat/12-add-login");
268    }
269
270    #[test]
271    fn every_kind_is_accepted() {
272        for kind in BranchKind::ALL {
273            let text = format!("{kind}/7-x");
274            assert_eq!(parse_and_validate(&text, 7).unwrap().kind(), kind);
275        }
276    }
277
278    #[test]
279    fn digits_and_single_dashes_are_a_valid_slug() {
280        let name = parse_and_validate("fix/3-v2-api-404s", 3).unwrap();
281        assert_eq!(name.slug(), "v2-api-404s");
282    }
283
284    /// The full rejection matrix: each case names the violated rule.
285    #[test]
286    fn invalid_branches_are_rejected_with_the_rule() {
287        let cases: &[(&str, u64, &str)] = &[
288            // Not a legal git branch name at all.
289            ("feat/12-a..b", 12, "invalid branch name"),
290            ("feat/12-a b", 12, "invalid branch name"),
291            ("feat/12-x.lock", 12, "invalid branch name"),
292            // No TYPE/ prefix.
293            ("12-add-login", 12, "conventional type prefix"),
294            // Unknown TYPE.
295            ("feature/12-add-login", 12, "not supported"),
296            ("FEAT/12-add-login", 12, "not supported"),
297            // A second `/` survives into the slug and fails the charset rule.
298            ("feat/12-a/b", 12, "kebab-case"),
299            // Wrong or missing issue number.
300            ("feat/13-add-login", 12, "\"12-\""),
301            ("feat/add-login", 12, "\"12-\""),
302            // Slug violations: empty, uppercase, underscore, edge/double dash.
303            ("feat/12-", 12, "kebab-case"),
304            ("feat/12-Add-Login", 12, "kebab-case"),
305            ("feat/12-add_login", 12, "kebab-case"),
306            ("feat/12-add--login", 12, "kebab-case"),
307            ("feat/12-add-login-", 12, "kebab-case"),
308        ];
309        for (text, number, expect) in cases {
310            let err = parse_and_validate(text, *number).unwrap_err();
311            assert!(matches!(err, Error::Usage(_)), "{text}: {err:?}");
312            assert!(
313                err.to_string().contains(expect),
314                "{text}: {err} (expected {expect:?})"
315            );
316        }
317    }
318
319    #[test]
320    fn leading_dash_slug_is_rejected() {
321        // `feat/12--x` reads as prefix `12-` + slug `-x`; git allows the name,
322        // so it must fall to the kebab-case rule.
323        let err = parse_and_validate("feat/12--x", 12).unwrap_err();
324        assert!(err.to_string().contains("kebab-case"));
325    }
326
327    #[test]
328    fn fallback_is_deterministic_and_normalizes_titles() {
329        let name = fallback(BranchKind::Fix, 482, "NULL owner crashes v1.2 API!");
330        assert_eq!(name.to_string(), "fix/482-null-owner-crashes-v1-2-api");
331        // Same inputs, same output.
332        assert_eq!(
333            fallback(BranchKind::Fix, 482, "NULL owner crashes v1.2 API!"),
334            name
335        );
336    }
337
338    #[test]
339    fn fallback_survives_hostile_titles() {
340        // Nothing slug-worthy at all.
341        assert_eq!(
342            fallback(BranchKind::Feat, 9, "!!!").to_string(),
343            "feat/9-issue"
344        );
345        assert_eq!(
346            fallback(BranchKind::Feat, 9, "").to_string(),
347            "feat/9-issue"
348        );
349        // Dots next to separators must not leave doubled or edge dashes.
350        assert_eq!(
351            fallback(BranchKind::Chore, 1, "v1.2 . rollout...").to_string(),
352            "chore/1-v1-2-rollout"
353        );
354        // Non-ASCII drops out; case folds.
355        assert_eq!(
356            fallback(BranchKind::Docs, 5, "Café MENU docs").to_string(),
357            "docs/5-caf-menu-docs"
358        );
359    }
360
361    #[test]
362    fn fallback_always_satisfies_the_validator() {
363        let titles = [
364            "Add login",
365            "NULL owner crashes v1.2 API!",
366            "",
367            "!!!",
368            "---",
369            "v1.2.3",
370            ".hidden",
371            "UPPER_case and  spaces",
372            "中文 only",
373            "trailing dot.",
374            "a",
375        ];
376        for title in titles {
377            for kind in BranchKind::ALL {
378                let name = fallback(kind, 123, title);
379                let text = name.to_string();
380                let parsed = parse_and_validate(&text, 123)
381                    .unwrap_or_else(|e| panic!("{text:?} from {title:?}: {e}"));
382                assert_eq!(parsed, name);
383            }
384        }
385    }
386}