use std::env;
use anyhow::Result;
use git2::Repository;
use crate::config::LanguageConfig;
use crate::event::GitEvent;
use crate::export::{
bus_factor_to_json, coupling_to_json, health_to_json, health_to_markdown, heatmap_to_json,
impact_to_json, log_to_json, ownership_to_json, quality_to_json, stats_to_json,
tech_debt_to_json, timeline_to_json,
};
use crate::git::{get_commit_files, load_events};
use crate::insights::{
actions_to_markdown, build_context_pack, build_context_summary, build_handoff_context,
build_next_actions, build_review_pack, explain_recommendation, handoff_to_markdown,
pack_to_markdown, review_pack_to_markdown, summary_to_markdown, verify_patch_risk,
};
use crate::stats::{
calculate_activity_timeline, calculate_bus_factor, calculate_change_coupling,
calculate_file_heatmap, calculate_impact_scores, calculate_ownership, calculate_project_health,
calculate_quality_scores, calculate_stats, calculate_tech_debt,
};
use crate::{
clear_analysis_cache, load_metrics, load_or_compute_with_repo, record_quick_action_usage,
reset_metrics,
};
use crate::{load_cached_review_pack, save_cached_review_pack};
const MAX_LOG_LIMIT: usize = 10000;
const DEFAULT_LOG_LIMIT: usize = 10;
const MAX_EVENTS_FOR_STATS: usize = 2000;
const ANALYSIS_CACHE_TTL_HOURS: u64 = 24;
const _: () = {
assert!(MAX_LOG_LIMIT <= 10000, "MAX_LOG_LIMIT must be reasonable");
assert!(MAX_LOG_LIMIT > 0, "MAX_LOG_LIMIT must be positive");
assert!(
DEFAULT_LOG_LIMIT <= MAX_LOG_LIMIT,
"DEFAULT must not exceed MAX"
);
assert!(
MAX_EVENTS_FOR_STATS <= 10000,
"MAX_EVENTS_FOR_STATS must be reasonable"
);
assert!(
MAX_EVENTS_FOR_STATS > 0,
"MAX_EVENTS_FOR_STATS must be positive"
);
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
#[default]
Json,
Markdown,
}
impl OutputFormat {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"json" => Some(OutputFormat::Json),
"md" | "markdown" => Some(OutputFormat::Markdown),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CliCommand {
Benchmark,
Stats { format: OutputFormat },
Heatmap { format: OutputFormat },
Impact { format: OutputFormat },
Coupling { format: OutputFormat },
Ownership { format: OutputFormat },
Quality { format: OutputFormat },
Timeline { format: OutputFormat },
BusFactor { format: OutputFormat },
TechDebt { format: OutputFormat },
Health { format: OutputFormat },
Summary { format: OutputFormat },
Pack { format: OutputFormat },
ReviewPack { format: OutputFormat },
NextActions { format: OutputFormat },
Why { id: String, format: OutputFormat },
Verify { format: OutputFormat },
QuickAction {
id: String,
compact: bool,
format: OutputFormat,
},
Handoff {
target: String,
format: OutputFormat,
},
Log { limit: usize, format: OutputFormat },
ClearCache,
Metrics { scope: String, format: OutputFormat },
MetricsReset,
Help,
Version,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiFocusTarget {
Risk,
Review,
History,
Files,
}
impl TuiFocusTarget {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"risk" => Some(Self::Risk),
"review" => Some(Self::Review),
"history" => Some(Self::History),
"files" => Some(Self::Files),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TuiOptions {
pub focus: Option<TuiFocusTarget>,
}
pub fn parse_cli_args() -> Option<CliCommand> {
let args: Vec<String> = env::args().collect();
if args.len() <= 1 {
return None;
}
let format = find_format_option(&args);
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--benchmark" => return Some(CliCommand::Benchmark),
"--stats" => return Some(CliCommand::Stats { format }),
"--heatmap" => return Some(CliCommand::Heatmap { format }),
"--impact" => return Some(CliCommand::Impact { format }),
"--coupling" => return Some(CliCommand::Coupling { format }),
"--ownership" => return Some(CliCommand::Ownership { format }),
"--quality" => return Some(CliCommand::Quality { format }),
"--timeline" => return Some(CliCommand::Timeline { format }),
"--bus-factor" => return Some(CliCommand::BusFactor { format }),
"--tech-debt" => return Some(CliCommand::TechDebt { format }),
"--health" => return Some(CliCommand::Health { format }),
"--summary" => return Some(CliCommand::Summary { format }),
"--pack" => return Some(CliCommand::Pack { format }),
"--review-pack" => return Some(CliCommand::ReviewPack { format }),
"--next-actions" => return Some(CliCommand::NextActions { format }),
"--verify" => return Some(CliCommand::Verify { format }),
"--quick-action" => {
return Some(parse_quick_action_option(&args, i, format));
}
"--why" => {
let id = args
.get(i + 1)
.filter(|s| !s.starts_with('-'))
.cloned()
.unwrap_or_else(|| "act-ship".to_string());
return Some(CliCommand::Why { id, format });
}
"--handoff" => {
let target = if i + 2 < args.len() && args[i + 1] == "--target" {
args[i + 2].clone()
} else {
"claude".to_string()
};
return Some(CliCommand::Handoff { target, format });
}
"--clear-cache" => return Some(CliCommand::ClearCache),
"--metrics" => {
let scope = args
.get(i + 1)
.filter(|s| !s.starts_with('-'))
.cloned()
.unwrap_or_else(|| "quick-actions".to_string());
return Some(CliCommand::Metrics { scope, format });
}
"--metrics-reset" => return Some(CliCommand::MetricsReset),
"--log" => {
return Some(parse_log_option(&args, i, format));
}
"--help" | "-h" => return Some(CliCommand::Help),
"--version" | "-V" => return Some(CliCommand::Version),
_ => {}
}
i += 1;
}
None
}
pub fn parse_tui_options(args: &[String]) -> TuiOptions {
let mut options = TuiOptions::default();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--layout" | "--pane-width" => {
if args.get(i + 1).is_some() {
i += 1;
}
}
"--focus" => {
if let Some(value) = args.get(i + 1) {
options.focus = TuiFocusTarget::from_str(value);
i += 1;
}
}
_ => {}
}
i += 1;
}
options
}
fn find_format_option(args: &[String]) -> OutputFormat {
for i in 0..args.len() {
if args[i] == "--format" && i + 1 < args.len() {
if let Some(fmt) = OutputFormat::from_str(&args[i + 1]) {
return fmt;
}
}
}
OutputFormat::default()
}
fn find_quick_action_format(args: &[String]) -> Option<&str> {
for i in 0..args.len() {
if args[i] == "--quick-action-format" && i + 1 < args.len() {
let value = args[i + 1].as_str();
if value == "compact" || value == "full" {
return Some(value);
}
}
}
None
}
fn parse_log_option(args: &[String], index: usize, format: OutputFormat) -> CliCommand {
let limit = if index + 2 < args.len() && args[index + 1] == "-n" {
match args[index + 2].parse::<usize>() {
Ok(n) if n > 0 && n <= MAX_LOG_LIMIT => n,
Ok(n) if n > MAX_LOG_LIMIT => {
eprintln!(
"Warning: limit {} exceeds maximum ({}), using maximum",
n, MAX_LOG_LIMIT
);
MAX_LOG_LIMIT
}
_ => {
eprintln!(
"Warning: invalid limit value, using default ({})",
DEFAULT_LOG_LIMIT
);
DEFAULT_LOG_LIMIT
}
}
} else {
DEFAULT_LOG_LIMIT
};
CliCommand::Log { limit, format }
}
fn parse_quick_action_option(args: &[String], index: usize, format: OutputFormat) -> CliCommand {
let id = args
.get(index + 1)
.filter(|s| !s.starts_with('-'))
.cloned()
.unwrap_or_else(|| "risk-summary".to_string());
let compact = find_quick_action_format(args)
.map(|s| s == "compact")
.unwrap_or(true);
CliCommand::QuickAction {
id,
compact,
format,
}
}
pub fn run_cli_mode(command: CliCommand) -> Result<()> {
match command {
CliCommand::Benchmark => {
Ok(())
}
CliCommand::ClearCache => run_clear_cache(),
CliCommand::Metrics { scope, format } => run_metrics(scope, format),
CliCommand::MetricsReset => run_metrics_reset(),
CliCommand::Help => run_help(),
CliCommand::Version => run_version(),
_ => {
let repo = Repository::discover(".").map_err(|_| {
anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
})?;
run_cli_mode_with_repo(command, &repo)
}
}
}
fn run_cli_mode_with_repo(command: CliCommand, repo: &Repository) -> Result<()> {
match command {
CliCommand::Stats { format } => run_stats(repo, format),
CliCommand::Heatmap { format } => run_heatmap(repo, format),
CliCommand::Impact { format } => run_impact(repo, format),
CliCommand::Coupling { format } => run_coupling(repo, format),
CliCommand::Ownership { format } => run_ownership(repo, format),
CliCommand::Quality { format } => run_quality(repo, format),
CliCommand::Timeline { format } => run_timeline(repo, format),
CliCommand::BusFactor { format } => run_bus_factor(repo, format),
CliCommand::TechDebt { format } => run_tech_debt(repo, format),
CliCommand::Health { format } => run_health(repo, format),
CliCommand::Summary { format } => run_summary(repo, format),
CliCommand::Pack { format } => run_pack(repo, format),
CliCommand::ReviewPack { format } => run_review_pack(repo, format),
CliCommand::NextActions { format } => run_next_actions(repo, format),
CliCommand::Why { id, format } => run_why(repo, id, format),
CliCommand::Verify { format } => run_verify(repo, format),
CliCommand::QuickAction {
id,
compact,
format,
} => run_quick_action(repo, id, compact, format),
CliCommand::Handoff { target, format } => run_handoff(repo, target, format),
CliCommand::Log { limit, format } => run_log(repo, limit, format),
CliCommand::Benchmark
| CliCommand::ClearCache
| CliCommand::Metrics { .. }
| CliCommand::MetricsReset
| CliCommand::Help
| CliCommand::Version => unreachable!(),
}
}
fn run_stats(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("stats_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let stats = calculate_stats(&event_refs);
match format {
OutputFormat::Json => stats_to_json(&stats),
OutputFormat::Markdown => Ok(stats_to_markdown(&stats)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_heatmap(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("heatmap_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
match format {
OutputFormat::Json => heatmap_to_json(&heatmap),
OutputFormat::Markdown => Ok(heatmap_to_markdown(&heatmap)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_impact(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("impact_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
let analysis =
calculate_impact_scores(&event_refs, |hash| get_commit_files(hash).ok(), &heatmap);
match format {
OutputFormat::Json => impact_to_json(&analysis),
OutputFormat::Markdown => Ok(impact_to_markdown(&analysis)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_coupling(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("coupling_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_change_coupling(
&event_refs,
|hash| get_commit_files(hash).ok(),
5, 0.3, );
match format {
OutputFormat::Json => coupling_to_json(&analysis),
OutputFormat::Markdown => Ok(coupling_to_markdown(&analysis)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_ownership(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("ownership_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let ownership = calculate_ownership(&event_refs, |hash| get_commit_files(hash).ok());
match format {
OutputFormat::Json => ownership_to_json(&ownership),
OutputFormat::Markdown => Ok(ownership_to_markdown(&ownership)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_quality(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("quality_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let coupling =
calculate_change_coupling(&event_refs, |hash| get_commit_files(hash).ok(), 5, 0.3);
let analysis = calculate_quality_scores(
&event_refs,
|hash| get_commit_files(hash).ok(),
&coupling,
);
match format {
OutputFormat::Json => quality_to_json(&analysis),
OutputFormat::Markdown => Ok(quality_to_markdown(&analysis)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_timeline(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("timeline_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let timeline = calculate_activity_timeline(&event_refs);
match format {
OutputFormat::Json => timeline_to_json(&timeline),
OutputFormat::Markdown => Ok(timeline_to_markdown(&timeline)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_bus_factor(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("bus_factor_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_bus_factor(&event_refs, |hash| get_commit_files(hash).ok(), 5);
match format {
OutputFormat::Json => bus_factor_to_json(&analysis),
OutputFormat::Markdown => Ok(bus_factor_to_markdown(&analysis)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_tech_debt(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("tech_debt_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_tech_debt(&event_refs, |hash| get_commit_files(hash).ok(), 3);
match format {
OutputFormat::Json => tech_debt_to_json(&analysis),
OutputFormat::Markdown => Ok(tech_debt_to_markdown(&analysis)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_health(repo: &Repository, format: OutputFormat) -> Result<()> {
let cache_key = format!("health_{:?}", format);
let output =
load_or_compute_with_repo(Some(repo), &cache_key, ANALYSIS_CACHE_TTL_HOURS, || {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
let coupling =
calculate_change_coupling(&event_refs, |hash| get_commit_files(hash).ok(), 5, 0.3);
let quality = calculate_quality_scores(
&event_refs,
|hash| get_commit_files(hash).ok(),
&coupling,
);
let bus_factor =
calculate_bus_factor(&event_refs, |hash| get_commit_files(hash).ok(), 5);
let tech_debt = calculate_tech_debt(&event_refs, |hash| get_commit_files(hash).ok(), 3);
let health = calculate_project_health(
&event_refs,
|hash| get_commit_files(hash).ok(),
Some(&quality),
Some(&bus_factor),
Some(&tech_debt),
&heatmap,
);
match format {
OutputFormat::Json => health_to_json(&health),
OutputFormat::Markdown => Ok(health_to_markdown(&health)),
}
})?;
println!("{}", output);
Ok(())
}
fn run_summary(repo: &Repository, format: OutputFormat) -> Result<()> {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let hot_path = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok())
.files
.first()
.map(|f| f.path.clone());
let selected = events.first().map(|e| e.short_hash.clone());
let summary = build_context_summary(Some(repo), hot_path, selected);
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(&summary)?,
OutputFormat::Markdown => summary_to_markdown(&summary),
};
println!("{}", output);
Ok(())
}
fn run_pack(repo: &Repository, format: OutputFormat) -> Result<()> {
let events = load_events_or_error()?;
let event_refs: Vec<&GitEvent> = events.iter().collect();
let selected = events.first().map(|e| e.short_hash.as_str());
let pack = build_context_pack(Some(repo), &event_refs, selected)?;
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(&pack)?,
OutputFormat::Markdown => pack_to_markdown(&pack),
};
println!("{}", output);
Ok(())
}
fn run_review_pack(repo: &Repository, format: OutputFormat) -> Result<()> {
let review_pack = load_or_build_review_pack(repo)?;
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(&review_pack)?,
OutputFormat::Markdown => review_pack_to_markdown(&review_pack),
};
println!("{}", output);
Ok(())
}
fn run_next_actions(repo: &Repository, format: OutputFormat) -> Result<()> {
let review_pack = load_or_build_review_pack(repo)?;
let actions = build_next_actions(&review_pack);
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(&actions)?,
OutputFormat::Markdown => actions_to_markdown(&actions),
};
println!("{}", output);
Ok(())
}
fn run_why(repo: &Repository, id: String, format: OutputFormat) -> Result<()> {
let review_pack = load_or_build_review_pack(repo)?;
let explanation = explain_recommendation(&review_pack, &id)
.unwrap_or_else(|| format!("No recommendation found for id: {}", id));
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(
&serde_json::json!({ "id": id, "explanation": explanation }),
)?,
OutputFormat::Markdown => format!("# Why\n\n- `{}`: {}\n", id, explanation),
};
println!("{}", output);
Ok(())
}
fn run_verify(repo: &Repository, format: OutputFormat) -> Result<()> {
let review_pack = load_or_build_review_pack(repo)?;
let verification = verify_patch_risk(&review_pack);
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(&verification)?,
OutputFormat::Markdown => format!(
"# Verify\n\n- **Verdict**: {}\n- **Risk Score**: {:.2}\n- **Confidence**: {:.2}\n",
verification
.get("verdict")
.and_then(|v| v.as_str())
.unwrap_or("unknown"),
verification
.get("risk_score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
verification
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
),
};
println!("{}", output);
Ok(())
}
fn run_handoff(repo: &Repository, target: String, format: OutputFormat) -> Result<()> {
let review_pack = load_or_build_review_pack(repo)?;
let handoff = build_handoff_context(&review_pack, &target);
let output = match format {
OutputFormat::Json => serde_json::to_string_pretty(&handoff)?,
OutputFormat::Markdown => handoff_to_markdown(&handoff),
};
println!("{}", output);
Ok(())
}
fn run_quick_action(
repo: &Repository,
id: String,
compact: bool,
format: OutputFormat,
) -> Result<()> {
let review_pack = load_or_build_review_pack(repo)?;
let output_value = match id.as_str() {
"risk-summary" => {
let verdict = verify_patch_risk(&review_pack);
serde_json::json!({
"id": id,
"risk_score": review_pack.risk_score,
"confidence": review_pack.confidence,
"verdict": verdict.get("verdict").and_then(|v| v.as_str()).unwrap_or("unknown"),
"top_risks": review_pack.top_risks.iter().take(3).map(|r| r.title.clone()).collect::<Vec<_>>(),
})
}
"review-pack" => {
if compact {
compact_review_pack_json(&review_pack)
} else {
serde_json::to_value(&review_pack)?
}
}
"next-actions" => {
let actions = build_next_actions(&review_pack);
if compact {
serde_json::json!({
"id": id,
"items": actions.iter().take(5).map(|a| serde_json::json!({"id": a.id, "priority": a.priority, "title": a.title})).collect::<Vec<_>>()
})
} else {
serde_json::to_value(actions)?
}
}
"verify" => verify_patch_risk(&review_pack),
"handoff-claude" => serde_json::to_value(build_handoff_context(&review_pack, "claude"))?,
"handoff-codex" => serde_json::to_value(build_handoff_context(&review_pack, "codex"))?,
"handoff-copilot" => serde_json::to_value(build_handoff_context(&review_pack, "copilot"))?,
_ => {
anyhow::bail!(
"Unknown quick action id: {} (supported: risk-summary, review-pack, next-actions, verify, handoff-claude, handoff-codex, handoff-copilot)",
id
)
}
};
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&output_value)?);
}
OutputFormat::Markdown => {
if id == "next-actions" {
let actions = build_next_actions(&review_pack);
println!("{}", actions_to_markdown(&actions));
} else if id == "review-pack" {
println!("{}", review_pack_to_markdown(&review_pack));
} else if id.starts_with("handoff-") {
let target = id.trim_start_matches("handoff-");
let handoff = build_handoff_context(&review_pack, target);
println!("{}", handoff_to_markdown(&handoff));
} else {
println!(
"```json\n{}\n```",
serde_json::to_string_pretty(&output_value)?
);
}
}
}
let _ = record_quick_action_usage(&id);
Ok(())
}
fn run_metrics(scope: String, format: OutputFormat) -> Result<()> {
if scope != "quick-actions" {
anyhow::bail!(
"Unknown metrics scope: {} (supported: quick-actions)",
scope
);
}
let metrics = load_metrics();
let mut rows: Vec<_> = metrics.quick_actions.into_iter().collect();
rows.sort_by(|a, b| b.1.count.cmp(&a.1.count));
match format {
OutputFormat::Json => {
let value = serde_json::json!({
"scope": "quick-actions",
"items": rows.into_iter().map(|(id, entry)| serde_json::json!({
"id": id,
"count": entry.count,
"last_used_at": entry.last_used_at
})).collect::<Vec<_>>()
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
OutputFormat::Markdown => {
println!("# Quick Action Metrics\n");
println!("| Action | Count | Last Used |");
println!("|---|---:|---|");
for (id, entry) in rows {
println!("| `{}` | {} | {} |", id, entry.count, entry.last_used_at);
}
}
}
Ok(())
}
fn run_metrics_reset() -> Result<()> {
reset_metrics()?;
let lang = LanguageConfig::load().language;
println!("{}", lang.cli_metrics_reset());
Ok(())
}
fn compact_review_pack_json(review_pack: &crate::insights::ReviewPack) -> serde_json::Value {
serde_json::json!({
"repo": review_pack.repo,
"branch": review_pack.branch,
"head": review_pack.head,
"risk_score": review_pack.risk_score,
"confidence": review_pack.confidence,
"summary": review_pack.summary,
"top_risks": review_pack.top_risks.iter().take(3).map(|r| serde_json::json!({"title": r.title, "severity": r.severity})).collect::<Vec<_>>(),
"recommended_actions": review_pack.recommended_actions.iter().take(5).map(|a| serde_json::json!({"id": a.id, "priority": a.priority, "title": a.title})).collect::<Vec<_>>()
})
}
fn load_or_build_review_pack(repo: &Repository) -> Result<crate::insights::ReviewPack> {
let events = load_events_or_error()?;
let selected = events.first().map(|e| e.short_hash.as_str());
if let Some(pack) = load_cached_review_pack(selected) {
return Ok(pack);
}
let event_refs: Vec<&GitEvent> = events.iter().collect();
let review_pack = build_review_pack(Some(repo), &event_refs, selected)?;
let _ = save_cached_review_pack(selected, &review_pack);
Ok(review_pack)
}
fn run_log(_repo: &Repository, limit: usize, format: OutputFormat) -> Result<()> {
let events = load_events_limited(limit)?;
let output = match format {
OutputFormat::Json => log_to_json(&events)?,
OutputFormat::Markdown => log_to_markdown(&events),
};
println!("{}", output);
Ok(())
}
fn run_clear_cache() -> Result<()> {
clear_analysis_cache()?;
let lang = LanguageConfig::load().language;
println!("{}", lang.cli_analysis_cache_cleared());
Ok(())
}
fn run_help() -> Result<()> {
let lang = LanguageConfig::load().language;
let help = format!(
r#"{title}
{usage}
gitstack [OPTIONS]
{analysis}
--stats Output author statistics
--heatmap Output file change heatmap
--impact Output Impact Score (commit influence)
--coupling Output Change Coupling (file co-change)
--ownership Output Code Ownership
--quality Output Commit Quality Score
--timeline Output Activity Timeline
--bus-factor Output Bus Factor analysis (knowledge risk)
--tech-debt Output Technical Debt Score
--health Output Project Health dashboard
--summary Output current narrow-pane context summary
--pack Output AI-ready insight pack
--review-pack Output AI review decision pack
--next-actions Output prioritized next actions
--why ID Explain a recommendation (e.g. act-add-tests)
--verify Verify current patch risk and confidence
--quick-action ID Execute one AI quick action
--handoff [--target claude|codex|copilot] Build handoff context for AI
--log -n N Output latest N commits (default: {default_limit}, max: {max_limit})
--metrics quick-actions Show local quick-action usage metrics
--metrics-reset Clear local metrics cache
--clear-cache Clear analysis cache files
{format}
--format json Output as JSON (default)
--format md Output as Markdown
{tui}
--focus risk|review|history|files
Startup focus preset for interactive mode
--quick-action-format compact|full
Output density for --quick-action (default: compact)
{general}
--help, -h Show this help message
--version, -V Show version information
{no_options}
{examples}
gitstack --stats # JSON output
gitstack --stats --format md # Markdown output
gitstack --bus-factor --format json # Bus factor analysis
gitstack --tech-debt --format md # Tech debt as Markdown
gitstack --summary --format md # Context summary
gitstack --pack --format json # AI-ready insight pack
gitstack --review-pack --format md # Review decision pack
gitstack --next-actions # Prioritized actions
gitstack --why act-add-tests # Explain recommendation
gitstack --verify # Risk verdict
gitstack --quick-action risk-summary # Compact risk decision signal
gitstack --metrics quick-actions # Local quick-action usage
gitstack --handoff --target codex # AI handoff context
gitstack --log -n 5 | jq . # Latest 5 commits
gitstack --focus risk # Risk-focused view
gitstack --focus files # File-status focused view
For more information, visit: https://github.com/Hiro-Chiba/gitstack"#,
title = lang.cli_help_title(),
usage = lang.cli_help_usage(),
analysis = lang.cli_help_analysis_options(),
format = lang.cli_help_format_options(),
tui = lang.cli_help_tui_options(),
general = lang.cli_help_general_options(),
no_options = lang.cli_help_no_options(),
examples = lang.cli_help_examples(),
default_limit = DEFAULT_LOG_LIMIT,
max_limit = MAX_LOG_LIMIT,
);
println!("{}", help);
Ok(())
}
fn run_version() -> Result<()> {
println!("gitstack {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
fn load_events_or_error() -> Result<Vec<GitEvent>> {
load_events(MAX_EVENTS_FOR_STATS)
.map_err(|_| anyhow::anyhow!("Error: Failed to load git history"))
}
fn load_events_limited(limit: usize) -> Result<Vec<GitEvent>> {
let safe_limit = limit.min(MAX_LOG_LIMIT);
load_events(safe_limit).map_err(|_| anyhow::anyhow!("Error: Failed to load git history"))
}
use crate::stats::{
ActivityTimeline, BusFactorAnalysis, ChangeCouplingAnalysis, CodeOwnership,
CommitImpactAnalysis, CommitQualityAnalysis, FileHeatmap, RepoStats, TechDebtAnalysis,
};
fn stats_to_markdown(stats: &RepoStats) -> String {
let mut md = String::new();
md.push_str("# Author Statistics\n\n");
md.push_str(&format!("- **Total Commits**: {}\n", stats.total_commits));
md.push_str(&format!(
"- **Total Insertions**: {}\n",
stats.total_insertions
));
md.push_str(&format!(
"- **Total Deletions**: {}\n",
stats.total_deletions
));
md.push_str(&format!("- **Authors**: {}\n\n", stats.author_count()));
md.push_str("## Top Contributors\n\n");
md.push_str("| Author | Commits | Lines (+/-) | % |\n");
md.push_str("|--------|--------:|------------:|--:|\n");
for author in stats.authors.iter().take(20) {
md.push_str(&format!(
"| {} | {} | +{} / -{} | {:.1}% |\n",
author.name,
author.commit_count,
author.insertions,
author.deletions,
author.commit_percentage(stats.total_commits)
));
}
md
}
fn heatmap_to_markdown(heatmap: &FileHeatmap) -> String {
let mut md = String::new();
md.push_str("# File Heatmap\n\n");
md.push_str(&format!("**Total Files**: {}\n\n", heatmap.total_files));
md.push_str("## Hot Files (Most Changed)\n\n");
md.push_str("| File | Changes | Heat |\n");
md.push_str("|------|--------:|:----:|\n");
for file in heatmap.files.iter().take(30) {
let heat_bar = file.heat_bar();
md.push_str(&format!(
"| `{}` | {} | {} |\n",
file.path, file.change_count, heat_bar
));
}
md
}
fn impact_to_markdown(analysis: &CommitImpactAnalysis) -> String {
let mut md = String::new();
md.push_str("# Impact Score Analysis\n\n");
md.push_str(&format!(
"- **Total Commits**: {}\n",
analysis.total_commits
));
md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
md.push_str(&format!(
"- **High Impact Commits**: {}\n\n",
analysis.high_impact_count
));
md.push_str("## High Impact Commits\n\n");
md.push_str("| Hash | Author | Score | Files | Message |\n");
md.push_str("|------|--------|------:|------:|--------|\n");
for commit in analysis.commits.iter().take(20) {
let msg = if commit.commit_message.chars().count() > 40 {
let truncated: String = commit.commit_message.chars().take(37).collect();
format!("{truncated}...")
} else {
commit.commit_message.clone()
};
md.push_str(&format!(
"| `{}` | {} | {:.2} | {} | {} |\n",
commit.commit_hash, commit.author, commit.score, commit.files_changed, msg
));
}
md
}
fn coupling_to_markdown(analysis: &ChangeCouplingAnalysis) -> String {
let mut md = String::new();
md.push_str("# Change Coupling Analysis\n\n");
md.push_str(&format!(
"- **Total Couplings**: {}\n",
analysis.couplings.len()
));
md.push_str(&format!(
"- **High Coupling (>70%)**: {}\n\n",
analysis.high_coupling_count
));
md.push_str("## File Couplings\n\n");
md.push_str("| File | Coupled With | Coupling | Co-Changes |\n");
md.push_str("|------|--------------|----------|------------|\n");
for coupling in analysis.couplings.iter().take(30) {
md.push_str(&format!(
"| `{}` | `{}` | {:.1}% | {} |\n",
coupling.file,
coupling.coupled_file,
coupling.coupling_percent * 100.0,
coupling.co_change_count
));
}
md
}
fn ownership_to_markdown(ownership: &CodeOwnership) -> String {
let mut md = String::new();
md.push_str("# Code Ownership\n\n");
md.push_str(&format!("**Total Files**: {}\n\n", ownership.total_files));
md.push_str("## Directory Ownership\n\n");
md.push_str("| Path | Primary Owner | Ownership | Commits |\n");
md.push_str("|------|---------------|-----------|--------:|\n");
for entry in ownership.entries.iter().filter(|e| e.is_directory).take(30) {
md.push_str(&format!(
"| `{}/` | {} | {:.1}% | {} |\n",
entry.path,
entry.primary_author,
entry.ownership_percentage(),
entry.total_commits
));
}
md
}
fn quality_to_markdown(analysis: &CommitQualityAnalysis) -> String {
let mut md = String::new();
md.push_str("# Commit Quality Analysis\n\n");
md.push_str(&format!(
"- **Total Commits**: {}\n",
analysis.total_commits
));
md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
md.push_str(&format!(
"- **High Quality (>0.6)**: {}\n",
analysis.high_quality_count
));
md.push_str(&format!(
"- **Low Quality (<0.4)**: {}\n\n",
analysis.low_quality_count
));
md.push_str("## Quality Breakdown\n\n");
md.push_str("| Hash | Author | Score | Level | Message |\n");
md.push_str("|------|--------|------:|-------|--------|\n");
for commit in analysis.commits.iter().take(20) {
let msg = if commit.commit_message.chars().count() > 40 {
let truncated: String = commit.commit_message.chars().take(37).collect();
format!("{truncated}...")
} else {
commit.commit_message.clone()
};
md.push_str(&format!(
"| `{}` | {} | {:.2} | {} | {} |\n",
commit.commit_hash,
commit.author,
commit.score,
commit.quality_level(),
msg
));
}
md
}
fn timeline_to_markdown(timeline: &ActivityTimeline) -> String {
let mut md = String::new();
md.push_str("# Activity Timeline\n\n");
md.push_str(&format!(
"- **Total Commits**: {}\n",
timeline.total_commits
));
md.push_str(&format!("- **Peak Time**: {}\n\n", timeline.peak_summary()));
md.push_str("## Weekly Activity Heatmap\n\n");
md.push_str("```\n");
md.push_str("Hour: 00 03 06 09 12 15 18 21\n");
md.push_str(" ┌──────────────────────────\n");
for day in 0..7 {
md.push_str(&format!(" {} │ ", ActivityTimeline::day_name(day)));
for hour in (0..24).step_by(3) {
let level = timeline.heat_level(day, hour);
md.push_str(ActivityTimeline::heat_char(level));
md.push(' ');
}
md.push('\n');
}
md.push_str("```\n");
md
}
fn bus_factor_to_markdown(analysis: &BusFactorAnalysis) -> String {
let mut md = String::new();
md.push_str("# Bus Factor Analysis\n\n");
md.push_str(&format!(
"- **Paths Analyzed**: {}\n",
analysis.total_paths_analyzed
));
md.push_str(&format!(
"- **High Risk (Bus Factor = 1)**: {}\n",
analysis.high_risk_count
));
md.push_str(&format!(
"- **Medium Risk (Bus Factor = 2)**: {}\n\n",
analysis.medium_risk_count
));
if analysis.high_risk_count > 0 {
md.push_str("## ⚠️ High Risk Areas\n\n");
md.push_str("These areas have only **1 person** with significant knowledge:\n\n");
md.push_str("| Path | Bus Factor | Primary Contributor | Ownership |\n");
md.push_str("|------|:----------:|---------------------|----------:|\n");
for entry in analysis
.entries
.iter()
.filter(|e| e.bus_factor == 1)
.take(20)
{
if let Some(c) = entry.contributors.first() {
md.push_str(&format!(
"| `{}/` | {} | {} | {:.1}% |\n",
entry.path, entry.bus_factor, c.name, c.contribution_percent
));
}
}
md.push('\n');
}
md.push_str("## All Areas by Risk\n\n");
md.push_str("| Path | Bus Factor | Risk | Top Contributors |\n");
md.push_str("|------|:----------:|------|------------------|\n");
for entry in analysis.entries.iter().take(30) {
let contributors: Vec<String> = entry
.contributors
.iter()
.take(3)
.map(|c| format!("{} ({:.0}%)", c.name, c.contribution_percent))
.collect();
md.push_str(&format!(
"| `{}/` | {} | {} | {} |\n",
entry.path,
entry.bus_factor,
entry.risk_level.display_name(),
contributors.join(", ")
));
}
md
}
fn tech_debt_to_markdown(analysis: &TechDebtAnalysis) -> String {
let mut md = String::new();
md.push_str("# Technical Debt Analysis\n\n");
md.push_str(&format!(
"- **Files Analyzed**: {}\n",
analysis.total_files_analyzed
));
md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
md.push_str(&format!(
"- **High Debt Files**: {}\n\n",
analysis.high_debt_count
));
if analysis.high_debt_count > 0 {
md.push_str("## ⚠️ High Debt Files\n\n");
md.push_str("These files have high churn and complexity:\n\n");
md.push_str("| File | Score | Churn | Complexity | Changes |\n");
md.push_str("|------|------:|------:|-----------:|--------:|\n");
for entry in analysis
.entries
.iter()
.filter(|e| e.debt_level == crate::stats::TechDebtLevel::High)
.take(20)
{
md.push_str(&format!(
"| `{}` | {:.2} | {:.2} | {:.2} | {} |\n",
entry.path,
entry.score,
entry.churn_score,
entry.complexity_score,
entry.change_count
));
}
md.push('\n');
}
md.push_str("## All Files by Debt Score\n\n");
md.push_str("| File | Score | Level | Changes | Total Lines |\n");
md.push_str("|------|------:|-------|--------:|------------:|\n");
for entry in analysis.entries.iter().take(30) {
md.push_str(&format!(
"| `{}` | {:.2} | {} | {} | {} |\n",
entry.path,
entry.score,
entry.debt_level.display_name(),
entry.change_count,
entry.total_changes
));
}
md
}
fn log_to_markdown(events: &[GitEvent]) -> String {
let mut md = String::new();
md.push_str("# Recent Commits\n\n");
md.push_str(&format!("**Showing**: {} commits\n\n", events.len()));
md.push_str("| Hash | Author | Date | Message |\n");
md.push_str("|------|--------|------|--------|\n");
for event in events {
let date = event.timestamp.format("%Y-%m-%d");
let msg = if event.message.chars().count() > 50 {
let truncated: String = event.message.chars().take(47).collect();
format!("{truncated}...")
} else {
event.message.clone()
};
md.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
event.short_hash, event.author, date, msg
));
}
md
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cli_args_stats() {
assert_eq!(
CliCommand::Stats {
format: OutputFormat::Json
},
CliCommand::Stats {
format: OutputFormat::Json
}
);
}
#[test]
fn test_parse_cli_args_help() {
assert_eq!(CliCommand::Help, CliCommand::Help);
}
#[test]
fn test_parse_cli_args_version() {
assert_eq!(CliCommand::Version, CliCommand::Version);
}
#[test]
fn test_parse_cli_args_log_default() {
let log = CliCommand::Log {
limit: 10,
format: OutputFormat::Json,
};
if let CliCommand::Log { limit, .. } = log {
assert_eq!(limit, 10);
}
}
#[test]
fn test_output_format_from_str() {
assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::from_str("md"), Some(OutputFormat::Markdown));
assert_eq!(
OutputFormat::from_str("markdown"),
Some(OutputFormat::Markdown)
);
assert_eq!(OutputFormat::from_str("invalid"), None);
}
#[test]
fn test_parse_tui_options_defaults() {
let args = vec!["gitstack".to_string()];
let opts = parse_tui_options(&args);
assert_eq!(opts.focus, None);
}
#[test]
fn test_parse_tui_options_valid_focus() {
let args = vec![
"gitstack".to_string(),
"--focus".to_string(),
"risk".to_string(),
];
let opts = parse_tui_options(&args);
assert_eq!(opts.focus, Some(TuiFocusTarget::Risk));
}
#[test]
fn test_parse_tui_options_legacy_layout_ignored() {
let args = vec![
"gitstack".to_string(),
"--layout".to_string(),
"micro".to_string(),
"--focus".to_string(),
"review".to_string(),
];
let opts = parse_tui_options(&args);
assert_eq!(opts.focus, Some(TuiFocusTarget::Review));
}
#[test]
fn test_parse_quick_action_defaults_compact() {
let args = vec![
"gitstack".to_string(),
"--quick-action".to_string(),
"verify".to_string(),
];
let cmd = parse_cli_args_from(&args).expect("command");
match cmd {
CliCommand::QuickAction { id, compact, .. } => {
assert_eq!(id, "verify");
assert!(compact);
}
_ => panic!("expected quick action"),
}
}
#[test]
fn test_parse_quick_action_full_mode() {
let args = vec![
"gitstack".to_string(),
"--quick-action".to_string(),
"review-pack".to_string(),
"--quick-action-format".to_string(),
"full".to_string(),
];
let cmd = parse_cli_args_from(&args).expect("command");
match cmd {
CliCommand::QuickAction { compact, .. } => assert!(!compact),
_ => panic!("expected quick action"),
}
}
#[test]
fn test_parse_metrics_command() {
let args = vec![
"gitstack".to_string(),
"--metrics".to_string(),
"quick-actions".to_string(),
];
let cmd = parse_metrics_args_from(&args).expect("metrics command");
match cmd {
CliCommand::Metrics { scope, .. } => assert_eq!(scope, "quick-actions"),
_ => panic!("expected metrics command"),
}
}
fn parse_cli_args_from(args: &[String]) -> Option<CliCommand> {
let format = super::find_format_option(args);
let mut i = 1;
while i < args.len() {
if args[i].as_str() == "--quick-action" {
let id = args
.get(i + 1)
.filter(|s| !s.starts_with('-'))
.cloned()
.unwrap_or_else(|| "risk-summary".to_string());
let compact = super::find_quick_action_format(args)
.map(|s| s == "compact")
.unwrap_or(true);
return Some(CliCommand::QuickAction {
id,
compact,
format,
});
}
i += 1;
}
None
}
fn parse_metrics_args_from(args: &[String]) -> Option<CliCommand> {
let format = super::find_format_option(args);
let mut i = 1;
while i < args.len() {
if args[i].as_str() == "--metrics" {
let scope = args
.get(i + 1)
.filter(|s| !s.starts_with('-'))
.cloned()
.unwrap_or_else(|| "quick-actions".to_string());
return Some(CliCommand::Metrics { scope, format });
}
i += 1;
}
None
}
}