Skip to main content

rusty_commit/utils/
commit_style.rs

1use std::collections::HashMap;
2
3/// Represents a learned style profile from commit history
4#[derive(Debug, Default)]
5pub struct CommitStyleProfile {
6    /// Most common commit types used (e.g., "feat", "fix")
7    pub type_frequencies: HashMap<String, usize>,
8    /// Whether scopes are commonly used
9    pub uses_scopes: bool,
10    /// Most common scopes
11    pub scope_frequencies: HashMap<String, usize>,
12    /// Average description length
13    pub avg_description_length: f64,
14    /// Most common prefix format
15    pub prefix_format: PrefixFormat,
16    /// Whether gitmoji is commonly used
17    pub uses_gitmoji: bool,
18    /// Most common emojis used
19    pub emoji_frequencies: HashMap<String, usize>,
20    /// Whether descriptions typically end with periods
21    pub adds_period: bool,
22    /// Whether descriptions are typically capitalized
23    pub capitalizes_description: bool,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27#[allow(dead_code)]
28pub enum PrefixFormat {
29    #[default]
30    Conventional, // feat(scope): description
31    ConventionalNoScope, // feat: description
32    GitMoji,             // ✨ feat: description
33    GitMojiDev,          // Full gitmoji.dev format
34    Simple,              // Just type: description
35    Other,
36}
37
38impl CommitStyleProfile {
39    /// Analyze commits and generate a style profile
40    pub fn analyze_from_commits<T: AsRef<str>>(commits: &[T]) -> Self {
41        let mut profile = Self::default();
42
43        if commits.is_empty() {
44            return profile;
45        }
46
47        let total = commits.len() as f64;
48
49        // Count types, scopes, and analyze each commit
50        let mut total_desc_len = 0;
51        let mut desc_count = 0;
52        let mut periods = 0;
53        let mut capitalized = 0;
54
55        for commit in commits {
56            let commit_str = commit.as_ref();
57
58            // Extract type
59            if let Some((prefix, _)) = commit_str.split_once(':') {
60                // Check for gitmoji
61                if let Some(emoji) = prefix.chars().next() {
62                    if is_emoji(emoji) {
63                        profile.uses_gitmoji = true;
64                        // Extract emoji
65                        let emoji_str = commit_str
66                            .chars()
67                            .take_while(|c| !c.is_ascii_alphanumeric())
68                            .collect::<String>();
69                        if !emoji_str.is_empty() {
70                            *profile
71                                .emoji_frequencies
72                                .entry(emoji_str.trim().to_string())
73                                .or_insert(0) += 1;
74                        }
75                        // Get type after emoji - extract just the type before any scope
76                        let type_part = prefix
77                            .chars()
78                            .skip_while(|c| !c.is_ascii_alphanumeric())
79                            .collect::<String>();
80                        // Extract just the type (before '(' if present)
81                        let clean_type = if let Some((t, _)) = type_part.split_once('(') {
82                            t.to_string()
83                        } else {
84                            type_part
85                        };
86                        if !clean_type.is_empty() {
87                            *profile.type_frequencies.entry(clean_type).or_insert(0) += 1;
88                        }
89                        profile.prefix_format = PrefixFormat::GitMoji;
90                    } else {
91                        // No emoji, check for conventional format
92                        if let Some((type_part, scope_part)) = prefix.split_once('(') {
93                            profile.uses_scopes = true;
94                            // Extract scope (text between '(' and ')')
95                            if let Some((scope, _)) = scope_part.split_once(')') {
96                                if !scope.is_empty() {
97                                    *profile
98                                        .scope_frequencies
99                                        .entry(scope.to_string())
100                                        .or_insert(0) += 1;
101                                }
102                            }
103                            *profile
104                                .type_frequencies
105                                .entry(type_part.to_string())
106                                .or_insert(0) += 1;
107                            profile.prefix_format = PrefixFormat::Conventional;
108                        } else {
109                            profile.prefix_format = PrefixFormat::ConventionalNoScope;
110                            *profile
111                                .type_frequencies
112                                .entry(prefix.to_string().trim().to_string())
113                                .or_insert(0) += 1;
114                        }
115                    }
116                }
117            }
118
119            // Analyze description
120            if let Some(desc) = commit_str.split_once(':').map(|x| x.1) {
121                let desc = desc.trim();
122                total_desc_len += desc.len();
123                desc_count += 1;
124
125                // Check for period at end
126                if desc.ends_with('.') {
127                    periods += 1;
128                }
129
130                // Check if first char is capitalized
131                if let Some(first) = desc.chars().next() {
132                    if first.is_ascii_uppercase() {
133                        capitalized += 1;
134                    }
135                }
136            }
137        }
138
139        // Calculate averages and percentages
140        if desc_count > 0 {
141            profile.avg_description_length = total_desc_len as f64 / desc_count as f64;
142            profile.adds_period = (periods as f64 / total) > 0.3; // 30% threshold
143            profile.capitalizes_description = (capitalized as f64 / desc_count as f64) > 0.5;
144            // 50% threshold
145        }
146
147        profile
148    }
149
150    /// Generate style guidance text for the AI prompt
151    pub fn to_prompt_guidance(&self) -> String {
152        let mut guidance = String::new();
153
154        // Add type guidance if we have data
155        if !self.type_frequencies.is_empty() {
156            let top_types: Vec<_> = self
157                .type_frequencies
158                .iter()
159                .filter(|(t, _)| is_valid_commit_type(t))
160                .take(3)
161                .collect();
162
163            if !top_types.is_empty() {
164                let types_list: Vec<String> = top_types.iter().map(|(t, _)| (*t).clone()).collect();
165
166                guidance.push_str(&format!(
167                    "- Common commit types in this repo: {}\n",
168                    types_list.join(", ")
169                ));
170            }
171        }
172
173        // Add scope guidance
174        if self.uses_scopes && !self.scope_frequencies.is_empty() {
175            let top_scopes: Vec<_> = self.scope_frequencies.keys().take(3).cloned().collect();
176
177            if !top_scopes.is_empty() {
178                guidance.push_str(&format!(
179                    "- Common scopes in this repo: {}\n",
180                    top_scopes.join(", ")
181                ));
182            }
183        }
184
185        // Add description length guidance
186        if self.avg_description_length > 0.0 {
187            let target_len = self.avg_description_length as usize;
188            guidance.push_str(&format!(
189                "- Keep descriptions around {} characters (based on repo style)\n",
190                target_len
191            ));
192        }
193
194        // Add capitalization guidance
195        if self.capitalizes_description {
196            guidance.push_str("- Capitalize the first letter of the description\n");
197        }
198
199        // Add period guidance
200        if self.adds_period {
201            guidance.push_str("- End the description with a period\n");
202        } else {
203            guidance.push_str("- Do not end the description with a period\n");
204        }
205
206        // Add gitmoji guidance
207        if self.uses_gitmoji {
208            let top_emojis: Vec<_> = self.emoji_frequencies.keys().take(3).cloned().collect();
209
210            if !top_emojis.is_empty() {
211                guidance.push_str(&format!(
212                    "- Common emojis used: {} (prefer gitmoji format)\n",
213                    top_emojis.join(", ")
214                ));
215            }
216        }
217
218        // Add prefix format guidance
219        match self.prefix_format {
220            PrefixFormat::Conventional => {
221                guidance.push_str("- Use format: <type>(<scope>): <description>\n");
222            }
223            PrefixFormat::ConventionalNoScope => {
224                guidance.push_str("- Use format: <type>: <description> (no scope)\n");
225            }
226            PrefixFormat::GitMoji => {
227                guidance.push_str("- Use format: <emoji> <type>: <description>\n");
228            }
229            PrefixFormat::GitMojiDev => {
230                guidance.push_str("- Use full gitmoji.dev format\n");
231            }
232            _ => {}
233        }
234
235        guidance
236    }
237
238    /// Check if profile has any meaningful data
239    pub fn is_empty(&self) -> bool {
240        self.type_frequencies.is_empty() && !self.uses_scopes
241    }
242}
243
244/// Check if a character is an emoji
245fn is_emoji(c: char) -> bool {
246    // Basic check for common emoji ranges
247    c as u32 > 0x1F600 || // Emoticons
248    (c as u32 >= 0x1F300 && c as u32 <= 0x1F9FF) || // Misc symbols
249    (c as u32 >= 0x2600 && c as u32 <= 0x26FF) || // Misc symbols
250    (c as u32 >= 0x2700 && c as u32 <= 0x27BF) || // Dingbats
251    (c as u32 >= 0xFE00 && c as u32 <= 0xFE0F) || // Variation selectors
252    c == '🎉' || c == '🚀' || c == '✨' || c == '🐛' ||
253    c == '🔥' || c == '💄' || c == '🎨' || c == '⚡' ||
254    c == '🍱' || c == '🔧' || c == '🚑' || c == '🔀' ||
255    c == '📝' || c == '✅' || c == '⬆' || c == '⬇'
256}
257
258/// Check if a string is a valid commit type
259fn is_valid_commit_type(t: &str) -> bool {
260    matches!(
261        t.to_lowercase().as_str(),
262        "feat"
263            | "fix"
264            | "docs"
265            | "style"
266            | "refactor"
267            | "perf"
268            | "test"
269            | "build"
270            | "ci"
271            | "chore"
272            | "revert"
273            | "breaking"
274    )
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_analyze_empty_commits() {
283        let commits: Vec<String> = vec![];
284        let profile = CommitStyleProfile::analyze_from_commits(&commits);
285        assert!(profile.is_empty());
286    }
287
288    #[test]
289    fn test_analyze_conventional_commits() {
290        let commits = vec![
291            "feat(auth): add login functionality",
292            "fix(api): resolve token refresh issue",
293            "docs(readme): update installation instructions",
294            "feat(auth): implement logout",
295        ];
296
297        let profile = CommitStyleProfile::analyze_from_commits(&commits);
298
299        // Should detect types
300        assert!(profile.type_frequencies.contains_key("feat"));
301        assert!(profile.type_frequencies.contains_key("fix"));
302        assert!(profile.type_frequencies.contains_key("docs"));
303
304        // Should detect scopes
305        assert!(profile.uses_scopes);
306        assert!(profile.scope_frequencies.contains_key("auth"));
307        assert!(profile.scope_frequencies.contains_key("api"));
308
309        // Should not detect gitmoji
310        assert!(!profile.uses_gitmoji);
311    }
312
313    #[test]
314    fn test_analyze_gitmoji_commits() {
315        let commits = vec![
316            "✨ feat(auth): add login functionality",
317            "🐛 fix(api): resolve token refresh issue",
318            "📝 docs: update installation instructions",
319        ];
320
321        let profile = CommitStyleProfile::analyze_from_commits(&commits);
322
323        // Should detect types
324        assert!(profile.type_frequencies.contains_key("feat"));
325        assert!(profile.type_frequencies.contains_key("fix"));
326        assert!(profile.type_frequencies.contains_key("docs"));
327
328        // Should detect gitmoji
329        assert!(profile.uses_gitmoji);
330    }
331
332    #[test]
333    fn test_generate_prompt_guidance() {
334        let commits = vec!["feat(auth): add login", "fix(api): resolve issue"];
335
336        let profile = CommitStyleProfile::analyze_from_commits(&commits);
337        let guidance = profile.to_prompt_guidance();
338
339        // Should include type guidance
340        assert!(guidance.contains("feat"));
341        assert!(guidance.contains("fix"));
342
343        // Should not be empty
344        assert!(!guidance.is_empty());
345    }
346}