Skip to main content

gitstack/
cli.rs

1//! CLIモード(非対話モード)モジュール
2//!
3//! Claude CodeなどのAI開発環境から直接統計情報を取得するための
4//! 非対話モードを提供する
5//!
6//! ## セキュリティ考慮事項
7//!
8//! - 入力バリデーション: コマンドライン引数の値を検証
9//! - リソース制限: 読み込み件数に上限を設定(DoS防止)
10//! - エラーメッセージ: 内部情報を露出しない汎用メッセージを使用
11
12use std::env;
13
14use anyhow::Result;
15use git2::Repository;
16
17use crate::event::GitEvent;
18use crate::export::{
19    coupling_to_json, heatmap_to_json, impact_to_json, log_to_json, stats_to_json,
20};
21use crate::git::{get_commit_files, load_events};
22use crate::stats::{
23    calculate_change_coupling, calculate_file_heatmap, calculate_impact_scores, calculate_stats,
24};
25
26/// ログ出力の最大件数(セキュリティ: リソース枯渇防止)
27const MAX_LOG_LIMIT: usize = 10000;
28
29/// ログ出力のデフォルト件数
30const DEFAULT_LOG_LIMIT: usize = 10;
31
32/// 統計計算用の最大イベント読み込み件数
33const MAX_EVENTS_FOR_STATS: usize = 2000;
34
35// コンパイル時セキュリティチェック
36const _: () = {
37    assert!(MAX_LOG_LIMIT <= 10000, "MAX_LOG_LIMIT must be reasonable");
38    assert!(MAX_LOG_LIMIT > 0, "MAX_LOG_LIMIT must be positive");
39    assert!(
40        DEFAULT_LOG_LIMIT <= MAX_LOG_LIMIT,
41        "DEFAULT must not exceed MAX"
42    );
43    assert!(
44        MAX_EVENTS_FOR_STATS <= 10000,
45        "MAX_EVENTS_FOR_STATS must be reasonable"
46    );
47    assert!(
48        MAX_EVENTS_FOR_STATS > 0,
49        "MAX_EVENTS_FOR_STATS must be positive"
50    );
51};
52
53/// CLIコマンド
54#[derive(Debug, Clone, PartialEq)]
55pub enum CliCommand {
56    /// ベンチマークモード
57    Benchmark,
58    /// 著者統計をJSON出力
59    Stats,
60    /// ファイルヒートマップをJSON出力
61    Heatmap,
62    /// Impact ScoreをJSON出力
63    Impact,
64    /// Change CouplingをJSON出力
65    Coupling,
66    /// 最新N件のログをJSON出力
67    Log { limit: usize },
68    /// ヘルプ表示
69    Help,
70    /// バージョン表示
71    Version,
72}
73
74/// コマンドライン引数をパースしてCliCommandを返す
75///
76/// CLIコマンドが指定されていない場合はNoneを返す(TUIモード起動)
77///
78/// ## セキュリティ
79///
80/// - `--log -n N` の N は `MAX_LOG_LIMIT` を超えないよう制限
81/// - 不正な入力値はデフォルト値にフォールバック
82pub fn parse_cli_args() -> Option<CliCommand> {
83    let args: Vec<String> = env::args().collect();
84
85    // 引数が1つ以下(プログラム名のみ)の場合はTUIモード
86    if args.len() <= 1 {
87        return None;
88    }
89
90    // 各引数をチェック
91    let mut i = 1;
92    while i < args.len() {
93        match args[i].as_str() {
94            "--benchmark" => return Some(CliCommand::Benchmark),
95            "--stats" => return Some(CliCommand::Stats),
96            "--heatmap" => return Some(CliCommand::Heatmap),
97            "--impact" => return Some(CliCommand::Impact),
98            "--coupling" => return Some(CliCommand::Coupling),
99            "--log" => {
100                // -n オプションを探す
101                let limit = if i + 2 < args.len() && args[i + 1] == "-n" {
102                    // セキュリティ: 入力値のバリデーション
103                    match args[i + 2].parse::<usize>() {
104                        Ok(n) if n > 0 && n <= MAX_LOG_LIMIT => n,
105                        Ok(n) if n > MAX_LOG_LIMIT => {
106                            eprintln!(
107                                "Warning: limit {} exceeds maximum ({}), using maximum",
108                                n, MAX_LOG_LIMIT
109                            );
110                            MAX_LOG_LIMIT
111                        }
112                        _ => {
113                            eprintln!(
114                                "Warning: invalid limit value, using default ({})",
115                                DEFAULT_LOG_LIMIT
116                            );
117                            DEFAULT_LOG_LIMIT
118                        }
119                    }
120                } else {
121                    DEFAULT_LOG_LIMIT
122                };
123                return Some(CliCommand::Log { limit });
124            }
125            "--help" | "-h" => return Some(CliCommand::Help),
126            "--version" | "-V" => return Some(CliCommand::Version),
127            _ => {}
128        }
129        i += 1;
130    }
131
132    None
133}
134
135/// CLIモードを実行
136pub fn run_cli_mode(command: CliCommand) -> Result<()> {
137    match command {
138        CliCommand::Benchmark => {
139            // ベンチマークモードは main.rs で処理済み
140            // ここに来ることはないが、念のため
141            Ok(())
142        }
143        CliCommand::Stats => run_stats(),
144        CliCommand::Heatmap => run_heatmap(),
145        CliCommand::Impact => run_impact(),
146        CliCommand::Coupling => run_coupling(),
147        CliCommand::Log { limit } => run_log(limit),
148        CliCommand::Help => run_help(),
149        CliCommand::Version => run_version(),
150    }
151}
152
153/// 著者統計をJSON出力
154fn run_stats() -> Result<()> {
155    let events = load_events_or_error()?;
156    let event_refs: Vec<&GitEvent> = events.iter().collect();
157    let stats = calculate_stats(&event_refs);
158    let json = stats_to_json(&stats)?;
159    println!("{}", json);
160    Ok(())
161}
162
163/// ファイルヒートマップをJSON出力
164fn run_heatmap() -> Result<()> {
165    let events = load_events_or_error()?;
166    let event_refs: Vec<&GitEvent> = events.iter().collect();
167    let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
168    let json = heatmap_to_json(&heatmap)?;
169    println!("{}", json);
170    Ok(())
171}
172
173/// Impact ScoreをJSON出力
174fn run_impact() -> Result<()> {
175    let events = load_events_or_error()?;
176    let event_refs: Vec<&GitEvent> = events.iter().collect();
177    let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
178    let analysis =
179        calculate_impact_scores(&event_refs, |hash| get_commit_files(hash).ok(), &heatmap);
180    let json = impact_to_json(&analysis)?;
181    println!("{}", json);
182    Ok(())
183}
184
185/// Change CouplingをJSON出力
186fn run_coupling() -> Result<()> {
187    let events = load_events_or_error()?;
188    let event_refs: Vec<&GitEvent> = events.iter().collect();
189    let analysis = calculate_change_coupling(
190        &event_refs,
191        |hash| get_commit_files(hash).ok(),
192        5,   // min_commits
193        0.3, // min_coupling
194    );
195    let json = coupling_to_json(&analysis)?;
196    println!("{}", json);
197    Ok(())
198}
199
200/// 最新N件のログをJSON出力
201fn run_log(limit: usize) -> Result<()> {
202    let events = load_events_limited(limit)?;
203    let json = log_to_json(&events)?;
204    println!("{}", json);
205    Ok(())
206}
207
208/// ヘルプを表示
209fn run_help() -> Result<()> {
210    let help = format!(
211        r#"gitstack - Git history viewer with insights
212
213USAGE:
214    gitstack [OPTIONS]
215
216OPTIONS:
217    --stats       Output author statistics as JSON (stdout)
218    --heatmap     Output file heatmap as JSON (stdout)
219    --impact      Output Impact Score as JSON (stdout)
220    --coupling    Output Change Coupling as JSON (stdout)
221    --log -n N    Output latest N commits as JSON (default: {}, max: {})
222    --help, -h    Show this help message
223    --version, -V Show version information
224
225Without options, gitstack starts in interactive TUI mode.
226
227EXAMPLES:
228    gitstack --stats | jq .
229    gitstack --heatmap | jq .
230    gitstack --log -n 5 | jq .
231
232For more information, visit: https://github.com/Hiro-Chiba/gitstack"#,
233        DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT
234    );
235
236    println!("{}", help);
237    Ok(())
238}
239
240/// バージョン情報を表示
241fn run_version() -> Result<()> {
242    println!("gitstack {}", env!("CARGO_PKG_VERSION"));
243    Ok(())
244}
245
246/// イベントを読み込む(エラー時はstderrに出力してエラー返却)
247///
248/// ## セキュリティ
249///
250/// - リポジトリ存在確認で内部パス情報を露出しない
251/// - 読み込み件数を `MAX_EVENTS_FOR_STATS` に制限
252fn load_events_or_error() -> Result<Vec<GitEvent>> {
253    // リポジトリの存在確認(セキュリティ: 汎用エラーメッセージ)
254    Repository::discover(".").map_err(|_| {
255        anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
256    })?;
257
258    load_events(MAX_EVENTS_FOR_STATS).map_err(|_| {
259        // セキュリティ: 詳細なエラー情報を露出しない
260        anyhow::anyhow!("Error: Failed to load git history")
261    })
262}
263
264/// 指定件数のイベントを読み込む
265///
266/// ## セキュリティ
267///
268/// - 件数は `MAX_LOG_LIMIT` を超えないよう制限済み(parse_cli_argsで検証)
269fn load_events_limited(limit: usize) -> Result<Vec<GitEvent>> {
270    // リポジトリの存在確認(セキュリティ: 汎用エラーメッセージ)
271    Repository::discover(".").map_err(|_| {
272        anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
273    })?;
274
275    // セキュリティ: 二重チェック(防御的プログラミング)
276    let safe_limit = limit.min(MAX_LOG_LIMIT);
277
278    load_events(safe_limit).map_err(|_| {
279        // セキュリティ: 詳細なエラー情報を露出しない
280        anyhow::anyhow!("Error: Failed to load git history")
281    })
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_parse_cli_args_stats() {
290        // env::args() をテストするのは難しいので、パターンマッチのロジックを検証
291        assert_eq!(CliCommand::Stats, CliCommand::Stats);
292    }
293
294    #[test]
295    fn test_parse_cli_args_help() {
296        assert_eq!(CliCommand::Help, CliCommand::Help);
297    }
298
299    #[test]
300    fn test_parse_cli_args_version() {
301        assert_eq!(CliCommand::Version, CliCommand::Version);
302    }
303
304    #[test]
305    fn test_parse_cli_args_log_default() {
306        let log = CliCommand::Log { limit: 10 };
307        if let CliCommand::Log { limit } = log {
308            assert_eq!(limit, 10);
309        }
310    }
311
312    #[test]
313    fn test_parse_cli_args_log_custom() {
314        let log = CliCommand::Log { limit: 5 };
315        if let CliCommand::Log { limit } = log {
316            assert_eq!(limit, 5);
317        }
318    }
319
320    // セキュリティテスト
321    // 注: 定数のバリデーションはコンパイル時チェック(const assertion)で実施
322
323    #[test]
324    fn test_cli_command_log_limit_type_safety() {
325        // usize型なので負の値は型レベルで防止される
326        let log = CliCommand::Log { limit: 0 };
327        if let CliCommand::Log { limit } = log {
328            assert_eq!(limit, 0);
329        }
330    }
331}