use std::fmt;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BranchKind {
Feat,
Fix,
Docs,
Refactor,
Test,
Build,
Ci,
Perf,
Chore,
}
impl BranchKind {
pub const ALL: [BranchKind; 9] = [
BranchKind::Feat,
BranchKind::Fix,
BranchKind::Docs,
BranchKind::Refactor,
BranchKind::Test,
BranchKind::Build,
BranchKind::Ci,
BranchKind::Perf,
BranchKind::Chore,
];
pub fn as_str(self) -> &'static str {
match self {
BranchKind::Feat => "feat",
BranchKind::Fix => "fix",
BranchKind::Docs => "docs",
BranchKind::Refactor => "refactor",
BranchKind::Test => "test",
BranchKind::Build => "build",
BranchKind::Ci => "ci",
BranchKind::Perf => "perf",
BranchKind::Chore => "chore",
}
}
pub fn parse(text: &str) -> Option<BranchKind> {
BranchKind::ALL.into_iter().find(|k| k.as_str() == text)
}
pub fn from_label(label: &str) -> Option<BranchKind> {
let lower = label.to_ascii_lowercase();
if let Some(kind) = BranchKind::parse(&lower) {
return Some(kind);
}
match lower.as_str() {
"bug" => Some(BranchKind::Fix),
"enhancement" | "feature" => Some(BranchKind::Feat),
"documentation" => Some(BranchKind::Docs),
"performance" => Some(BranchKind::Perf),
"tests" => Some(BranchKind::Test),
_ => None,
}
}
}
impl fmt::Display for BranchKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchName {
kind: BranchKind,
number: u64,
slug: String,
}
impl BranchName {
pub fn kind(&self) -> BranchKind {
self.kind
}
pub fn number(&self) -> u64 {
self.number
}
pub fn slug(&self) -> &str {
&self.slug
}
}
impl fmt::Display for BranchName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}-{}", self.kind, self.number, self.slug)
}
}
fn kind_list() -> String {
BranchKind::ALL
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub fn branch_rule(issue_number: u64) -> String {
format!(
"Choose a branch in the exact form TYPE/{issue_number}-SLUG. TYPE must be one of {}. SLUG must be lowercase kebab-case.",
kind_list()
)
}
fn is_valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& !slug.starts_with('-')
&& !slug.ends_with('-')
&& !slug.contains("--")
&& slug
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
pub fn parse_and_validate(generated: &str, issue_number: u64) -> Result<BranchName> {
crate::git::validate_branch_name(generated).map_err(Error::usage)?;
let (kind, suffix) = generated
.split_once('/')
.ok_or_else(|| Error::usage("generated branch must have a conventional type prefix"))?;
let kind = BranchKind::parse(kind)
.ok_or_else(|| Error::usage(format!("generated branch type {kind:?} is not supported")))?;
let prefix = format!("{issue_number}-");
let slug = suffix
.strip_prefix(&prefix)
.ok_or_else(|| Error::usage(format!("generated branch must contain {prefix:?}")))?;
if !is_valid_slug(slug) {
return Err(Error::usage(
"generated branch slug must be non-empty lowercase kebab-case",
));
}
Ok(BranchName {
kind,
number: issue_number,
slug: slug.to_string(),
})
}
pub fn fallback(kind: BranchKind, issue_number: u64, title: &str) -> BranchName {
let mut slug = crate::slug::slugify(&title.to_lowercase()).replace('.', "-");
while slug.contains("--") {
slug = slug.replace("--", "-");
}
let slug = slug.trim_matches('-');
let slug = if slug.is_empty() { "issue" } else { slug };
BranchName {
kind,
number: issue_number,
slug: slug.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_parse_display_round_trip() {
for kind in BranchKind::ALL {
assert_eq!(BranchKind::parse(kind.as_str()), Some(kind));
assert_eq!(kind.to_string(), kind.as_str());
}
assert_eq!(BranchKind::parse("feature"), None);
assert_eq!(BranchKind::parse("FEAT"), None);
assert_eq!(BranchKind::parse(""), None);
}
#[test]
fn kind_from_label_maps_common_hints() {
assert_eq!(BranchKind::from_label("bug"), Some(BranchKind::Fix));
assert_eq!(BranchKind::from_label("Bug"), Some(BranchKind::Fix));
assert_eq!(
BranchKind::from_label("enhancement"),
Some(BranchKind::Feat)
);
assert_eq!(BranchKind::from_label("Feature"), Some(BranchKind::Feat));
assert_eq!(
BranchKind::from_label("documentation"),
Some(BranchKind::Docs)
);
assert_eq!(
BranchKind::from_label("performance"),
Some(BranchKind::Perf)
);
assert_eq!(BranchKind::from_label("tests"), Some(BranchKind::Test));
assert_eq!(BranchKind::from_label("chore"), Some(BranchKind::Chore));
assert_eq!(BranchKind::from_label("help wanted"), None);
}
#[test]
fn branch_rule_names_every_kind_and_the_number() {
let rule = branch_rule(42);
assert!(rule.contains("TYPE/42-SLUG"));
for kind in BranchKind::ALL {
assert!(rule.contains(kind.as_str()), "missing {kind}");
}
assert!(rule.contains("lowercase kebab-case"));
}
#[test]
fn valid_branch_parses_into_parts() {
let name = parse_and_validate("feat/12-add-login", 12).unwrap();
assert_eq!(name.kind(), BranchKind::Feat);
assert_eq!(name.number(), 12);
assert_eq!(name.slug(), "add-login");
assert_eq!(name.to_string(), "feat/12-add-login");
}
#[test]
fn every_kind_is_accepted() {
for kind in BranchKind::ALL {
let text = format!("{kind}/7-x");
assert_eq!(parse_and_validate(&text, 7).unwrap().kind(), kind);
}
}
#[test]
fn digits_and_single_dashes_are_a_valid_slug() {
let name = parse_and_validate("fix/3-v2-api-404s", 3).unwrap();
assert_eq!(name.slug(), "v2-api-404s");
}
#[test]
fn invalid_branches_are_rejected_with_the_rule() {
let cases: &[(&str, u64, &str)] = &[
("feat/12-a..b", 12, "invalid branch name"),
("feat/12-a b", 12, "invalid branch name"),
("feat/12-x.lock", 12, "invalid branch name"),
("12-add-login", 12, "conventional type prefix"),
("feature/12-add-login", 12, "not supported"),
("FEAT/12-add-login", 12, "not supported"),
("feat/12-a/b", 12, "kebab-case"),
("feat/13-add-login", 12, "\"12-\""),
("feat/add-login", 12, "\"12-\""),
("feat/12-", 12, "kebab-case"),
("feat/12-Add-Login", 12, "kebab-case"),
("feat/12-add_login", 12, "kebab-case"),
("feat/12-add--login", 12, "kebab-case"),
("feat/12-add-login-", 12, "kebab-case"),
];
for (text, number, expect) in cases {
let err = parse_and_validate(text, *number).unwrap_err();
assert!(matches!(err, Error::Usage(_)), "{text}: {err:?}");
assert!(
err.to_string().contains(expect),
"{text}: {err} (expected {expect:?})"
);
}
}
#[test]
fn leading_dash_slug_is_rejected() {
let err = parse_and_validate("feat/12--x", 12).unwrap_err();
assert!(err.to_string().contains("kebab-case"));
}
#[test]
fn fallback_is_deterministic_and_normalizes_titles() {
let name = fallback(BranchKind::Fix, 482, "NULL owner crashes v1.2 API!");
assert_eq!(name.to_string(), "fix/482-null-owner-crashes-v1-2-api");
assert_eq!(
fallback(BranchKind::Fix, 482, "NULL owner crashes v1.2 API!"),
name
);
}
#[test]
fn fallback_survives_hostile_titles() {
assert_eq!(
fallback(BranchKind::Feat, 9, "!!!").to_string(),
"feat/9-issue"
);
assert_eq!(
fallback(BranchKind::Feat, 9, "").to_string(),
"feat/9-issue"
);
assert_eq!(
fallback(BranchKind::Chore, 1, "v1.2 . rollout...").to_string(),
"chore/1-v1-2-rollout"
);
assert_eq!(
fallback(BranchKind::Docs, 5, "Café MENU docs").to_string(),
"docs/5-caf-menu-docs"
);
}
#[test]
fn fallback_always_satisfies_the_validator() {
let titles = [
"Add login",
"NULL owner crashes v1.2 API!",
"",
"!!!",
"---",
"v1.2.3",
".hidden",
"UPPER_case and spaces",
"中文 only",
"trailing dot.",
"a",
];
for title in titles {
for kind in BranchKind::ALL {
let name = fallback(kind, 123, title);
let text = name.to_string();
let parsed = parse_and_validate(&text, 123)
.unwrap_or_else(|e| panic!("{text:?} from {title:?}: {e}"));
assert_eq!(parsed, name);
}
}
}
}