omni_dev/claude/context/
branch.rs1use std::str::FromStr;
4use std::sync::LazyLock;
5
6use anyhow::Result;
7use regex::Regex;
8
9use crate::data::context::{BranchContext, WorkType};
10
11const GITHUB_FLOW_MAX_BRANCH_LEN: usize = 50;
13
14pub struct BranchAnalyzer;
16
17impl BranchAnalyzer {
18 pub fn analyze(branch_name: &str) -> Result<BranchContext> {
20 let mut context = BranchContext::default();
21
22 if let Some(captures) = STANDARD_BRANCH_PATTERN.captures(branch_name) {
24 context.work_type = captures
26 .name("type")
27 .map(|m| WorkType::from_str(m.as_str()))
28 .transpose()?
29 .unwrap_or(WorkType::Unknown);
30
31 context.scope = captures.name("scope").map(|m| m.as_str().to_string());
32
33 context.description = captures
34 .name("desc")
35 .map(|m| m.as_str().replace(['-', '_'], " "))
36 .unwrap_or_default();
37 } else if let Some(captures) = TICKET_BRANCH_PATTERN.captures(branch_name) {
38 context.ticket_id = captures.name("ticket").map(|m| m.as_str().to_string());
40 context.description = captures
41 .name("desc")
42 .map(|m| m.as_str().replace(['-', '_'], " "))
43 .unwrap_or_default();
44
45 context.work_type = infer_work_type_from_description(&context.description);
47 } else if let Some(captures) = USER_BRANCH_PATTERN.captures(branch_name) {
48 context.description = captures
50 .name("desc")
51 .map(|m| m.as_str().replace(['-', '_'], " "))
52 .unwrap_or_default();
53
54 context.work_type = infer_work_type_from_description(&context.description);
55 } else {
56 context.description = branch_name.replace(['-', '_'], " ");
58 context.work_type = infer_work_type_from_description(&context.description);
59 }
60
61 context.ticket_id = context
63 .ticket_id
64 .or_else(|| extract_ticket_references(branch_name));
65
66 context.is_feature_branch = matches!(
68 context.work_type,
69 WorkType::Feature | WorkType::Fix | WorkType::Refactor
70 );
71
72 context.description = clean_description(&context.description);
74
75 Ok(context)
76 }
77
78 pub fn analyze_branching_strategy(branches: &[String]) -> BranchingStrategy {
80 let mut has_gitflow = false;
81 let mut has_github_flow = false;
82 let mut has_conventional = false;
83
84 for branch in branches {
85 if branch.starts_with("feature/")
86 || branch.starts_with("release/")
87 || branch.starts_with("hotfix/")
88 {
89 has_gitflow = true;
90 }
91 if branch.contains("feat/") || branch.contains("fix/") || branch.contains("docs/") {
92 has_conventional = true;
93 }
94 if branch.len() < GITHUB_FLOW_MAX_BRANCH_LEN && !branch.contains('/') {
95 has_github_flow = true;
96 }
97 }
98
99 if has_gitflow {
100 BranchingStrategy::GitFlow
101 } else if has_conventional {
102 BranchingStrategy::ConventionalCommits
103 } else if has_github_flow {
104 BranchingStrategy::GitHubFlow
105 } else {
106 BranchingStrategy::Unknown
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
113pub enum BranchingStrategy {
114 GitFlow,
116 GitHubFlow,
118 ConventionalCommits,
120 Unknown,
122}
123
124#[allow(clippy::unwrap_used)] static STANDARD_BRANCH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
127 Regex::new(r"^(?P<type>feature|feat|fix|bugfix|docs?|refactor|chore|test|ci|build|perf|hotfix|release)/(?:(?P<scope>[^/]+)/)?(?P<desc>.+)$").unwrap()
128});
129
130#[allow(clippy::unwrap_used)] static TICKET_BRANCH_PATTERN: LazyLock<Regex> =
132 LazyLock::new(|| Regex::new(r"^(?P<ticket>[A-Z]+-\d+|issue-\d+|#\d+)-(?P<desc>.+)$").unwrap());
133
134#[allow(clippy::unwrap_used)] static USER_BRANCH_PATTERN: LazyLock<Regex> =
136 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+/(?P<desc>.+)$").unwrap());
137
138#[allow(clippy::unwrap_used)] static TICKET_REFERENCE_PATTERN: LazyLock<Regex> =
140 LazyLock::new(|| Regex::new(r"([A-Z]+-\d+|#\d+|issue-\d+)").unwrap());
141
142fn extract_ticket_references(branch_name: &str) -> Option<String> {
144 TICKET_REFERENCE_PATTERN
145 .find(branch_name)
146 .map(|m| m.as_str().to_string())
147}
148
149fn infer_work_type_from_description(description: &str) -> WorkType {
151 let desc_lower = description.to_lowercase();
152
153 if desc_lower.contains("fix") || desc_lower.contains("bug") || desc_lower.contains("issue") {
154 WorkType::Fix
155 } else if desc_lower.contains("doc") || desc_lower.contains("readme") {
156 WorkType::Docs
157 } else if desc_lower.contains("test") {
158 WorkType::Test
159 } else if desc_lower.contains("refactor") || desc_lower.contains("cleanup") {
160 WorkType::Refactor
161 } else if desc_lower.contains("build") || desc_lower.contains("config") {
162 WorkType::Build
163 } else if desc_lower.contains("ci") || desc_lower.contains("workflow") {
164 WorkType::Ci
165 } else if desc_lower.contains("perf") || desc_lower.contains("performance") {
166 WorkType::Perf
167 } else if desc_lower.contains("chore") || desc_lower.contains("maintenance") {
168 WorkType::Chore
169 } else {
170 WorkType::Feature
172 }
173}
174
175fn clean_description(description: &str) -> String {
177 let mut cleaned = description.trim().to_string();
178
179 let prefixes = [
181 "add ",
182 "implement ",
183 "create ",
184 "update ",
185 "fix ",
186 "remove ",
187 ];
188 for prefix in &prefixes {
189 if cleaned.to_lowercase().starts_with(prefix) {
190 cleaned = cleaned[prefix.len()..].to_string();
191 break;
192 }
193 }
194
195 if let Some(first_char) = cleaned.chars().next() {
197 cleaned = first_char.to_uppercase().collect::<String>() + &cleaned[first_char.len_utf8()..];
198 }
199
200 cleaned
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::data::context::WorkType;
207
208 #[test]
211 fn standard_branch_feat_with_scope() -> anyhow::Result<()> {
212 let ctx = BranchAnalyzer::analyze("feat/auth/add-login")?;
213 assert!(matches!(ctx.work_type, WorkType::Feature));
214 assert_eq!(ctx.scope, Some("auth".to_string()));
215 assert!(ctx.is_feature_branch);
216 Ok(())
217 }
218
219 #[test]
220 fn standard_branch_fix_no_scope() -> anyhow::Result<()> {
221 let ctx = BranchAnalyzer::analyze("fix/crash-on-startup")?;
222 assert!(matches!(ctx.work_type, WorkType::Fix));
223 assert!(ctx.scope.is_none());
224 assert!(ctx.is_feature_branch);
225 Ok(())
226 }
227
228 #[test]
229 fn standard_branch_refactor() -> anyhow::Result<()> {
230 let ctx = BranchAnalyzer::analyze("refactor/cleanup-modules")?;
231 assert!(matches!(ctx.work_type, WorkType::Refactor));
232 assert!(ctx.is_feature_branch);
233 Ok(())
234 }
235
236 #[test]
237 fn standard_branch_docs() -> anyhow::Result<()> {
238 let ctx = BranchAnalyzer::analyze("docs/update-readme")?;
239 assert!(matches!(ctx.work_type, WorkType::Docs));
240 assert!(!ctx.is_feature_branch); Ok(())
242 }
243
244 #[test]
245 fn ticket_branch_jira() -> anyhow::Result<()> {
246 let ctx = BranchAnalyzer::analyze("PROJ-123-add-feature")?;
247 assert_eq!(ctx.ticket_id, Some("PROJ-123".to_string()));
248 Ok(())
249 }
250
251 #[test]
252 fn ticket_branch_issue() -> anyhow::Result<()> {
253 let ctx = BranchAnalyzer::analyze("issue-456-fix-bug")?;
254 assert_eq!(ctx.ticket_id, Some("issue-456".to_string()));
255 assert!(matches!(ctx.work_type, WorkType::Fix));
256 Ok(())
257 }
258
259 #[test]
260 fn user_branch() -> anyhow::Result<()> {
261 let ctx = BranchAnalyzer::analyze("johndoe/add-dark-mode")?;
262 assert!(matches!(ctx.work_type, WorkType::Feature));
263 Ok(())
264 }
265
266 #[test]
267 fn simple_branch_name() -> anyhow::Result<()> {
268 let ctx = BranchAnalyzer::analyze("add-login-page")?;
269 assert!(matches!(ctx.work_type, WorkType::Feature));
270 Ok(())
271 }
272
273 #[test]
274 fn main_branch() -> anyhow::Result<()> {
275 let ctx = BranchAnalyzer::analyze("main")?;
276 assert!(matches!(ctx.work_type, WorkType::Feature));
279 Ok(())
280 }
281
282 #[test]
285 fn gitflow_branches() {
286 let branches: Vec<String> = vec![
287 "feature/add-auth".to_string(),
288 "release/1.0".to_string(),
289 "main".to_string(),
290 ];
291 assert!(matches!(
292 BranchAnalyzer::analyze_branching_strategy(&branches),
293 BranchingStrategy::GitFlow
294 ));
295 }
296
297 #[test]
298 fn conventional_branches() {
299 let branches: Vec<String> = vec!["feat/add-auth".to_string(), "fix/crash".to_string()];
300 assert!(matches!(
301 BranchAnalyzer::analyze_branching_strategy(&branches),
302 BranchingStrategy::ConventionalCommits
303 ));
304 }
305
306 #[test]
307 fn github_flow_branches() {
308 let branches: Vec<String> = vec!["short-name".to_string(), "another-branch".to_string()];
309 assert!(matches!(
310 BranchAnalyzer::analyze_branching_strategy(&branches),
311 BranchingStrategy::GitHubFlow
312 ));
313 }
314
315 #[test]
316 fn empty_branches_unknown() {
317 assert!(matches!(
318 BranchAnalyzer::analyze_branching_strategy(&[]),
319 BranchingStrategy::Unknown
320 ));
321 }
322
323 #[test]
326 fn infer_fix_keywords() {
327 assert!(matches!(
328 infer_work_type_from_description("fix login bug"),
329 WorkType::Fix
330 ));
331 assert!(matches!(
332 infer_work_type_from_description("resolve issue"),
333 WorkType::Fix
334 ));
335 }
336
337 #[test]
338 fn infer_various_types() {
339 assert!(matches!(
340 infer_work_type_from_description("update docs"),
341 WorkType::Docs
342 ));
343 assert!(matches!(
344 infer_work_type_from_description("add test cases"),
345 WorkType::Test
346 ));
347 assert!(matches!(
348 infer_work_type_from_description("refactor modules"),
349 WorkType::Refactor
350 ));
351 assert!(matches!(
352 infer_work_type_from_description("ci pipeline"),
353 WorkType::Ci
354 ));
355 assert!(matches!(
356 infer_work_type_from_description("build config"),
357 WorkType::Build
358 ));
359 assert!(matches!(
360 infer_work_type_from_description("performance tuning"),
361 WorkType::Perf
362 ));
363 assert!(matches!(
364 infer_work_type_from_description("chore maintenance"),
365 WorkType::Chore
366 ));
367 }
368
369 #[test]
370 fn infer_default_feature() {
371 assert!(matches!(
372 infer_work_type_from_description("add login page"),
373 WorkType::Feature
374 ));
375 }
376
377 #[test]
380 fn clean_removes_prefixes() {
381 assert_eq!(clean_description("add login page"), "Login page");
382 assert_eq!(clean_description("implement auth"), "Auth");
383 assert_eq!(clean_description("fix crash"), "Crash");
384 }
385
386 #[test]
387 fn clean_capitalizes() {
388 assert_eq!(clean_description("some description"), "Some description");
389 }
390
391 #[test]
392 fn clean_trims_whitespace() {
393 assert_eq!(clean_description(" hello "), "Hello");
394 }
395
396 #[test]
399 fn extract_jira_ticket() {
400 assert_eq!(
401 extract_ticket_references("PROJ-123-some-work"),
402 Some("PROJ-123".to_string())
403 );
404 }
405
406 #[test]
407 fn extract_issue_reference() {
408 assert_eq!(
409 extract_ticket_references("issue-456-fix"),
410 Some("issue-456".to_string())
411 );
412 }
413
414 #[test]
415 fn extract_hash_reference() {
416 assert_eq!(
417 extract_ticket_references("work-on-#789"),
418 Some("#789".to_string())
419 );
420 }
421
422 #[test]
423 fn no_ticket_reference() {
424 assert_eq!(extract_ticket_references("plain-branch-name"), None);
425 }
426}