1use crate::config::{expand_placeholders, BranchType, WorktreeConfig};
2use crate::error::{GwmError, Result};
3use regex::Regex;
4use std::sync::LazyLock;
5
6static 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
25pub 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
42pub 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 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 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 pub fn validate(&self) -> Result<()> {
94 self.validate_against(&default_branch_types())
95 }
96
97 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 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
164pub 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 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}