1use std::fmt;
15
16use crate::error::{Error, Result};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum BranchKind {
21 Feat,
23 Fix,
25 Docs,
27 Refactor,
29 Test,
31 Build,
33 Ci,
35 Perf,
37 Chore,
39}
40
41impl BranchKind {
42 pub const ALL: [BranchKind; 9] = [
44 BranchKind::Feat,
45 BranchKind::Fix,
46 BranchKind::Docs,
47 BranchKind::Refactor,
48 BranchKind::Test,
49 BranchKind::Build,
50 BranchKind::Ci,
51 BranchKind::Perf,
52 BranchKind::Chore,
53 ];
54
55 pub fn as_str(self) -> &'static str {
57 match self {
58 BranchKind::Feat => "feat",
59 BranchKind::Fix => "fix",
60 BranchKind::Docs => "docs",
61 BranchKind::Refactor => "refactor",
62 BranchKind::Test => "test",
63 BranchKind::Build => "build",
64 BranchKind::Ci => "ci",
65 BranchKind::Perf => "perf",
66 BranchKind::Chore => "chore",
67 }
68 }
69
70 pub fn parse(text: &str) -> Option<BranchKind> {
72 BranchKind::ALL.into_iter().find(|k| k.as_str() == text)
73 }
74
75 pub fn from_label(label: &str) -> Option<BranchKind> {
81 let lower = label.to_ascii_lowercase();
82 if let Some(kind) = BranchKind::parse(&lower) {
83 return Some(kind);
84 }
85 match lower.as_str() {
86 "bug" => Some(BranchKind::Fix),
87 "enhancement" | "feature" => Some(BranchKind::Feat),
88 "documentation" => Some(BranchKind::Docs),
89 "performance" => Some(BranchKind::Perf),
90 "tests" => Some(BranchKind::Test),
91 _ => None,
92 }
93 }
94}
95
96impl fmt::Display for BranchKind {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str(self.as_str())
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct BranchName {
105 kind: BranchKind,
106 number: u64,
107 slug: String,
108}
109
110impl BranchName {
111 pub fn kind(&self) -> BranchKind {
113 self.kind
114 }
115
116 pub fn number(&self) -> u64 {
118 self.number
119 }
120
121 pub fn slug(&self) -> &str {
123 &self.slug
124 }
125}
126
127impl fmt::Display for BranchName {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 write!(f, "{}/{}-{}", self.kind, self.number, self.slug)
130 }
131}
132
133fn kind_list() -> String {
135 BranchKind::ALL
136 .iter()
137 .map(|k| k.as_str())
138 .collect::<Vec<_>>()
139 .join(", ")
140}
141
142pub fn branch_rule(issue_number: u64) -> String {
146 format!(
147 "Choose a branch in the exact form TYPE/{issue_number}-SLUG. TYPE must be one of {}. SLUG must be lowercase kebab-case.",
148 kind_list()
149 )
150}
151
152fn is_valid_slug(slug: &str) -> bool {
155 !slug.is_empty()
156 && !slug.starts_with('-')
157 && !slug.ends_with('-')
158 && !slug.contains("--")
159 && slug
160 .bytes()
161 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
162}
163
164pub fn parse_and_validate(generated: &str, issue_number: u64) -> Result<BranchName> {
169 crate::git::validate_branch_name(generated).map_err(Error::usage)?;
170 let (kind, suffix) = generated
171 .split_once('/')
172 .ok_or_else(|| Error::usage("generated branch must have a conventional type prefix"))?;
173 let kind = BranchKind::parse(kind)
174 .ok_or_else(|| Error::usage(format!("generated branch type {kind:?} is not supported")))?;
175 let prefix = format!("{issue_number}-");
176 let slug = suffix
177 .strip_prefix(&prefix)
178 .ok_or_else(|| Error::usage(format!("generated branch must contain {prefix:?}")))?;
179 if !is_valid_slug(slug) {
180 return Err(Error::usage(
181 "generated branch slug must be non-empty lowercase kebab-case",
182 ));
183 }
184 Ok(BranchName {
185 kind,
186 number: issue_number,
187 slug: slug.to_string(),
188 })
189}
190
191pub fn fallback(kind: BranchKind, issue_number: u64, title: &str) -> BranchName {
197 let mut slug = crate::slug::slugify(&title.to_lowercase()).replace('.', "-");
200 while slug.contains("--") {
201 slug = slug.replace("--", "-");
202 }
203 let slug = slug.trim_matches('-');
204 let slug = if slug.is_empty() { "issue" } else { slug };
205 BranchName {
206 kind,
207 number: issue_number,
208 slug: slug.to_string(),
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn kind_parse_display_round_trip() {
218 for kind in BranchKind::ALL {
219 assert_eq!(BranchKind::parse(kind.as_str()), Some(kind));
220 assert_eq!(kind.to_string(), kind.as_str());
221 }
222 assert_eq!(BranchKind::parse("feature"), None);
223 assert_eq!(BranchKind::parse("FEAT"), None);
224 assert_eq!(BranchKind::parse(""), None);
225 }
226
227 #[test]
228 fn kind_from_label_maps_common_hints() {
229 assert_eq!(BranchKind::from_label("bug"), Some(BranchKind::Fix));
230 assert_eq!(BranchKind::from_label("Bug"), Some(BranchKind::Fix));
231 assert_eq!(
232 BranchKind::from_label("enhancement"),
233 Some(BranchKind::Feat)
234 );
235 assert_eq!(BranchKind::from_label("Feature"), Some(BranchKind::Feat));
236 assert_eq!(
237 BranchKind::from_label("documentation"),
238 Some(BranchKind::Docs)
239 );
240 assert_eq!(
241 BranchKind::from_label("performance"),
242 Some(BranchKind::Perf)
243 );
244 assert_eq!(BranchKind::from_label("tests"), Some(BranchKind::Test));
245 assert_eq!(BranchKind::from_label("chore"), Some(BranchKind::Chore));
247 assert_eq!(BranchKind::from_label("help wanted"), None);
249 }
250
251 #[test]
252 fn branch_rule_names_every_kind_and_the_number() {
253 let rule = branch_rule(42);
254 assert!(rule.contains("TYPE/42-SLUG"));
255 for kind in BranchKind::ALL {
256 assert!(rule.contains(kind.as_str()), "missing {kind}");
257 }
258 assert!(rule.contains("lowercase kebab-case"));
259 }
260
261 #[test]
262 fn valid_branch_parses_into_parts() {
263 let name = parse_and_validate("feat/12-add-login", 12).unwrap();
264 assert_eq!(name.kind(), BranchKind::Feat);
265 assert_eq!(name.number(), 12);
266 assert_eq!(name.slug(), "add-login");
267 assert_eq!(name.to_string(), "feat/12-add-login");
268 }
269
270 #[test]
271 fn every_kind_is_accepted() {
272 for kind in BranchKind::ALL {
273 let text = format!("{kind}/7-x");
274 assert_eq!(parse_and_validate(&text, 7).unwrap().kind(), kind);
275 }
276 }
277
278 #[test]
279 fn digits_and_single_dashes_are_a_valid_slug() {
280 let name = parse_and_validate("fix/3-v2-api-404s", 3).unwrap();
281 assert_eq!(name.slug(), "v2-api-404s");
282 }
283
284 #[test]
286 fn invalid_branches_are_rejected_with_the_rule() {
287 let cases: &[(&str, u64, &str)] = &[
288 ("feat/12-a..b", 12, "invalid branch name"),
290 ("feat/12-a b", 12, "invalid branch name"),
291 ("feat/12-x.lock", 12, "invalid branch name"),
292 ("12-add-login", 12, "conventional type prefix"),
294 ("feature/12-add-login", 12, "not supported"),
296 ("FEAT/12-add-login", 12, "not supported"),
297 ("feat/12-a/b", 12, "kebab-case"),
299 ("feat/13-add-login", 12, "\"12-\""),
301 ("feat/add-login", 12, "\"12-\""),
302 ("feat/12-", 12, "kebab-case"),
304 ("feat/12-Add-Login", 12, "kebab-case"),
305 ("feat/12-add_login", 12, "kebab-case"),
306 ("feat/12-add--login", 12, "kebab-case"),
307 ("feat/12-add-login-", 12, "kebab-case"),
308 ];
309 for (text, number, expect) in cases {
310 let err = parse_and_validate(text, *number).unwrap_err();
311 assert!(matches!(err, Error::Usage(_)), "{text}: {err:?}");
312 assert!(
313 err.to_string().contains(expect),
314 "{text}: {err} (expected {expect:?})"
315 );
316 }
317 }
318
319 #[test]
320 fn leading_dash_slug_is_rejected() {
321 let err = parse_and_validate("feat/12--x", 12).unwrap_err();
324 assert!(err.to_string().contains("kebab-case"));
325 }
326
327 #[test]
328 fn fallback_is_deterministic_and_normalizes_titles() {
329 let name = fallback(BranchKind::Fix, 482, "NULL owner crashes v1.2 API!");
330 assert_eq!(name.to_string(), "fix/482-null-owner-crashes-v1-2-api");
331 assert_eq!(
333 fallback(BranchKind::Fix, 482, "NULL owner crashes v1.2 API!"),
334 name
335 );
336 }
337
338 #[test]
339 fn fallback_survives_hostile_titles() {
340 assert_eq!(
342 fallback(BranchKind::Feat, 9, "!!!").to_string(),
343 "feat/9-issue"
344 );
345 assert_eq!(
346 fallback(BranchKind::Feat, 9, "").to_string(),
347 "feat/9-issue"
348 );
349 assert_eq!(
351 fallback(BranchKind::Chore, 1, "v1.2 . rollout...").to_string(),
352 "chore/1-v1-2-rollout"
353 );
354 assert_eq!(
356 fallback(BranchKind::Docs, 5, "Café MENU docs").to_string(),
357 "docs/5-caf-menu-docs"
358 );
359 }
360
361 #[test]
362 fn fallback_always_satisfies_the_validator() {
363 let titles = [
364 "Add login",
365 "NULL owner crashes v1.2 API!",
366 "",
367 "!!!",
368 "---",
369 "v1.2.3",
370 ".hidden",
371 "UPPER_case and spaces",
372 "中文 only",
373 "trailing dot.",
374 "a",
375 ];
376 for title in titles {
377 for kind in BranchKind::ALL {
378 let name = fallback(kind, 123, title);
379 let text = name.to_string();
380 let parsed = parse_and_validate(&text, 123)
381 .unwrap_or_else(|e| panic!("{text:?} from {title:?}: {e}"));
382 assert_eq!(parsed, name);
383 }
384 }
385 }
386}