const MAX_SLUG_LEN: usize = 50;
pub(crate) fn slugify(title: &str) -> String {
let mut slug = String::with_capacity(title.len());
let mut prev_dash = false;
for ch in title.chars() {
if ch.is_alphanumeric() {
slug.extend(ch.to_lowercase());
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
let trimmed = slug.trim_matches('-');
if trimmed.is_empty() {
return "issue".to_string();
}
if trimmed.len() <= MAX_SLUG_LEN {
return trimmed.to_string();
}
let mut end = MAX_SLUG_LEN;
while end > 0 && !trimmed.is_char_boundary(end) {
end -= 1;
}
let cut = &trimmed[..end];
match cut.rfind('-') {
Some(idx) if idx > 0 => cut[..idx].to_string(),
_ => cut.trim_end_matches('-').to_string(),
}
}
pub(crate) fn issue_type(labels: &[String]) -> &'static str {
for label in labels {
let l = label.to_lowercase();
let mapped = match l.as_str() {
"bug" | "fix" | "bugfix" => Some("fix"),
"enhancement" | "feature" | "feat" => Some("feat"),
"docs" | "documentation" => Some("docs"),
"refactor" | "refactoring" => Some("refactor"),
"chore" => Some("chore"),
"test" | "tests" | "testing" => Some("test"),
"perf" | "performance" => Some("perf"),
_ => None,
};
if let Some(t) = mapped {
return t;
}
}
"feat"
}
pub(crate) fn derive_branch_name(issue_number: u64, title: &str, labels: &[String]) -> String {
format!("{}/{}-{}", issue_type(labels), issue_number, slugify(title))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_basic() {
assert_eq!(slugify("Add the thing"), "add-the-thing");
}
#[test]
fn slugify_collapses_punctuation() {
assert_eq!(
slugify("feat(trusty-mpm): one-shot workflow!!"),
"feat-trusty-mpm-one-shot-workflow"
);
}
#[test]
fn slugify_truncates_long_title() {
let title =
"this is a very long issue title that should be truncated at a word boundary somewhere";
let slug = slugify(title);
assert!(slug.len() <= MAX_SLUG_LEN, "slug too long: {slug}");
assert!(!slug.ends_with('-'));
let full = slugify(title);
assert!(
full.starts_with(&slug),
"slug `{slug}` is not a prefix of full slug `{full}`"
);
}
#[test]
fn slugify_empty_falls_back() {
assert_eq!(slugify(""), "issue");
assert_eq!(slugify("!!!"), "issue");
}
#[test]
fn slugify_preserves_accented_latin() {
let slug = slugify("Café déjà vu");
assert_ne!(slug, "issue", "accented title collapsed to fallback");
assert_eq!(slug, "café-déjà-vu");
}
#[test]
fn slugify_preserves_cjk() {
let slug = slugify("課題を修正する");
assert_ne!(slug, "issue", "CJK title collapsed to fallback");
assert_eq!(slug, "課題を修正する");
}
#[test]
fn slugify_truncates_multibyte_safely() {
let title = "あ".repeat(60);
let slug = slugify(&title);
assert!(slug.len() <= MAX_SLUG_LEN, "slug too long: {slug}");
assert!(!slug.is_empty());
}
#[test]
fn issue_type_bug() {
assert_eq!(issue_type(&["bug".to_string()]), "fix");
}
#[test]
fn issue_type_enhancement() {
assert_eq!(issue_type(&["enhancement".to_string()]), "feat");
}
#[test]
fn issue_type_docs() {
assert_eq!(issue_type(&["documentation".to_string()]), "docs");
}
#[test]
fn issue_type_default_feat() {
assert_eq!(issue_type(&["wontfix".to_string()]), "feat");
assert_eq!(issue_type(&[]), "feat");
}
#[test]
fn issue_type_case_insensitive() {
assert_eq!(issue_type(&["BUG".to_string()]), "fix");
assert_eq!(issue_type(&["Refactor".to_string()]), "refactor");
}
#[test]
fn issue_type_first_match_wins() {
let labels = vec!["needs-triage".to_string(), "bug".to_string()];
assert_eq!(issue_type(&labels), "fix");
}
#[test]
fn derive_branch_name_basic() {
assert_eq!(
derive_branch_name(1232, "Add the thing", &[]),
"feat/1232-add-the-thing"
);
}
#[test]
fn derive_branch_name_uses_label_type() {
assert_eq!(
derive_branch_name(7, "Fix the crash", &["bug".to_string()]),
"fix/7-fix-the-crash"
);
}
}