1use crate::app::CommitType;
6use crate::git::FileStatus;
7
8#[derive(Debug, Clone)]
10pub struct CommitSuggestion {
11 pub commit_type: CommitType,
13 pub scope: Option<String>,
15 pub message: String,
17 pub confidence: f32,
19}
20
21impl CommitSuggestion {
22 pub fn full_message(&self) -> String {
24 match &self.scope {
25 Some(scope) => format!("{}({}): {}", self.commit_type.name(), scope, self.message),
26 None => format!("{}: {}", self.commit_type.name(), self.message),
27 }
28 }
29}
30
31pub fn generate_suggestions(statuses: &[FileStatus]) -> Vec<CommitSuggestion> {
35 if statuses.is_empty() {
36 return Vec::new();
37 }
38
39 let paths: Vec<&str> = statuses.iter().map(|s| s.path.as_str()).collect();
40 let mut suggestions = Vec::new();
41
42 let type_counts = count_inferred_types(&paths);
44
45 let mut type_vec: Vec<_> = type_counts.into_iter().collect();
47 type_vec.sort_by(|a, b| b.1.cmp(&a.1));
48
49 for (commit_type, count) in type_vec.iter().take(3) {
50 let confidence = *count as f32 / paths.len() as f32;
51 if confidence < 0.2 {
52 continue;
53 }
54
55 let scope = infer_scope_from_paths(&paths);
56 let message = generate_message(*commit_type, scope.as_deref(), &paths);
57
58 suggestions.push(CommitSuggestion {
59 commit_type: *commit_type,
60 scope,
61 message,
62 confidence,
63 });
64 }
65
66 if suggestions.is_empty() {
68 let scope = infer_scope_from_paths(&paths);
69 let message = generate_message(CommitType::Chore, scope.as_deref(), &paths);
70 suggestions.push(CommitSuggestion {
71 commit_type: CommitType::Chore,
72 scope,
73 message,
74 confidence: 0.3,
75 });
76 }
77
78 suggestions.sort_by(|a, b| {
80 b.confidence
81 .partial_cmp(&a.confidence)
82 .unwrap_or(std::cmp::Ordering::Equal)
83 });
84
85 suggestions.truncate(3);
87
88 suggestions
89}
90
91fn infer_type_from_path(path: &str) -> Option<CommitType> {
93 let path_lower = path.to_lowercase();
94 let file_name = path_lower.split('/').next_back().unwrap_or(&path_lower);
95
96 if path_lower.contains("/tests/")
98 || path_lower.starts_with("tests/")
99 || file_name.contains("_test.")
100 || file_name.contains(".test.")
101 || file_name.ends_with("_test.rs")
102 || file_name.ends_with("_test.go")
103 || file_name.ends_with("_test.py")
104 || file_name.ends_with(".spec.js")
105 || file_name.ends_with(".spec.ts")
106 || file_name.starts_with("test_")
107 {
108 return Some(CommitType::Test);
109 }
110
111 if path_lower.starts_with("readme")
113 || path_lower.ends_with(".md")
114 || path_lower.contains("/docs/")
115 || path_lower.starts_with("docs/")
116 || path_lower.contains("license")
117 || path_lower.contains("changelog")
118 {
119 return Some(CommitType::Docs);
120 }
121
122 if path_lower == "cargo.toml"
124 || path_lower == "package.json"
125 || path_lower == "go.mod"
126 || path_lower == "requirements.txt"
127 || path_lower == "pyproject.toml"
128 || path_lower == "tsconfig.json"
129 || path_lower == "jest.config.json"
130 || path_lower == "eslint.config.json"
131 || path_lower == ".eslintrc.json"
132 || path_lower == ".prettierrc"
133 || path_lower == ".prettierrc.json"
134 || path_lower.ends_with(".lock")
135 || path_lower.starts_with(".github/")
136 || path_lower == ".gitignore"
137 || path_lower == ".dockerignore"
138 || path_lower == "dockerfile"
139 || path_lower == "docker-compose.yml"
140 || path_lower == "docker-compose.yaml"
141 || path_lower == "makefile"
142 || path_lower.ends_with(".yml")
143 || path_lower.ends_with(".yaml")
144 {
145 return Some(CommitType::Chore);
146 }
147
148 if path_lower.ends_with(".css")
150 || path_lower.ends_with(".scss")
151 || path_lower.ends_with(".sass")
152 || path_lower.ends_with(".less")
153 {
154 return Some(CommitType::Style);
155 }
156
157 None
158}
159
160fn count_inferred_types(paths: &[&str]) -> std::collections::HashMap<CommitType, usize> {
162 let mut counts = std::collections::HashMap::new();
163
164 for path in paths {
165 if let Some(commit_type) = infer_type_from_path(path) {
166 *counts.entry(commit_type).or_insert(0) += 1;
167 }
168 }
169
170 if counts.is_empty() {
172 counts.insert(CommitType::Feat, paths.len());
174 }
175
176 counts
177}
178
179pub fn infer_scope_from_paths(paths: &[&str]) -> Option<String> {
181 if paths.is_empty() {
182 return None;
183 }
184
185 let first_parts: Vec<&str> = paths[0].split('/').collect();
187 if first_parts.len() < 2 {
188 return None;
189 }
190
191 let scope_candidates: Vec<Option<&str>> = paths
193 .iter()
194 .map(|p| {
195 let parts: Vec<&str> = p.split('/').collect();
196 if parts.len() >= 2 && parts[0] == "src" {
197 Some(parts[1])
198 } else if parts.len() >= 2 {
199 Some(parts[0])
200 } else {
201 None
202 }
203 })
204 .collect();
205
206 let first_scope = scope_candidates.first().and_then(|s| *s)?;
208 if scope_candidates
209 .iter()
210 .all(|s| s.map(|x| x == first_scope).unwrap_or(false))
211 {
212 if !first_scope.contains('.') {
214 return Some(first_scope.to_string());
215 }
216 }
217
218 None
219}
220
221fn generate_message(commit_type: CommitType, scope: Option<&str>, paths: &[&str]) -> String {
223 let file_count = paths.len();
224
225 match commit_type {
226 CommitType::Test => {
227 if file_count == 1 {
228 format!("add tests for {}", extract_module_name(paths[0]))
229 } else {
230 "add tests".to_string()
231 }
232 }
233 CommitType::Docs => {
234 if file_count == 1 && paths[0].to_lowercase().starts_with("readme") {
235 "update README".to_string()
236 } else if file_count == 1 {
237 format!("update {}", extract_file_name(paths[0]))
238 } else {
239 "update documentation".to_string()
240 }
241 }
242 CommitType::Chore => {
243 if file_count == 1 {
244 format!("update {}", extract_file_name(paths[0]))
245 } else {
246 "update configuration".to_string()
247 }
248 }
249 CommitType::Style => "update styles".to_string(),
250 CommitType::Feat => {
251 if let Some(s) = scope {
252 format!("add {} feature", s)
253 } else if file_count == 1 {
254 format!("add {}", extract_module_name(paths[0]))
255 } else {
256 "add new feature".to_string()
257 }
258 }
259 CommitType::Fix => {
260 if let Some(s) = scope {
261 format!("fix {} issue", s)
262 } else {
263 "fix issue".to_string()
264 }
265 }
266 CommitType::Refactor => {
267 if let Some(s) = scope {
268 format!("refactor {}", s)
269 } else {
270 "refactor code".to_string()
271 }
272 }
273 CommitType::Perf => {
274 if let Some(s) = scope {
275 format!("improve {} performance", s)
276 } else {
277 "improve performance".to_string()
278 }
279 }
280 }
281}
282
283fn extract_module_name(path: &str) -> String {
285 let file_name = path.split('/').next_back().unwrap_or(path);
286 file_name
287 .strip_suffix(".rs")
288 .or_else(|| file_name.strip_suffix(".go"))
289 .or_else(|| file_name.strip_suffix(".py"))
290 .or_else(|| file_name.strip_suffix(".js"))
291 .or_else(|| file_name.strip_suffix(".ts"))
292 .or_else(|| file_name.strip_suffix(".tsx"))
293 .or_else(|| file_name.strip_suffix(".jsx"))
294 .unwrap_or(file_name)
295 .to_string()
296}
297
298fn extract_file_name(path: &str) -> String {
300 path.split('/').next_back().unwrap_or(path).to_string()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::git::FileStatusKind;
307
308 fn create_staged_status(path: &str) -> FileStatus {
309 FileStatus {
310 path: path.to_string(),
311 kind: FileStatusKind::StagedNew,
312 }
313 }
314
315 #[test]
316 fn test_infer_type_from_test_file() {
317 assert_eq!(
318 infer_type_from_path("src/app_test.rs"),
319 Some(CommitType::Test)
320 );
321 assert_eq!(
322 infer_type_from_path("tests/integration_test.rs"),
323 Some(CommitType::Test)
324 );
325 assert_eq!(
326 infer_type_from_path("src/utils.spec.js"),
327 Some(CommitType::Test)
328 );
329 }
330
331 #[test]
332 fn test_infer_type_from_readme() {
333 assert_eq!(infer_type_from_path("README.md"), Some(CommitType::Docs));
334 assert_eq!(infer_type_from_path("readme.txt"), Some(CommitType::Docs));
335 }
336
337 #[test]
338 fn test_infer_type_from_docs() {
339 assert_eq!(infer_type_from_path("docs/api.md"), Some(CommitType::Docs));
340 assert_eq!(infer_type_from_path("CHANGELOG.md"), Some(CommitType::Docs));
341 }
342
343 #[test]
344 fn test_infer_type_from_cargo_toml() {
345 assert_eq!(infer_type_from_path("Cargo.toml"), Some(CommitType::Chore));
346 assert_eq!(infer_type_from_path("cargo.toml"), Some(CommitType::Chore));
348 }
349
350 #[test]
351 fn test_infer_type_from_package_json() {
352 assert_eq!(
353 infer_type_from_path("package.json"),
354 Some(CommitType::Chore)
355 );
356 }
357
358 #[test]
359 fn test_infer_type_from_regular_json_is_none() {
360 assert_eq!(infer_type_from_path("src/data.json"), None);
362 assert_eq!(infer_type_from_path("config/settings.json"), None);
363 }
364
365 #[test]
366 fn test_infer_type_from_regular_toml_is_none() {
367 assert_eq!(infer_type_from_path("src/config.toml"), None);
369 }
370
371 #[test]
372 fn test_infer_type_from_github_workflow() {
373 assert_eq!(
374 infer_type_from_path(".github/workflows/ci.yml"),
375 Some(CommitType::Chore)
376 );
377 }
378
379 #[test]
380 fn test_infer_type_from_css() {
381 assert_eq!(
382 infer_type_from_path("styles/main.css"),
383 Some(CommitType::Style)
384 );
385 assert_eq!(infer_type_from_path("app.scss"), Some(CommitType::Style));
386 }
387
388 #[test]
389 fn test_infer_type_from_regular_source() {
390 assert_eq!(infer_type_from_path("src/main.rs"), None);
392 assert_eq!(infer_type_from_path("src/app.rs"), None);
393 }
394
395 #[test]
396 fn test_infer_scope_from_src_auth() {
397 let paths = vec!["src/auth/login.rs", "src/auth/logout.rs"];
398 assert_eq!(infer_scope_from_paths(&paths), Some("auth".to_string()));
399 }
400
401 #[test]
402 fn test_infer_scope_from_src_tui() {
403 let paths = vec!["src/tui/ui.rs", "src/tui/render.rs"];
404 assert_eq!(infer_scope_from_paths(&paths), Some("tui".to_string()));
405 }
406
407 #[test]
408 fn test_infer_scope_mixed_paths() {
409 let paths = vec!["src/auth/login.rs", "src/tui/ui.rs"];
410 assert_eq!(infer_scope_from_paths(&paths), None);
412 }
413
414 #[test]
415 fn test_infer_scope_single_file() {
416 let paths = vec!["src/main.rs"];
417 assert_eq!(infer_scope_from_paths(&paths), None);
419 }
420
421 #[test]
422 fn test_generate_suggestions_empty() {
423 let statuses: Vec<FileStatus> = vec![];
424 let suggestions = generate_suggestions(&statuses);
425 assert!(suggestions.is_empty());
426 }
427
428 #[test]
429 fn test_generate_suggestions_test_files() {
430 let statuses = vec![
431 create_staged_status("src/app_test.rs"),
432 create_staged_status("src/utils_test.rs"),
433 ];
434 let suggestions = generate_suggestions(&statuses);
435 assert!(!suggestions.is_empty());
436 assert_eq!(suggestions[0].commit_type, CommitType::Test);
437 }
438
439 #[test]
440 fn test_generate_suggestions_readme() {
441 let statuses = vec![create_staged_status("README.md")];
442 let suggestions = generate_suggestions(&statuses);
443 assert!(!suggestions.is_empty());
444 assert_eq!(suggestions[0].commit_type, CommitType::Docs);
445 assert!(suggestions[0].message.contains("README"));
446 }
447
448 #[test]
449 fn test_generate_suggestions_cargo_toml() {
450 let statuses = vec![create_staged_status("Cargo.toml")];
451 let suggestions = generate_suggestions(&statuses);
452 assert!(!suggestions.is_empty());
453 assert_eq!(suggestions[0].commit_type, CommitType::Chore);
454 }
455
456 #[test]
457 fn test_generate_suggestions_max_three() {
458 let statuses = vec![
460 create_staged_status("src/a.rs"),
461 create_staged_status("src/b.rs"),
462 create_staged_status("src/c.rs"),
463 create_staged_status("src/d.rs"),
464 create_staged_status("src/e.rs"),
465 ];
466 let suggestions = generate_suggestions(&statuses);
467 assert!(suggestions.len() <= 3);
468 }
469
470 #[test]
471 fn test_commit_suggestion_full_message_with_scope() {
472 let suggestion = CommitSuggestion {
473 commit_type: CommitType::Feat,
474 scope: Some("auth".to_string()),
475 message: "add login".to_string(),
476 confidence: 0.8,
477 };
478 assert_eq!(suggestion.full_message(), "feat(auth): add login");
479 }
480
481 #[test]
482 fn test_commit_suggestion_full_message_without_scope() {
483 let suggestion = CommitSuggestion {
484 commit_type: CommitType::Fix,
485 scope: None,
486 message: "fix bug".to_string(),
487 confidence: 0.7,
488 };
489 assert_eq!(suggestion.full_message(), "fix: fix bug");
490 }
491
492 #[test]
493 fn test_extract_module_name() {
494 assert_eq!(extract_module_name("src/app.rs"), "app");
495 assert_eq!(extract_module_name("main.go"), "main");
496 assert_eq!(extract_module_name("utils.py"), "utils");
497 }
498
499 #[test]
500 fn test_extract_file_name() {
501 assert_eq!(extract_file_name("src/app.rs"), "app.rs");
502 assert_eq!(extract_file_name("Cargo.toml"), "Cargo.toml");
503 }
504}