Skip to main content

gitstack/
suggestion.rs

1//! コミット提案エンジン
2//!
3//! ステージされたファイルからコミットメッセージの提案を生成する
4
5use crate::app::CommitType;
6use crate::git::FileStatus;
7
8/// コミット提案
9#[derive(Debug, Clone)]
10pub struct CommitSuggestion {
11    /// コミットタイプ
12    pub commit_type: CommitType,
13    /// スコープ(オプション)
14    pub scope: Option<String>,
15    /// メッセージ本文
16    pub message: String,
17    /// 信頼度(0.0-1.0)
18    pub confidence: f32,
19}
20
21impl CommitSuggestion {
22    /// 完全なコミットメッセージを生成
23    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
31/// ステージされたファイルから提案を生成
32///
33/// 最大3件の提案を生成し、信頼度順にソートして返す
34pub 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    // 1. ファイルパスからタイプを推測
43    let type_counts = count_inferred_types(&paths);
44
45    // 最も多いタイプから提案を生成
46    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    // 2. 提案が少ない場合は汎用提案を追加
67    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    // 信頼度でソート(NaN対策)
79    suggestions.sort_by(|a, b| {
80        b.confidence
81            .partial_cmp(&a.confidence)
82            .unwrap_or(std::cmp::Ordering::Equal)
83    });
84
85    // 最大3件
86    suggestions.truncate(3);
87
88    suggestions
89}
90
91/// ファイルパスからコミットタイプを推測
92fn 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    // テストファイル
97    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    // ドキュメント
112    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    // 設定・依存関係ファイル(ルート直下の設定ファイルのみ)
123    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    // スタイル関連
149    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
160/// 各タイプの出現回数をカウント
161fn 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    // 明示的なタイプがない場合はファイル操作から推測
171    if counts.is_empty() {
172        // デフォルトはfeat(新機能)
173        counts.insert(CommitType::Feat, paths.len());
174    }
175
176    counts
177}
178
179/// 共通ディレクトリからスコープを推測
180pub fn infer_scope_from_paths(paths: &[&str]) -> Option<String> {
181    if paths.is_empty() {
182        return None;
183    }
184
185    // 共通のディレクトリを探す
186    let first_parts: Vec<&str> = paths[0].split('/').collect();
187    if first_parts.len() < 2 {
188        return None;
189    }
190
191    // src/ の直下のディレクトリをスコープとする
192    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    // 全ファイルが同じスコープを持つ場合のみ返す
207    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        // ファイル名の場合はスコープとしない
213        if !first_scope.contains('.') {
214            return Some(first_scope.to_string());
215        }
216    }
217
218    None
219}
220
221/// メッセージを自動生成
222fn 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
283/// パスからモジュール名を抽出(拡張子を除去)
284fn 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
298/// パスからファイル名を抽出
299fn 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        // 小文字も対応
347        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        // 一般的な.jsonファイルはNone(デフォルトでfeatになる)
361        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        // 一般的な.tomlファイルはNone
368        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        // 通常のソースファイルはNone(後でデフォルト処理)
391        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        // 異なるスコープの場合はNone
411        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        // 単一ファイルでsrc直下の場合はNone(main.rsはファイル名)
418        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        // 多くのファイルでも最大3件
459        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}