rusty_commit/utils/
commit_style.rs1use std::collections::HashMap;
2
3#[derive(Debug, Default)]
5pub struct CommitStyleProfile {
6 pub type_frequencies: HashMap<String, usize>,
8 pub uses_scopes: bool,
10 pub scope_frequencies: HashMap<String, usize>,
12 pub avg_description_length: f64,
14 pub prefix_format: PrefixFormat,
16 pub uses_gitmoji: bool,
18 pub emoji_frequencies: HashMap<String, usize>,
20 pub adds_period: bool,
22 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, ConventionalNoScope, GitMoji, GitMojiDev, Simple, Other,
36}
37
38impl CommitStyleProfile {
39 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 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 if let Some((prefix, _)) = commit_str.split_once(':') {
60 if let Some(emoji) = prefix.chars().next() {
62 if is_emoji(emoji) {
63 profile.uses_gitmoji = true;
64 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 let type_part = prefix
77 .chars()
78 .skip_while(|c| !c.is_ascii_alphanumeric())
79 .collect::<String>();
80 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 if let Some((type_part, scope_part)) = prefix.split_once('(') {
93 profile.uses_scopes = true;
94 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 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 if desc.ends_with('.') {
127 periods += 1;
128 }
129
130 if let Some(first) = desc.chars().next() {
132 if first.is_ascii_uppercase() {
133 capitalized += 1;
134 }
135 }
136 }
137 }
138
139 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; profile.capitalizes_description = (capitalized as f64 / desc_count as f64) > 0.5;
144 }
146
147 profile
148 }
149
150 pub fn to_prompt_guidance(&self) -> String {
152 let mut guidance = String::new();
153
154 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 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 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 if self.capitalizes_description {
196 guidance.push_str("- Capitalize the first letter of the description\n");
197 }
198
199 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 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 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 pub fn is_empty(&self) -> bool {
240 self.type_frequencies.is_empty() && !self.uses_scopes
241 }
242}
243
244fn is_emoji(c: char) -> bool {
246 c as u32 > 0x1F600 || (c as u32 >= 0x1F300 && c as u32 <= 0x1F9FF) || (c as u32 >= 0x2600 && c as u32 <= 0x26FF) || (c as u32 >= 0x2700 && c as u32 <= 0x27BF) || (c as u32 >= 0xFE00 && c as u32 <= 0xFE0F) || c == '🎉' || c == '🚀' || c == '✨' || c == '🐛' ||
253 c == '🔥' || c == '💄' || c == '🎨' || c == '⚡' ||
254 c == '🍱' || c == '🔧' || c == '🚑' || c == '🔀' ||
255 c == '📝' || c == '✅' || c == '⬆' || c == '⬇'
256}
257
258fn 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 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 assert!(profile.uses_scopes);
306 assert!(profile.scope_frequencies.contains_key("auth"));
307 assert!(profile.scope_frequencies.contains_key("api"));
308
309 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 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 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 assert!(guidance.contains("feat"));
341 assert!(guidance.contains("fix"));
342
343 assert!(!guidance.is_empty());
345 }
346}