use crate::config::{expand_placeholders, BranchType, WorktreeConfig};
use crate::error::{GwmError, Result};
use regex::Regex;
use std::sync::LazyLock;
static ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").expect("static ISSUE_RE compiles"));
static DESC_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("static DESC_RE compiles"));
static BRANCH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([a-z]+)/#(\d+)-([a-z0-9-]+)$").expect("static BRANCH_RE compiles"));
pub const BRANCH_TYPES: &[(&str, &str)] = &[
("feat", "New feature implementation"),
("fix", "Bug fix"),
("hotfix", "Critical production bug fix"),
("docs", "Documentation changes"),
("test", "Test additions or modifications"),
("refactor", "Code restructuring"),
("chore", "Maintenance tasks"),
("perf", "Performance improvements"),
("ci", "CI/CD configuration"),
("build", "Build system changes"),
];
pub fn default_branch_types() -> Vec<BranchType> {
BRANCH_TYPES
.iter()
.map(|(name, desc)| BranchType {
name: (*name).into(),
description: (*desc).into(),
})
.collect()
}
#[derive(Debug, Clone)]
pub struct BranchSpec {
pub type_: String,
pub issue: String,
pub desc: String,
}
impl BranchSpec {
pub fn new(type_: impl Into<String>, issue: impl Into<String>, desc: impl Into<String>) -> Result<Self> {
Self::new_with_types(type_, issue, desc, &default_branch_types())
}
pub fn new_with_types(
type_: impl Into<String>,
issue: impl Into<String>,
desc: impl Into<String>,
allowed: &[BranchType],
) -> Result<Self> {
let s = Self {
type_: type_.into(),
issue: issue.into(),
desc: kebab(&desc.into()),
};
s.validate_against(allowed)?;
Ok(s)
}
pub fn validate(&self) -> Result<()> {
self.validate_against(&default_branch_types())
}
pub fn validate_against(&self, allowed: &[BranchType]) -> Result<()> {
if !allowed.iter().any(|t| t.name == self.type_) {
let names = allowed.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
return Err(GwmError::InvalidBranchType {
got: self.type_.clone(),
allowed: names,
});
}
if !ISSUE_RE.is_match(&self.issue) {
return Err(GwmError::InvalidIssue(self.issue.clone()));
}
if !DESC_RE.is_match(&self.desc) {
return Err(GwmError::InvalidDescription(self.desc.clone()));
}
Ok(())
}
pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
expand_placeholders(
&cfg.branch_pattern,
repo,
Some(&self.type_),
Some(&self.issue),
Some(&self.desc),
None,
)
}
pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
expand_placeholders(
&cfg.path_pattern,
repo,
Some(&self.type_),
Some(&self.issue),
Some(&self.desc),
None,
)
}
pub fn worktree_path(
&self,
cfg: &WorktreeConfig,
repo: &str,
repo_path: &std::path::Path,
) -> Result<std::path::PathBuf> {
let base = expand_placeholders(
&cfg.base,
repo,
Some(&self.type_),
Some(&self.issue),
Some(&self.desc),
Some(repo_path),
)?;
let dir = self.worktree_dirname(cfg, repo)?;
Ok(std::path::PathBuf::from(base).join(dir))
}
}
pub fn parse_branch(branch: &str) -> Option<BranchSpec> {
let cap = BRANCH_RE.captures(branch)?;
Some(BranchSpec {
type_: cap.get(1)?.as_str().to_string(),
issue: cap.get(2)?.as_str().to_string(),
desc: cap.get(3)?.as_str().to_string(),
})
}
pub fn kebab(input: &str) -> String {
let lower = input.to_lowercase();
let mut out = String::with_capacity(lower.len());
let mut prev_dash = false;
for c in lower.chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_string()
}