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    bus_factor_to_json, coupling_to_json, heatmap_to_json, impact_to_json, log_to_json,
20    ownership_to_json, quality_to_json, stats_to_json, tech_debt_to_json, timeline_to_json,
21};
22use crate::git::{get_commit_files, load_events};
23use crate::stats::{
24    calculate_activity_timeline, calculate_bus_factor, calculate_change_coupling,
25    calculate_file_heatmap, calculate_impact_scores, calculate_ownership, calculate_quality_scores,
26    calculate_stats, calculate_tech_debt,
27};
28
29/// ログ出力の最大件数(セキュリティ: リソース枯渇防止)
30const MAX_LOG_LIMIT: usize = 10000;
31
32/// ログ出力のデフォルト件数
33const DEFAULT_LOG_LIMIT: usize = 10;
34
35/// 統計計算用の最大イベント読み込み件数
36const MAX_EVENTS_FOR_STATS: usize = 2000;
37
38// コンパイル時セキュリティチェック
39const _: () = {
40    assert!(MAX_LOG_LIMIT <= 10000, "MAX_LOG_LIMIT must be reasonable");
41    assert!(MAX_LOG_LIMIT > 0, "MAX_LOG_LIMIT must be positive");
42    assert!(
43        DEFAULT_LOG_LIMIT <= MAX_LOG_LIMIT,
44        "DEFAULT must not exceed MAX"
45    );
46    assert!(
47        MAX_EVENTS_FOR_STATS <= 10000,
48        "MAX_EVENTS_FOR_STATS must be reasonable"
49    );
50    assert!(
51        MAX_EVENTS_FOR_STATS > 0,
52        "MAX_EVENTS_FOR_STATS must be positive"
53    );
54};
55
56/// 出力フォーマット
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum OutputFormat {
59    #[default]
60    Json,
61    Markdown,
62}
63
64impl OutputFormat {
65    fn from_str(s: &str) -> Option<Self> {
66        match s.to_lowercase().as_str() {
67            "json" => Some(OutputFormat::Json),
68            "md" | "markdown" => Some(OutputFormat::Markdown),
69            _ => None,
70        }
71    }
72}
73
74/// CLIコマンド
75#[derive(Debug, Clone, PartialEq)]
76pub enum CliCommand {
77    /// ベンチマークモード
78    Benchmark,
79    /// 著者統計を出力
80    Stats { format: OutputFormat },
81    /// ファイルヒートマップを出力
82    Heatmap { format: OutputFormat },
83    /// Impact Scoreを出力
84    Impact { format: OutputFormat },
85    /// Change Couplingを出力
86    Coupling { format: OutputFormat },
87    /// コードオーナーシップを出力
88    Ownership { format: OutputFormat },
89    /// コミット品質スコアを出力
90    Quality { format: OutputFormat },
91    /// 活動タイムラインを出力
92    Timeline { format: OutputFormat },
93    /// バスファクター分析を出力
94    BusFactor { format: OutputFormat },
95    /// 技術的負債スコアを出力
96    TechDebt { format: OutputFormat },
97    /// 最新N件のログを出力
98    Log { limit: usize, format: OutputFormat },
99    /// ヘルプ表示
100    Help,
101    /// バージョン表示
102    Version,
103}
104
105/// コマンドライン引数をパースしてCliCommandを返す
106///
107/// CLIコマンドが指定されていない場合はNoneを返す(TUIモード起動)
108///
109/// ## セキュリティ
110///
111/// - `--log -n N` の N は `MAX_LOG_LIMIT` を超えないよう制限
112/// - 不正な入力値はデフォルト値にフォールバック
113pub fn parse_cli_args() -> Option<CliCommand> {
114    let args: Vec<String> = env::args().collect();
115
116    // 引数が1つ以下(プログラム名のみ)の場合はTUIモード
117    if args.len() <= 1 {
118        return None;
119    }
120
121    // --format オプションを探す
122    let format = find_format_option(&args);
123
124    // 各引数をチェック
125    let mut i = 1;
126    while i < args.len() {
127        match args[i].as_str() {
128            "--benchmark" => return Some(CliCommand::Benchmark),
129            "--stats" => return Some(CliCommand::Stats { format }),
130            "--heatmap" => return Some(CliCommand::Heatmap { format }),
131            "--impact" => return Some(CliCommand::Impact { format }),
132            "--coupling" => return Some(CliCommand::Coupling { format }),
133            "--ownership" => return Some(CliCommand::Ownership { format }),
134            "--quality" => return Some(CliCommand::Quality { format }),
135            "--timeline" => return Some(CliCommand::Timeline { format }),
136            "--bus-factor" => return Some(CliCommand::BusFactor { format }),
137            "--tech-debt" => return Some(CliCommand::TechDebt { format }),
138            "--log" => {
139                // -n オプションを探す
140                let limit = if i + 2 < args.len() && args[i + 1] == "-n" {
141                    // セキュリティ: 入力値のバリデーション
142                    match args[i + 2].parse::<usize>() {
143                        Ok(n) if n > 0 && n <= MAX_LOG_LIMIT => n,
144                        Ok(n) if n > MAX_LOG_LIMIT => {
145                            eprintln!(
146                                "Warning: limit {} exceeds maximum ({}), using maximum",
147                                n, MAX_LOG_LIMIT
148                            );
149                            MAX_LOG_LIMIT
150                        }
151                        _ => {
152                            eprintln!(
153                                "Warning: invalid limit value, using default ({})",
154                                DEFAULT_LOG_LIMIT
155                            );
156                            DEFAULT_LOG_LIMIT
157                        }
158                    }
159                } else {
160                    DEFAULT_LOG_LIMIT
161                };
162                return Some(CliCommand::Log { limit, format });
163            }
164            "--help" | "-h" => return Some(CliCommand::Help),
165            "--version" | "-V" => return Some(CliCommand::Version),
166            _ => {}
167        }
168        i += 1;
169    }
170
171    None
172}
173
174/// --format オプションを探す
175fn find_format_option(args: &[String]) -> OutputFormat {
176    for i in 0..args.len() {
177        if args[i] == "--format" && i + 1 < args.len() {
178            if let Some(fmt) = OutputFormat::from_str(&args[i + 1]) {
179                return fmt;
180            }
181        }
182    }
183    OutputFormat::default()
184}
185
186/// CLIモードを実行
187pub fn run_cli_mode(command: CliCommand) -> Result<()> {
188    match command {
189        CliCommand::Benchmark => {
190            // ベンチマークモードは main.rs で処理済み
191            // ここに来ることはないが、念のため
192            Ok(())
193        }
194        CliCommand::Stats { format } => run_stats(format),
195        CliCommand::Heatmap { format } => run_heatmap(format),
196        CliCommand::Impact { format } => run_impact(format),
197        CliCommand::Coupling { format } => run_coupling(format),
198        CliCommand::Ownership { format } => run_ownership(format),
199        CliCommand::Quality { format } => run_quality(format),
200        CliCommand::Timeline { format } => run_timeline(format),
201        CliCommand::BusFactor { format } => run_bus_factor(format),
202        CliCommand::TechDebt { format } => run_tech_debt(format),
203        CliCommand::Log { limit, format } => run_log(limit, format),
204        CliCommand::Help => run_help(),
205        CliCommand::Version => run_version(),
206    }
207}
208
209/// 著者統計を出力
210fn run_stats(format: OutputFormat) -> Result<()> {
211    let events = load_events_or_error()?;
212    let event_refs: Vec<&GitEvent> = events.iter().collect();
213    let stats = calculate_stats(&event_refs);
214
215    let output = match format {
216        OutputFormat::Json => stats_to_json(&stats)?,
217        OutputFormat::Markdown => stats_to_markdown(&stats),
218    };
219    println!("{}", output);
220    Ok(())
221}
222
223/// ファイルヒートマップを出力
224fn run_heatmap(format: OutputFormat) -> Result<()> {
225    let events = load_events_or_error()?;
226    let event_refs: Vec<&GitEvent> = events.iter().collect();
227    let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
228
229    let output = match format {
230        OutputFormat::Json => heatmap_to_json(&heatmap)?,
231        OutputFormat::Markdown => heatmap_to_markdown(&heatmap),
232    };
233    println!("{}", output);
234    Ok(())
235}
236
237/// Impact Scoreを出力
238fn run_impact(format: OutputFormat) -> Result<()> {
239    let events = load_events_or_error()?;
240    let event_refs: Vec<&GitEvent> = events.iter().collect();
241    let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
242    let analysis =
243        calculate_impact_scores(&event_refs, |hash| get_commit_files(hash).ok(), &heatmap);
244
245    let output = match format {
246        OutputFormat::Json => impact_to_json(&analysis)?,
247        OutputFormat::Markdown => impact_to_markdown(&analysis),
248    };
249    println!("{}", output);
250    Ok(())
251}
252
253/// Change Couplingを出力
254fn run_coupling(format: OutputFormat) -> Result<()> {
255    let events = load_events_or_error()?;
256    let event_refs: Vec<&GitEvent> = events.iter().collect();
257    let analysis = calculate_change_coupling(
258        &event_refs,
259        |hash| get_commit_files(hash).ok(),
260        5,   // min_commits
261        0.3, // min_coupling
262    );
263
264    let output = match format {
265        OutputFormat::Json => coupling_to_json(&analysis)?,
266        OutputFormat::Markdown => coupling_to_markdown(&analysis),
267    };
268    println!("{}", output);
269    Ok(())
270}
271
272/// コードオーナーシップを出力
273fn run_ownership(format: OutputFormat) -> Result<()> {
274    let events = load_events_or_error()?;
275    let event_refs: Vec<&GitEvent> = events.iter().collect();
276    let ownership = calculate_ownership(&event_refs, |hash| get_commit_files(hash).ok());
277
278    let output = match format {
279        OutputFormat::Json => ownership_to_json(&ownership)?,
280        OutputFormat::Markdown => ownership_to_markdown(&ownership),
281    };
282    println!("{}", output);
283    Ok(())
284}
285
286/// コミット品質スコアを出力
287fn run_quality(format: OutputFormat) -> Result<()> {
288    let events = load_events_or_error()?;
289    let event_refs: Vec<&GitEvent> = events.iter().collect();
290    let coupling = calculate_change_coupling(
291        &event_refs,
292        |hash| get_commit_files(hash).ok(),
293        5,
294        0.3,
295    );
296    let analysis =
297        calculate_quality_scores(&event_refs, |hash| get_commit_files(hash).ok(), &coupling);
298
299    let output = match format {
300        OutputFormat::Json => quality_to_json(&analysis)?,
301        OutputFormat::Markdown => quality_to_markdown(&analysis),
302    };
303    println!("{}", output);
304    Ok(())
305}
306
307/// 活動タイムラインを出力
308fn run_timeline(format: OutputFormat) -> Result<()> {
309    let events = load_events_or_error()?;
310    let event_refs: Vec<&GitEvent> = events.iter().collect();
311    let timeline = calculate_activity_timeline(&event_refs);
312
313    let output = match format {
314        OutputFormat::Json => timeline_to_json(&timeline)?,
315        OutputFormat::Markdown => timeline_to_markdown(&timeline),
316    };
317    println!("{}", output);
318    Ok(())
319}
320
321/// バスファクター分析を出力
322fn run_bus_factor(format: OutputFormat) -> Result<()> {
323    let events = load_events_or_error()?;
324    let event_refs: Vec<&GitEvent> = events.iter().collect();
325    let analysis = calculate_bus_factor(&event_refs, |hash| get_commit_files(hash).ok(), 5);
326
327    let output = match format {
328        OutputFormat::Json => bus_factor_to_json(&analysis)?,
329        OutputFormat::Markdown => bus_factor_to_markdown(&analysis),
330    };
331    println!("{}", output);
332    Ok(())
333}
334
335/// 技術的負債スコアを出力
336fn run_tech_debt(format: OutputFormat) -> Result<()> {
337    let events = load_events_or_error()?;
338    let event_refs: Vec<&GitEvent> = events.iter().collect();
339    let analysis = calculate_tech_debt(&event_refs, |hash| get_commit_files(hash).ok(), 3);
340
341    let output = match format {
342        OutputFormat::Json => tech_debt_to_json(&analysis)?,
343        OutputFormat::Markdown => tech_debt_to_markdown(&analysis),
344    };
345    println!("{}", output);
346    Ok(())
347}
348
349/// 最新N件のログを出力
350fn run_log(limit: usize, format: OutputFormat) -> Result<()> {
351    let events = load_events_limited(limit)?;
352
353    let output = match format {
354        OutputFormat::Json => log_to_json(&events)?,
355        OutputFormat::Markdown => log_to_markdown(&events),
356    };
357    println!("{}", output);
358    Ok(())
359}
360
361/// ヘルプを表示
362fn run_help() -> Result<()> {
363    let help = format!(
364        r#"gitstack - Git history viewer with insights
365
366USAGE:
367    gitstack [OPTIONS]
368
369ANALYSIS OPTIONS:
370    --stats       Output author statistics
371    --heatmap     Output file change heatmap
372    --impact      Output Impact Score (commit influence)
373    --coupling    Output Change Coupling (file co-change)
374    --ownership   Output Code Ownership
375    --quality     Output Commit Quality Score
376    --timeline    Output Activity Timeline
377    --bus-factor  Output Bus Factor analysis (knowledge risk)
378    --tech-debt   Output Technical Debt Score
379    --log -n N    Output latest N commits (default: {}, max: {})
380
381FORMAT OPTIONS:
382    --format json     Output as JSON (default)
383    --format md       Output as Markdown
384
385GENERAL OPTIONS:
386    --help, -h        Show this help message
387    --version, -V     Show version information
388
389Without options, gitstack starts in interactive TUI mode.
390
391EXAMPLES:
392    gitstack --stats                      # JSON output
393    gitstack --stats --format md          # Markdown output
394    gitstack --bus-factor --format json   # Bus factor analysis
395    gitstack --tech-debt --format md      # Tech debt as Markdown
396    gitstack --log -n 5 | jq .            # Latest 5 commits
397
398For more information, visit: https://github.com/Hiro-Chiba/gitstack"#,
399        DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT
400    );
401
402    println!("{}", help);
403    Ok(())
404}
405
406/// バージョン情報を表示
407fn run_version() -> Result<()> {
408    println!("gitstack {}", env!("CARGO_PKG_VERSION"));
409    Ok(())
410}
411
412/// イベントを読み込む(エラー時はstderrに出力してエラー返却)
413fn load_events_or_error() -> Result<Vec<GitEvent>> {
414    Repository::discover(".").map_err(|_| {
415        anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
416    })?;
417
418    load_events(MAX_EVENTS_FOR_STATS).map_err(|_| {
419        anyhow::anyhow!("Error: Failed to load git history")
420    })
421}
422
423/// 指定件数のイベントを読み込む
424fn load_events_limited(limit: usize) -> Result<Vec<GitEvent>> {
425    Repository::discover(".").map_err(|_| {
426        anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
427    })?;
428
429    let safe_limit = limit.min(MAX_LOG_LIMIT);
430
431    load_events(safe_limit).map_err(|_| {
432        anyhow::anyhow!("Error: Failed to load git history")
433    })
434}
435
436// ============================================================
437// Markdown出力関数
438// ============================================================
439
440use crate::stats::{
441    ActivityTimeline, BusFactorAnalysis, ChangeCouplingAnalysis, CodeOwnership,
442    CommitImpactAnalysis, CommitQualityAnalysis, FileHeatmap, RepoStats, TechDebtAnalysis,
443};
444
445fn stats_to_markdown(stats: &RepoStats) -> String {
446    let mut md = String::new();
447    md.push_str("# Author Statistics\n\n");
448    md.push_str(&format!(
449        "- **Total Commits**: {}\n",
450        stats.total_commits
451    ));
452    md.push_str(&format!(
453        "- **Total Insertions**: {}\n",
454        stats.total_insertions
455    ));
456    md.push_str(&format!(
457        "- **Total Deletions**: {}\n",
458        stats.total_deletions
459    ));
460    md.push_str(&format!("- **Authors**: {}\n\n", stats.author_count()));
461
462    md.push_str("## Top Contributors\n\n");
463    md.push_str("| Author | Commits | Lines (+/-) | % |\n");
464    md.push_str("|--------|--------:|------------:|--:|\n");
465    for author in stats.authors.iter().take(20) {
466        md.push_str(&format!(
467            "| {} | {} | +{} / -{} | {:.1}% |\n",
468            author.name,
469            author.commit_count,
470            author.insertions,
471            author.deletions,
472            author.commit_percentage(stats.total_commits)
473        ));
474    }
475    md
476}
477
478fn heatmap_to_markdown(heatmap: &FileHeatmap) -> String {
479    let mut md = String::new();
480    md.push_str("# File Heatmap\n\n");
481    md.push_str(&format!("**Total Files**: {}\n\n", heatmap.total_files));
482
483    md.push_str("## Hot Files (Most Changed)\n\n");
484    md.push_str("| File | Changes | Heat |\n");
485    md.push_str("|------|--------:|:----:|\n");
486    for file in heatmap.files.iter().take(30) {
487        let heat_bar = file.heat_bar();
488        md.push_str(&format!(
489            "| `{}` | {} | {} |\n",
490            file.path, file.change_count, heat_bar
491        ));
492    }
493    md
494}
495
496fn impact_to_markdown(analysis: &CommitImpactAnalysis) -> String {
497    let mut md = String::new();
498    md.push_str("# Impact Score Analysis\n\n");
499    md.push_str(&format!(
500        "- **Total Commits**: {}\n",
501        analysis.total_commits
502    ));
503    md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
504    md.push_str(&format!(
505        "- **High Impact Commits**: {}\n\n",
506        analysis.high_impact_count
507    ));
508
509    md.push_str("## High Impact Commits\n\n");
510    md.push_str("| Hash | Author | Score | Files | Message |\n");
511    md.push_str("|------|--------|------:|------:|--------|\n");
512    for commit in analysis.commits.iter().take(20) {
513        let msg = if commit.commit_message.len() > 40 {
514            format!("{}...", &commit.commit_message[..37])
515        } else {
516            commit.commit_message.clone()
517        };
518        md.push_str(&format!(
519            "| `{}` | {} | {:.2} | {} | {} |\n",
520            commit.commit_hash, commit.author, commit.score, commit.files_changed, msg
521        ));
522    }
523    md
524}
525
526fn coupling_to_markdown(analysis: &ChangeCouplingAnalysis) -> String {
527    let mut md = String::new();
528    md.push_str("# Change Coupling Analysis\n\n");
529    md.push_str(&format!(
530        "- **Total Couplings**: {}\n",
531        analysis.couplings.len()
532    ));
533    md.push_str(&format!(
534        "- **High Coupling (>70%)**: {}\n\n",
535        analysis.high_coupling_count
536    ));
537
538    md.push_str("## File Couplings\n\n");
539    md.push_str("| File | Coupled With | Coupling | Co-Changes |\n");
540    md.push_str("|------|--------------|----------|------------|\n");
541    for coupling in analysis.couplings.iter().take(30) {
542        md.push_str(&format!(
543            "| `{}` | `{}` | {:.1}% | {} |\n",
544            coupling.file,
545            coupling.coupled_file,
546            coupling.coupling_percent * 100.0,
547            coupling.co_change_count
548        ));
549    }
550    md
551}
552
553fn ownership_to_markdown(ownership: &CodeOwnership) -> String {
554    let mut md = String::new();
555    md.push_str("# Code Ownership\n\n");
556    md.push_str(&format!("**Total Files**: {}\n\n", ownership.total_files));
557
558    md.push_str("## Directory Ownership\n\n");
559    md.push_str("| Path | Primary Owner | Ownership | Commits |\n");
560    md.push_str("|------|---------------|-----------|--------:|\n");
561    for entry in ownership.entries.iter().filter(|e| e.is_directory).take(30) {
562        md.push_str(&format!(
563            "| `{}/` | {} | {:.1}% | {} |\n",
564            entry.path,
565            entry.primary_author,
566            entry.ownership_percentage(),
567            entry.total_commits
568        ));
569    }
570    md
571}
572
573fn quality_to_markdown(analysis: &CommitQualityAnalysis) -> String {
574    let mut md = String::new();
575    md.push_str("# Commit Quality Analysis\n\n");
576    md.push_str(&format!(
577        "- **Total Commits**: {}\n",
578        analysis.total_commits
579    ));
580    md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
581    md.push_str(&format!(
582        "- **High Quality (>0.6)**: {}\n",
583        analysis.high_quality_count
584    ));
585    md.push_str(&format!(
586        "- **Low Quality (<0.4)**: {}\n\n",
587        analysis.low_quality_count
588    ));
589
590    md.push_str("## Quality Breakdown\n\n");
591    md.push_str("| Hash | Author | Score | Level | Message |\n");
592    md.push_str("|------|--------|------:|-------|--------|\n");
593    for commit in analysis.commits.iter().take(20) {
594        let msg = if commit.commit_message.len() > 40 {
595            format!("{}...", &commit.commit_message[..37])
596        } else {
597            commit.commit_message.clone()
598        };
599        md.push_str(&format!(
600            "| `{}` | {} | {:.2} | {} | {} |\n",
601            commit.commit_hash,
602            commit.author,
603            commit.score,
604            commit.quality_level(),
605            msg
606        ));
607    }
608    md
609}
610
611fn timeline_to_markdown(timeline: &ActivityTimeline) -> String {
612    let mut md = String::new();
613    md.push_str("# Activity Timeline\n\n");
614    md.push_str(&format!(
615        "- **Total Commits**: {}\n",
616        timeline.total_commits
617    ));
618    md.push_str(&format!("- **Peak Time**: {}\n\n", timeline.peak_summary()));
619
620    md.push_str("## Weekly Activity Heatmap\n\n");
621    md.push_str("```\n");
622    md.push_str("Hour:  00 03 06 09 12 15 18 21\n");
623    md.push_str("       ┌──────────────────────────\n");
624    for day in 0..7 {
625        md.push_str(&format!(" {}  │ ", ActivityTimeline::day_name(day)));
626        for hour in (0..24).step_by(3) {
627            let level = timeline.heat_level(day, hour);
628            md.push_str(ActivityTimeline::heat_char(level));
629            md.push(' ');
630        }
631        md.push('\n');
632    }
633    md.push_str("```\n");
634    md
635}
636
637fn bus_factor_to_markdown(analysis: &BusFactorAnalysis) -> String {
638    let mut md = String::new();
639    md.push_str("# Bus Factor Analysis\n\n");
640    md.push_str(&format!(
641        "- **Paths Analyzed**: {}\n",
642        analysis.total_paths_analyzed
643    ));
644    md.push_str(&format!(
645        "- **High Risk (Bus Factor = 1)**: {}\n",
646        analysis.high_risk_count
647    ));
648    md.push_str(&format!(
649        "- **Medium Risk (Bus Factor = 2)**: {}\n\n",
650        analysis.medium_risk_count
651    ));
652
653    if analysis.high_risk_count > 0 {
654        md.push_str("## ⚠️ High Risk Areas\n\n");
655        md.push_str("These areas have only **1 person** with significant knowledge:\n\n");
656        md.push_str("| Path | Bus Factor | Primary Contributor | Ownership |\n");
657        md.push_str("|------|:----------:|---------------------|----------:|\n");
658        for entry in analysis.entries.iter().filter(|e| e.bus_factor == 1).take(20) {
659            if let Some(c) = entry.contributors.first() {
660                md.push_str(&format!(
661                    "| `{}/` | {} | {} | {:.1}% |\n",
662                    entry.path, entry.bus_factor, c.name, c.contribution_percent
663                ));
664            }
665        }
666        md.push('\n');
667    }
668
669    md.push_str("## All Areas by Risk\n\n");
670    md.push_str("| Path | Bus Factor | Risk | Top Contributors |\n");
671    md.push_str("|------|:----------:|------|------------------|\n");
672    for entry in analysis.entries.iter().take(30) {
673        let contributors: Vec<String> = entry
674            .contributors
675            .iter()
676            .take(3)
677            .map(|c| format!("{} ({:.0}%)", c.name, c.contribution_percent))
678            .collect();
679        md.push_str(&format!(
680            "| `{}/` | {} | {} | {} |\n",
681            entry.path,
682            entry.bus_factor,
683            entry.risk_level.display_name(),
684            contributors.join(", ")
685        ));
686    }
687    md
688}
689
690fn tech_debt_to_markdown(analysis: &TechDebtAnalysis) -> String {
691    let mut md = String::new();
692    md.push_str("# Technical Debt Analysis\n\n");
693    md.push_str(&format!(
694        "- **Files Analyzed**: {}\n",
695        analysis.total_files_analyzed
696    ));
697    md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
698    md.push_str(&format!(
699        "- **High Debt Files**: {}\n\n",
700        analysis.high_debt_count
701    ));
702
703    if analysis.high_debt_count > 0 {
704        md.push_str("## ⚠️ High Debt Files\n\n");
705        md.push_str("These files have high churn and complexity:\n\n");
706        md.push_str("| File | Score | Churn | Complexity | Changes |\n");
707        md.push_str("|------|------:|------:|-----------:|--------:|\n");
708        for entry in analysis
709            .entries
710            .iter()
711            .filter(|e| e.debt_level == crate::stats::TechDebtLevel::High)
712            .take(20)
713        {
714            md.push_str(&format!(
715                "| `{}` | {:.2} | {:.2} | {:.2} | {} |\n",
716                entry.path,
717                entry.score,
718                entry.churn_score,
719                entry.complexity_score,
720                entry.change_count
721            ));
722        }
723        md.push('\n');
724    }
725
726    md.push_str("## All Files by Debt Score\n\n");
727    md.push_str("| File | Score | Level | Changes | Total Lines |\n");
728    md.push_str("|------|------:|-------|--------:|------------:|\n");
729    for entry in analysis.entries.iter().take(30) {
730        md.push_str(&format!(
731            "| `{}` | {:.2} | {} | {} | {} |\n",
732            entry.path,
733            entry.score,
734            entry.debt_level.display_name(),
735            entry.change_count,
736            entry.total_changes
737        ));
738    }
739    md
740}
741
742fn log_to_markdown(events: &[GitEvent]) -> String {
743    let mut md = String::new();
744    md.push_str("# Recent Commits\n\n");
745    md.push_str(&format!("**Showing**: {} commits\n\n", events.len()));
746
747    md.push_str("| Hash | Author | Date | Message |\n");
748    md.push_str("|------|--------|------|--------|\n");
749    for event in events {
750        let date = event.timestamp.format("%Y-%m-%d");
751        let msg = if event.message.len() > 50 {
752            format!("{}...", &event.message[..47])
753        } else {
754            event.message.clone()
755        };
756        md.push_str(&format!(
757            "| `{}` | {} | {} | {} |\n",
758            event.short_hash, event.author, date, msg
759        ));
760    }
761    md
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn test_parse_cli_args_stats() {
770        assert_eq!(
771            CliCommand::Stats {
772                format: OutputFormat::Json
773            },
774            CliCommand::Stats {
775                format: OutputFormat::Json
776            }
777        );
778    }
779
780    #[test]
781    fn test_parse_cli_args_help() {
782        assert_eq!(CliCommand::Help, CliCommand::Help);
783    }
784
785    #[test]
786    fn test_parse_cli_args_version() {
787        assert_eq!(CliCommand::Version, CliCommand::Version);
788    }
789
790    #[test]
791    fn test_parse_cli_args_log_default() {
792        let log = CliCommand::Log {
793            limit: 10,
794            format: OutputFormat::Json,
795        };
796        if let CliCommand::Log { limit, .. } = log {
797            assert_eq!(limit, 10);
798        }
799    }
800
801    #[test]
802    fn test_output_format_from_str() {
803        assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
804        assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json));
805        assert_eq!(OutputFormat::from_str("md"), Some(OutputFormat::Markdown));
806        assert_eq!(
807            OutputFormat::from_str("markdown"),
808            Some(OutputFormat::Markdown)
809        );
810        assert_eq!(OutputFormat::from_str("invalid"), None);
811    }
812}