Skip to main content

gwm/
naming.rs

1use crate::config::{expand_placeholders, BranchType, WorktreeConfig};
2use crate::error::{GwmError, Result};
3use regex::Regex;
4use std::sync::LazyLock;
5
6/// Compile-time literal regexes lifted to module statics so each branch
7/// validation / parse runs at ~50ns instead of recompiling the pattern
8/// per call (issue #97). `LazyLock::new` defers the work until the
9/// first access; `expect` is acceptable here because the input is a
10/// hard-coded literal — a regex-compile failure would be a developer
11/// bug caught by the test suite at first use, not a user-facing error
12/// path the CLAUDE.md "no unwrap on user paths" rule targets.
13///
14/// `ISSUE_RE` pins the digits-only contract on issue numbers (no
15/// scientific notation, no hex, no leading zeros stripped). `DESC_RE`
16/// matches the post-`kebab` shape — leading alphanumeric, then a tail
17/// of alphanumeric / dash. `BRANCH_RE` captures the three segments of
18/// a gwm-style branch (`<type>/#<issue>-<desc>`) in one pass.
19static ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").expect("static ISSUE_RE compiles"));
20static DESC_RE: LazyLock<Regex> =
21  LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("static DESC_RE compiles"));
22static BRANCH_RE: LazyLock<Regex> =
23  LazyLock::new(|| Regex::new(r"^([a-z]+)/#(\d+)-([a-z0-9-]+)$").expect("static BRANCH_RE compiles"));
24
25/// Built-in branch types — the fallback when `.gwm.toml` carries no
26/// `[[branch_types]]` block. Kept as a `&[(&str, &str)]` const so the
27/// static string table stays compile-time and zero-alloc; the runtime
28/// view is materialised on demand via [`default_branch_types`].
29pub const BRANCH_TYPES: &[(&str, &str)] = &[
30  ("feat", "New feature implementation"),
31  ("fix", "Bug fix"),
32  ("hotfix", "Critical production bug fix"),
33  ("docs", "Documentation changes"),
34  ("test", "Test additions or modifications"),
35  ("refactor", "Code restructuring"),
36  ("chore", "Maintenance tasks"),
37  ("perf", "Performance improvements"),
38  ("ci", "CI/CD configuration"),
39  ("build", "Build system changes"),
40];
41
42/// Runtime view of [`BRANCH_TYPES`] as a `Vec<BranchType>`. Used by
43/// [`crate::config::Config::resolved_branch_types`] when no override
44/// is configured, and by [`BranchSpec::validate`] / [`BranchSpec::new`]
45/// to keep the legacy "no config = built-in defaults" contract.
46pub fn default_branch_types() -> Vec<BranchType> {
47  BRANCH_TYPES
48    .iter()
49    .map(|(name, desc)| BranchType {
50      name: (*name).into(),
51      description: (*desc).into(),
52    })
53    .collect()
54}
55
56#[derive(Debug, Clone)]
57pub struct BranchSpec {
58  pub type_: String,
59  pub issue: String,
60  pub desc: String,
61}
62
63impl BranchSpec {
64  /// Construct a [`BranchSpec`] validated against the built-in branch
65  /// types. Kept for callers (tests, internal helpers) that don't have
66  /// a [`crate::config::Config`] in scope; production code paths
67  /// (`gwm create`, TUI create) should use [`Self::new_with_types`]
68  /// with the resolved list so per-repo overrides are honoured.
69  pub fn new(type_: impl Into<String>, issue: impl Into<String>, desc: impl Into<String>) -> Result<Self> {
70    Self::new_with_types(type_, issue, desc, &default_branch_types())
71  }
72
73  /// Construct a [`BranchSpec`] validated against the supplied list of
74  /// allowed branch types — typically the output of
75  /// [`crate::config::Config::resolved_branch_types`].
76  pub fn new_with_types(
77    type_: impl Into<String>,
78    issue: impl Into<String>,
79    desc: impl Into<String>,
80    allowed: &[BranchType],
81  ) -> Result<Self> {
82    let s = Self {
83      type_: type_.into(),
84      issue: issue.into(),
85      desc: kebab(&desc.into()),
86    };
87    s.validate_against(allowed)?;
88    Ok(s)
89  }
90
91  /// Validate against the built-in branch types. Convenience wrapper
92  /// around [`Self::validate_against`] for legacy call sites.
93  pub fn validate(&self) -> Result<()> {
94    self.validate_against(&default_branch_types())
95  }
96
97  /// Validate against the supplied list of allowed branch types. The
98  /// error message produced when the type is rejected enumerates the
99  /// allowed names so the TUI status bar / CLI stderr always shows the
100  /// repo-local truth (built-in or `.gwm.toml`-driven).
101  pub fn validate_against(&self, allowed: &[BranchType]) -> Result<()> {
102    if !allowed.iter().any(|t| t.name == self.type_) {
103      let names = allowed.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
104      return Err(GwmError::InvalidBranchType {
105        got: self.type_.clone(),
106        allowed: names,
107      });
108    }
109    if !ISSUE_RE.is_match(&self.issue) {
110      return Err(GwmError::InvalidIssue(self.issue.clone()));
111    }
112    if !DESC_RE.is_match(&self.desc) {
113      return Err(GwmError::InvalidDescription(self.desc.clone()));
114    }
115    Ok(())
116  }
117
118  pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
119    expand_placeholders(
120      &cfg.branch_pattern,
121      repo,
122      Some(&self.type_),
123      Some(&self.issue),
124      Some(&self.desc),
125      None,
126    )
127  }
128
129  pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
130    expand_placeholders(
131      &cfg.path_pattern,
132      repo,
133      Some(&self.type_),
134      Some(&self.issue),
135      Some(&self.desc),
136      None,
137    )
138  }
139
140  /// Resolve the absolute worktree path for this spec. `repo_path` is the
141  /// main repo's working directory on disk — it feeds the `{repo_path}` /
142  /// `{repo_parent}` placeholders so a `base` like `{repo_parent}/worktrees`
143  /// can be expressed relative to the repo (matching, e.g., an editor's
144  /// `../worktrees` convention).
145  pub fn worktree_path(
146    &self,
147    cfg: &WorktreeConfig,
148    repo: &str,
149    repo_path: &std::path::Path,
150  ) -> Result<std::path::PathBuf> {
151    let base = expand_placeholders(
152      &cfg.base,
153      repo,
154      Some(&self.type_),
155      Some(&self.issue),
156      Some(&self.desc),
157      Some(repo_path),
158    )?;
159    let dir = self.worktree_dirname(cfg, repo)?;
160    Ok(std::path::PathBuf::from(base).join(dir))
161  }
162}
163
164/// Try to recover a BranchSpec from a free-form branch name like `feat/#123-my-desc`.
165pub fn parse_branch(branch: &str) -> Option<BranchSpec> {
166  let cap = BRANCH_RE.captures(branch)?;
167  Some(BranchSpec {
168    type_: cap.get(1)?.as_str().to_string(),
169    issue: cap.get(2)?.as_str().to_string(),
170    desc: cap.get(3)?.as_str().to_string(),
171  })
172}
173
174pub fn kebab(input: &str) -> String {
175  // Lowercase, then collapse every non-alphanumeric run into a single `-`.
176  let lower = input.to_lowercase();
177  let mut out = String::with_capacity(lower.len());
178  let mut prev_dash = false;
179  for c in lower.chars() {
180    if c.is_ascii_alphanumeric() {
181      out.push(c);
182      prev_dash = false;
183    } else if !prev_dash && !out.is_empty() {
184      out.push('-');
185      prev_dash = true;
186    }
187  }
188  out.trim_matches('-').to_string()
189}