Skip to main content

omni_dev/claude/context/
branch.rs

1//! Branch analysis and work pattern detection.
2
3use std::str::FromStr;
4use std::sync::LazyLock;
5
6use anyhow::Result;
7use regex::Regex;
8
9use crate::data::context::{BranchContext, WorkType};
10
11/// Maximum branch name length considered characteristic of GitHub Flow (short, flat names).
12const GITHUB_FLOW_MAX_BRANCH_LEN: usize = 50;
13
14/// Branch naming pattern analyzer.
15pub struct BranchAnalyzer;
16
17impl BranchAnalyzer {
18    /// Analyzes a branch name and extracts context information.
19    pub fn analyze(branch_name: &str) -> Result<BranchContext> {
20        let mut context = BranchContext::default();
21
22        // Parse different branch naming conventions
23        if let Some(captures) = STANDARD_BRANCH_PATTERN.captures(branch_name) {
24            // Standard pattern: type/scope/description or type/description
25            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            // Ticket-based pattern: JIRA-123-description, issue-456-description
39            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            // Infer work type from description or ticket prefix
46            context.work_type = infer_work_type_from_description(&context.description);
47        } else if let Some(captures) = USER_BRANCH_PATTERN.captures(branch_name) {
48            // User-based pattern: username/feature-description
49            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            // Fallback: try to extract any meaningful information
57            context.description = branch_name.replace(['-', '_'], " ");
58            context.work_type = infer_work_type_from_description(&context.description);
59        }
60
61        // Extract ticket references from anywhere in the branch name
62        context.ticket_id = context
63            .ticket_id
64            .or_else(|| extract_ticket_references(branch_name));
65
66        // Determine if this is a feature branch
67        context.is_feature_branch = matches!(
68            context.work_type,
69            WorkType::Feature | WorkType::Fix | WorkType::Refactor
70        );
71
72        // Clean up description
73        context.description = clean_description(&context.description);
74
75        Ok(context)
76    }
77
78    /// Analyzes multiple branch names to understand the branching strategy.
79    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/// Branching strategy patterns.
112#[derive(Debug, Clone)]
113pub enum BranchingStrategy {
114    /// Git Flow branching model with feature/, release/, hotfix/ branches.
115    GitFlow,
116    /// GitHub Flow with simple feature branches and main branch.
117    GitHubFlow,
118    /// Conventional commits with type-based branch naming.
119    ConventionalCommits,
120    /// Unknown or mixed branching strategy.
121    Unknown,
122}
123
124// Branch naming pattern regexes
125#[allow(clippy::unwrap_used)] // Compile-time constant regex pattern
126static 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)] // Compile-time constant regex pattern
131static 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)] // Compile-time constant regex pattern
135static USER_BRANCH_PATTERN: LazyLock<Regex> =
136    LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+/(?P<desc>.+)$").unwrap());
137
138#[allow(clippy::unwrap_used)] // Compile-time constant regex pattern
139static TICKET_REFERENCE_PATTERN: LazyLock<Regex> =
140    LazyLock::new(|| Regex::new(r"([A-Z]+-\d+|#\d+|issue-\d+)").unwrap());
141
142/// Extracts ticket references from a branch name.
143fn 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
149/// Infers the work type from description keywords.
150fn 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        // Default to feature for most other cases
171        WorkType::Feature
172    }
173}
174
175/// Cleans up and normalizes description text.
176fn clean_description(description: &str) -> String {
177    let mut cleaned = description.trim().to_string();
178
179    // Remove common prefixes
180    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    // Ensure proper capitalization
196    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    // ── BranchAnalyzer::analyze ──────────────────────────────────────
209
210    #[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); // Docs is not a feature branch
241        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        // "main" has no type keywords → defaults to Feature via infer_work_type_from_description
277        // but is_feature_branch is set based on work_type
278        assert!(matches!(ctx.work_type, WorkType::Feature));
279        Ok(())
280    }
281
282    // ── analyze_branching_strategy ───────────────────────────────────
283
284    #[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    // ── infer_work_type_from_description ─────────────────────────────
324
325    #[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    // ── clean_description ────────────────────────────────────────────
378
379    #[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    // ── extract_ticket_references ────────────────────────────────────
397
398    #[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}