use crate::output::{explain, policy};
use crate::util::{find_repo_root, write_html_report};
use crate::{OutputFormat, OutputLevel, OutputMode};
use anyhow::Context;
use hotspots_core::delta::Delta;
use hotspots_core::gate::{check_gate, GateConfig, GateVerdict};
use hotspots_core::snapshot::{self, Snapshot};
use hotspots_core::TouchMode;
use hotspots_core::{analyze_with_progress, AnalysisOptions};
use hotspots_core::{delta, git};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
pub(crate) struct AnalyzeArgs {
pub path: PathBuf,
pub format: OutputFormat,
pub mode: Option<OutputMode>,
pub policy: bool,
pub top: Option<usize>,
pub min_lrs: Option<f64>,
pub config_path: Option<PathBuf>,
pub output: Option<PathBuf>,
pub explain: bool,
pub force: bool,
pub no_persist: bool,
pub level: Option<OutputLevel>,
pub per_function_touches: bool,
pub all_functions: bool,
pub include_models: bool,
pub explain_patterns: bool,
pub source_url: Option<String>,
pub jobs: Option<usize>,
pub callgraph_skip_above: Option<usize>,
pub no_per_function_touches: bool,
pub skip_touch_metrics: bool,
pub hybrid_touches: Option<usize>,
pub skip_gate: bool,
}
pub(crate) fn validate_analyze_flags(args: &AnalyzeArgs) -> anyhow::Result<()> {
let AnalyzeArgs {
mode,
format,
policy,
explain,
per_function_touches,
no_persist,
force,
level,
all_functions,
include_models,
explain_patterns,
..
} = args;
if *policy && *mode != Some(OutputMode::Delta) {
anyhow::bail!("--policy flag is only valid with --mode delta");
}
if *explain && *mode != Some(OutputMode::Snapshot) {
anyhow::bail!("--explain flag is only valid with --mode snapshot");
}
if *per_function_touches && mode.is_none() {
anyhow::bail!(
"--per-function-touches is only valid with --mode snapshot, --mode delta, or --mode models"
);
}
if *no_persist {
if mode.is_none() {
anyhow::bail!("--no-persist is only valid with --mode snapshot or --mode delta");
}
if *mode == Some(OutputMode::Models) {
anyhow::bail!("--no-persist is only valid with --mode snapshot or --mode delta");
}
if *force {
anyhow::bail!("--no-persist and --force are mutually exclusive");
}
}
if level.is_some() {
if *mode != Some(OutputMode::Snapshot) {
anyhow::bail!("--level is only valid with --mode snapshot");
}
if !matches!(format, OutputFormat::Text) {
anyhow::bail!("--level is only valid with --format text");
}
if *explain {
anyhow::bail!("--level and --explain are mutually exclusive");
}
}
if *all_functions
&& (*mode != Some(OutputMode::Snapshot) || !matches!(format, OutputFormat::Json))
{
anyhow::bail!("--all-functions is only valid with --mode snapshot --format json");
}
if *mode == Some(OutputMode::Models)
&& !matches!(format, OutputFormat::Text | OutputFormat::Json)
{
anyhow::bail!("--mode models supports --format text or --format json");
}
if *include_models
&& (*mode != Some(OutputMode::Snapshot)
|| !matches!(format, OutputFormat::Json | OutputFormat::Html))
{
anyhow::bail!("--include-models is only valid with --mode snapshot --format json/html");
}
if *explain_patterns && *mode != Some(OutputMode::Snapshot) && mode.is_some() {
anyhow::bail!("--explain-patterns is only valid with --mode snapshot or without --mode");
}
if matches!(format, OutputFormat::Sarif) && *mode != Some(OutputMode::Snapshot) {
anyhow::bail!("--format sarif requires --mode snapshot");
}
Ok(())
}
pub(crate) fn handle_analyze(args: AnalyzeArgs) -> anyhow::Result<()> {
validate_analyze_flags(&args)?;
let AnalyzeArgs {
path,
format,
mode,
policy,
top,
min_lrs,
config_path,
output,
explain,
force,
no_persist,
level,
per_function_touches,
no_per_function_touches,
skip_touch_metrics,
hybrid_touches,
all_functions,
include_models,
explain_patterns,
source_url,
jobs,
callgraph_skip_above,
skip_gate,
} = args;
if let Some(n) = jobs {
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build_global();
}
let normalized_path = if path.is_relative() {
std::env::current_dir()?.join(&path)
} else {
path
};
if !normalized_path.exists() {
anyhow::bail!("Path does not exist: {}", normalized_path.display());
}
let project_root = find_repo_root(&normalized_path).unwrap_or_else(|_| normalized_path.clone());
let resolved_config =
hotspots_core::config::load_and_resolve(&project_root, config_path.as_deref())
.context("failed to load configuration")?;
if let Some(ref p) = resolved_config.config_path {
eprintln!("Using config: {}", p.display());
}
let effective_min_lrs = min_lrs.or(resolved_config.min_lrs);
let effective_top = top.or(resolved_config.top_n);
let touch_args = TouchArgs {
no_per_function: no_per_function_touches,
per_function: per_function_touches,
hybrid: hybrid_touches,
skip: skip_touch_metrics,
};
let effective_touch_mode = resolve_touch_mode(
touch_args.no_per_function,
touch_args.per_function,
touch_args.hybrid.or(resolved_config.hybrid_touch_threshold),
resolved_config.per_function_touches,
);
if let Some(output_mode) = mode {
let result = handle_mode_output(
&normalized_path,
output_mode,
&resolved_config,
ModeOutputOptions {
format,
policy,
top: effective_top,
min_lrs: effective_min_lrs,
output,
explain,
force,
no_persist,
level,
touch_mode: effective_touch_mode,
all_functions,
include_models,
explain_patterns,
source_url,
callgraph_skip_above,
skip_touch_metrics: touch_args.skip,
skip_gate,
},
);
return result;
}
let repo_root_for_ranker =
find_repo_root(&normalized_path).unwrap_or_else(|_| normalized_path.clone());
let ranker_path = snapshot::hotspots_dir(&repo_root_for_ranker).join("ranker.json");
if ranker_path.exists() {
let result = handle_mode_output(
&normalized_path,
OutputMode::Snapshot,
&resolved_config,
ModeOutputOptions {
format,
policy: false,
top: effective_top,
min_lrs: effective_min_lrs,
output,
explain: matches!(format, OutputFormat::Text),
force,
no_persist: true, level,
touch_mode: effective_touch_mode,
all_functions: false,
include_models: false,
explain_patterns,
source_url,
callgraph_skip_above,
skip_touch_metrics: touch_args.skip,
skip_gate,
},
);
return result;
}
handle_default_output(
&normalized_path,
format,
explain_patterns,
effective_min_lrs,
effective_top,
&resolved_config,
)
}
struct TouchArgs {
no_per_function: bool,
per_function: bool,
hybrid: Option<usize>,
skip: bool,
}
fn handle_default_output(
path: &Path,
format: OutputFormat,
explain_patterns: bool,
min_lrs: Option<f64>,
top: Option<usize>,
resolved_config: &hotspots_core::ResolvedConfig,
) -> anyhow::Result<()> {
let analysis_progress = make_analysis_progress();
let explicit_top = top.or(resolved_config.top_n);
let limit = match explicit_top {
Some(0) => usize::MAX,
Some(n) => n,
None => 20,
};
let mut reports = analyze_with_progress(
path,
AnalysisOptions {
min_lrs,
top_n: if matches!(format, OutputFormat::Text) {
Some(limit).filter(|&n| n != usize::MAX)
} else {
explicit_top.filter(|&n| n != 0)
},
},
Some(resolved_config),
Some(analysis_progress.as_ref()),
)?;
if explain_patterns {
populate_pattern_details(&mut reports, resolved_config);
}
match format {
OutputFormat::Text => {
let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
print!(
"{}",
hotspots_core::render_text_grouped(&reports, limit, color)
);
}
OutputFormat::Json => println!("{}", hotspots_core::render_json(&reports)),
OutputFormat::Html | OutputFormat::Jsonl => {
anyhow::bail!("HTML/JSONL format requires --mode snapshot or --mode delta");
}
OutputFormat::Sarif => anyhow::bail!("SARIF format requires --mode snapshot"),
}
Ok(())
}
fn populate_pattern_details(
reports: &mut [hotspots_core::FunctionRiskReport],
resolved_config: &hotspots_core::ResolvedConfig,
) {
for report in reports.iter_mut() {
let t1 = hotspots_core::patterns::Tier1Input {
cc: report.metrics.cc as usize,
nd: report.metrics.nd as usize,
fo: report.metrics.fo as usize,
ns: report.metrics.ns as usize,
loc: report.metrics.loc as usize,
};
let t2 = hotspots_core::patterns::Tier2Input {
fan_in: None,
scc_size: None,
churn_lines: None,
days_since_last_change: None,
neighbor_churn: None,
is_entrypoint: false,
};
report.pattern_details = Some(hotspots_core::patterns::classify_detailed(
&t1,
&t2,
&resolved_config.pattern_thresholds,
));
}
}
pub(crate) struct ModeOutputOptions {
pub format: OutputFormat,
pub policy: bool,
pub top: Option<usize>,
pub min_lrs: Option<f64>,
pub output: Option<PathBuf>,
pub explain: bool,
pub force: bool,
pub no_persist: bool,
pub level: Option<OutputLevel>,
pub touch_mode: TouchMode,
pub all_functions: bool,
pub include_models: bool,
pub explain_patterns: bool,
pub source_url: Option<String>,
pub callgraph_skip_above: Option<usize>,
pub skip_touch_metrics: bool,
pub skip_gate: bool,
}
pub(crate) fn handle_mode_output(
path: &Path,
mode: OutputMode,
resolved_config: &hotspots_core::ResolvedConfig,
opts: ModeOutputOptions,
) -> anyhow::Result<()> {
let repo_root = find_repo_root(path)?;
let analysis_progress = make_analysis_progress();
let reports = analyze_with_progress(
path,
AnalysisOptions {
min_lrs: opts.min_lrs,
top_n: None,
},
Some(resolved_config),
Some(analysis_progress.as_ref()),
)?;
let pr_context = git::detect_pr_context();
match mode {
OutputMode::Snapshot => {
handle_snapshot_mode(path, &repo_root, resolved_config, reports, pr_context, opts)
}
OutputMode::Delta => {
handle_delta_mode(&repo_root, resolved_config, reports, pr_context, opts)
}
OutputMode::Models => handle_models_mode(path, &repo_root, resolved_config, reports, opts),
}
}
fn handle_snapshot_mode(
path: &Path,
repo_root: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
reports: Vec<hotspots_core::FunctionRiskReport>,
pr_context: hotspots_core::git::PrContext,
opts: ModeOutputOptions,
) -> anyhow::Result<()> {
let ModeOutputOptions {
format,
explain,
force,
no_persist,
level,
touch_mode,
all_functions,
include_models,
explain_patterns,
source_url,
callgraph_skip_above,
skip_touch_metrics,
skip_gate,
top,
output,
..
} = opts;
let mut snapshot = build_snapshot_via_db(
repo_root,
resolved_config,
reports,
touch_mode,
callgraph_skip_above,
skip_touch_metrics,
)
.context("failed to build enriched snapshot")?;
snapshot.populate_patterns(&resolved_config.pattern_thresholds);
if explain_patterns {
snapshot.populate_pattern_details(&resolved_config.pattern_thresholds);
}
{
use hotspots_core::trainer::{make_rel, repo_prefixes};
let (prefix_can, prefix_raw) = repo_prefixes(repo_root);
let partner_scores: std::collections::HashMap<String, f64> = snapshot
.functions
.iter()
.map(|f| {
let rel = make_rel(&f.file, &prefix_can, &prefix_raw);
(rel, f.activity_risk.unwrap_or(f.lrs))
})
.collect();
snapshot.populate_directed_coupling(repo_root, &partner_scores);
}
if !pr_context.is_pr && !no_persist {
snapshot::persist_snapshot(repo_root, &snapshot, force)
.context("failed to persist snapshot")?;
snapshot::append_to_index(repo_root, &snapshot).context("failed to update index")?;
}
let ranker_applied = apply_trained_ranker(repo_root, &mut snapshot);
if ranker_applied {
snapshot.compute_quadrants(resolved_config.driver_threshold_percentile, true);
}
let total_function_count = snapshot.functions.len();
if !skip_gate {
let gate_verdict = check_gate(repo_root, &snapshot.functions, &GateConfig::default());
if let GateVerdict::Suppressed {
p_at_10, threshold, ..
} = &gate_verdict
{
if ranker_applied {
eprintln!(
"hotspots: activity ranker P@10={p_at_10:.2} (< {threshold:.2}); using trained ranker instead."
);
} else {
eprintln!(
"hotspots: warning: activity ranker P@10={p_at_10:.2} < {threshold:.2} โ rankings may be misleading."
);
eprintln!(" Run `hotspots train` to fit a local ranker from this repo's fix history.");
}
}
}
apply_top_n(&mut snapshot, format, explain, level, top);
emit_snapshot_output(
&mut snapshot,
SnapshotOutputOpts {
format,
explain,
level,
top,
total_function_count,
output,
co_change_window_days: resolved_config.co_change_window_days,
co_change_min_count: resolved_config.co_change_min_count,
all_functions,
include_models,
source_url: source_url.clone(),
risk_thresholds: hotspots_core::risk::RiskThresholds {
moderate: resolved_config.moderate_threshold,
high: resolved_config.high_threshold,
critical: resolved_config.critical_threshold,
},
},
repo_root,
path,
)
}
fn handle_delta_mode(
repo_root: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
reports: Vec<hotspots_core::FunctionRiskReport>,
pr_context: hotspots_core::git::PrContext,
opts: ModeOutputOptions,
) -> anyhow::Result<()> {
let ModeOutputOptions {
format,
policy,
output,
source_url,
touch_mode,
callgraph_skip_above,
skip_touch_metrics,
..
} = opts;
let snapshot = build_enriched_snapshot(
repo_root,
resolved_config,
reports,
touch_mode,
callgraph_skip_above,
skip_touch_metrics,
)
.context("failed to build enriched snapshot")?;
let delta_val = if pr_context.is_pr {
compute_pr_delta(repo_root, &snapshot)?
} else {
delta::compute_delta(repo_root, &snapshot)?
};
let delta_with_extras = enrich_delta(repo_root, resolved_config, &snapshot, delta_val, policy)?;
if emit_delta_output(
&delta_with_extras,
format,
policy,
output,
source_url.as_deref(),
)? {
std::process::exit(1);
}
Ok(())
}
fn enrich_delta(
repo_root: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
snapshot: &Snapshot,
delta_val: delta::Delta,
policy: bool,
) -> anyhow::Result<delta::Delta> {
let mut unique_files: Vec<String> = snapshot
.functions
.iter()
.map(|f| f.file.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
unique_files.sort();
let files_as_str: Vec<&str> = unique_files.iter().map(|s| s.as_str()).collect();
let import_edges = hotspots_core::imports::resolve_file_deps(&files_as_str, repo_root);
let mut current_co_change = hotspots_core::git::extract_co_change_pairs(
repo_root,
resolved_config.co_change_window_days,
resolved_config.co_change_min_count,
)
.unwrap_or_default();
hotspots_core::aggregates::annotate_static_deps(
&mut current_co_change,
&import_edges,
repo_root,
);
let parent_sha = snapshot.commit.parents.first().cloned();
let prev_co_change: Vec<hotspots_core::git::CoChangePair> = parent_sha
.as_deref()
.and_then(|sha| {
hotspots_core::delta::load_parent_snapshot(repo_root, sha)
.ok()
.flatten()
})
.and_then(|s| s.aggregates)
.map(|a| a.co_change)
.unwrap_or_default();
let mut enriched = delta_val.clone();
if policy {
let policy_results = hotspots_core::policy::evaluate_policies(
&delta_val,
snapshot,
repo_root,
resolved_config,
)
.context("failed to evaluate policies")?;
if let Some(results) = policy_results {
enriched.policy = Some(results);
}
}
enriched.aggregates = Some(hotspots_core::aggregates::compute_delta_aggregates(
&delta_val,
¤t_co_change,
&prev_co_change,
));
Ok(enriched)
}
fn handle_models_mode(
path: &Path,
repo_root: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
reports: Vec<hotspots_core::FunctionRiskReport>,
opts: ModeOutputOptions,
) -> anyhow::Result<()> {
let ModeOutputOptions {
format,
top,
touch_mode,
callgraph_skip_above,
skip_touch_metrics,
..
} = opts;
let snapshot = build_enriched_snapshot(
repo_root,
resolved_config,
reports,
touch_mode,
callgraph_skip_above,
skip_touch_metrics,
)
.context("failed to build enriched snapshot")?;
let model_map = hotspots_core::models::compute_model_risk_map(path, repo_root, &snapshot, top)
.context("failed to compute model risk map")?;
match format {
OutputFormat::Text => {
print!(
"{}",
hotspots_core::models::render_model_risk_text(&model_map, top)
);
}
OutputFormat::Json => {
println!(
"{}",
hotspots_core::models::render_model_risk_json(&model_map)?
);
}
OutputFormat::Html | OutputFormat::Jsonl | OutputFormat::Sarif => {
unreachable!("validated by validate_analyze_flags")
}
}
Ok(())
}
struct SnapshotOutputOpts {
format: OutputFormat,
explain: bool,
level: Option<OutputLevel>,
top: Option<usize>,
total_function_count: usize,
output: Option<PathBuf>,
co_change_window_days: u64,
co_change_min_count: usize,
all_functions: bool,
include_models: bool,
source_url: Option<String>,
risk_thresholds: hotspots_core::risk::RiskThresholds,
}
fn emit_snapshot_output(
snapshot: &mut Snapshot,
opts: SnapshotOutputOpts,
repo_root: &Path,
analysis_path: &Path,
) -> anyhow::Result<()> {
match opts.format {
OutputFormat::Json => emit_json_output(snapshot, repo_root, analysis_path, opts),
OutputFormat::Jsonl => emit_jsonl_output(snapshot),
OutputFormat::Text => emit_text_output(snapshot, repo_root, opts),
OutputFormat::Html => emit_html_output(snapshot, repo_root, analysis_path, opts),
OutputFormat::Sarif => emit_sarif_output(snapshot, repo_root, opts),
}
}
fn emit_json_output(
snapshot: &mut Snapshot,
repo_root: &Path,
analysis_path: &Path,
opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
let SnapshotOutputOpts {
all_functions,
include_models,
co_change_window_days,
co_change_min_count,
output,
..
} = opts;
let aggregates = hotspots_core::aggregates::compute_snapshot_aggregates_with_models(
snapshot,
repo_root,
co_change_window_days,
co_change_min_count,
include_models.then_some(analysis_path),
);
if all_functions {
snapshot.aggregates = Some(aggregates);
write_json_snapshot(snapshot, output)
} else {
let agent_output = hotspots_core::aggregates::compute_agent_snapshot_output(
snapshot,
&aggregates,
repo_root,
);
write_json_agent(&agent_output, output)
}
}
fn emit_jsonl_output(snapshot: &mut Snapshot) -> anyhow::Result<()> {
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
snapshot
.write_jsonl_to(&mut out)
.context("failed to write snapshot JSONL")
}
fn emit_text_output(
snapshot: &mut Snapshot,
repo_root: &Path,
opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
let SnapshotOutputOpts {
level,
explain,
top,
total_function_count,
co_change_window_days,
co_change_min_count,
..
} = opts;
let aggregates = hotspots_core::aggregates::compute_snapshot_aggregates(
snapshot,
repo_root,
co_change_window_days,
co_change_min_count,
);
if level == Some(OutputLevel::File) {
explain::print_file_risk_output(&aggregates.file_risk, top)?;
} else if level == Some(OutputLevel::Module) {
explain::print_module_output(&aggregates.modules, top)?;
} else if explain {
let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
explain::print_explain_output(snapshot, total_function_count, color)?;
} else {
anyhow::bail!(
"text format without --explain is not supported for snapshot mode (use --format json or add --explain)"
);
}
Ok(())
}
fn emit_html_output(
snapshot: &mut Snapshot,
repo_root: &Path,
analysis_path: &Path,
opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
let SnapshotOutputOpts {
co_change_window_days,
co_change_min_count,
include_models,
source_url,
risk_thresholds,
output,
..
} = opts;
let aggregates = hotspots_core::aggregates::compute_snapshot_aggregates_with_models(
snapshot,
repo_root,
co_change_window_days,
co_change_min_count,
include_models.then_some(analysis_path),
);
snapshot.aggregates = Some(aggregates);
let history: Vec<_> = hotspots_core::trends::load_snapshot_window(repo_root, 30)
.unwrap_or_default()
.into_iter()
.filter_map(|s| s.summary.map(|sum| (s.commit, sum)))
.collect();
let html = hotspots_core::html::render_html_snapshot(
snapshot,
&history,
source_url.as_deref(),
&risk_thresholds,
);
let output_path = output.unwrap_or_else(|| PathBuf::from(".hotspots/report.html"));
write_html_report(&output_path, &html)?;
eprintln!("HTML report written to: {}", output_path.display());
Ok(())
}
fn emit_sarif_output(
snapshot: &mut Snapshot,
repo_root: &Path,
opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
let sarif = hotspots_core::sarif::render_sarif(snapshot, repo_root);
if let Some(output_path) = opts.output {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
std::fs::write(&output_path, &sarif)
.with_context(|| format!("failed to write SARIF to {}", output_path.display()))?;
eprintln!("SARIF report written to: {}", output_path.display());
} else {
println!("{sarif}");
}
Ok(())
}
fn apply_top_n(
snapshot: &mut Snapshot,
format: OutputFormat,
explain: bool,
level: Option<OutputLevel>,
top: Option<usize>,
) {
let is_aggregate_level = level == Some(OutputLevel::File) || level == Some(OutputLevel::Module);
let is_text = matches!(format, OutputFormat::Text);
if !is_aggregate_level && (top.is_some() || (is_text && explain)) {
snapshot.functions.sort_by(|a, b| {
let a_score = a.activity_risk.unwrap_or(a.lrs);
let b_score = b.activity_risk.unwrap_or(b.lrs);
b_score
.partial_cmp(&a_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let limit = match top {
Some(0) => usize::MAX,
Some(n) => n,
None if is_text => 20,
None => usize::MAX,
};
if limit != usize::MAX {
snapshot.functions.truncate(limit);
}
}
}
fn apply_trained_ranker(repo_root: &Path, snapshot: &mut Snapshot) -> bool {
let model_path = snapshot::hotspots_dir(repo_root).join("ranker.json");
if !model_path.exists() {
return false;
}
let model = match hotspots_core::trainer::RankerModel::load(&model_path) {
Ok(m) => m,
Err(e) => {
eprintln!("hotspots: warning: failed to load ranker.json: {e}");
return false;
}
};
for func in &mut snapshot.functions {
func.activity_risk = Some(hotspots_core::trainer::score(&model, func));
}
true
}
fn write_snapshot_json_file<F>(output_path: &Path, write: F) -> anyhow::Result<()>
where
F: FnOnce(&mut std::io::BufWriter<std::fs::File>) -> anyhow::Result<()>,
{
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
let file = std::fs::File::create(output_path)
.with_context(|| format!("failed to create {}", output_path.display()))?;
let mut out = std::io::BufWriter::new(file);
write(&mut out)
}
fn write_json_snapshot(snapshot: &Snapshot, output: Option<PathBuf>) -> anyhow::Result<()> {
if let Some(output_path) = output {
write_snapshot_json_file(&output_path, |out| {
snapshot
.write_json_to(out)
.context("failed to write snapshot JSON")
})?;
eprintln!("JSON report written to: {}", output_path.display());
} else {
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
snapshot
.write_json_to(&mut out)
.context("failed to write snapshot JSON")?;
}
Ok(())
}
fn write_json_agent(
agent_output: &hotspots_core::aggregates::AgentSnapshotOutput,
output: Option<PathBuf>,
) -> anyhow::Result<()> {
if let Some(output_path) = output {
write_snapshot_json_file(&output_path, |out| {
agent_output
.write_json_to(out)
.context("failed to write agent snapshot JSON")
})?;
eprintln!("JSON report written to: {}", output_path.display());
} else {
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
agent_output
.write_json_to(&mut out)
.context("failed to write agent snapshot JSON")?;
}
Ok(())
}
fn emit_delta_output(
delta_val: &Delta,
format: OutputFormat,
with_policy: bool,
output: Option<PathBuf>,
source_url: Option<&str>,
) -> anyhow::Result<bool> {
let has_blocking_failures = delta_val
.policy
.as_ref()
.map(|p| p.has_blocking_failures())
.unwrap_or(false);
match format {
OutputFormat::Json => {
println!("{}", delta_val.to_json()?);
}
OutputFormat::Jsonl => {
anyhow::bail!("JSONL format is not supported for delta mode (use --mode snapshot)");
}
OutputFormat::Text => {
emit_delta_text(delta_val, with_policy)?;
}
OutputFormat::Html => {
emit_delta_html(delta_val, source_url, output)?;
}
OutputFormat::Sarif => {
anyhow::bail!("SARIF format is not supported for delta mode (use --mode snapshot)");
}
}
Ok(has_blocking_failures)
}
fn emit_delta_text(delta_val: &Delta, with_policy: bool) -> anyhow::Result<()> {
if !with_policy {
anyhow::bail!(
"text format is not supported for delta mode without --policy (use --format json)"
);
}
if let Some(ref policy_results) = delta_val.policy {
policy::print_policy_text_output(delta_val, policy_results)?;
} else {
println!("Delta Analysis");
println!("{}", "=".repeat(80));
println!("Baseline delta (no parent snapshot) - policy evaluation skipped.");
println!(
"\nDelta contains {} function changes.",
delta_val.deltas.len()
);
}
Ok(())
}
fn emit_delta_html(
delta_val: &Delta,
source_url: Option<&str>,
output: Option<PathBuf>,
) -> anyhow::Result<()> {
let html = hotspots_core::html::render_html_delta(delta_val, source_url);
let output_path = output.unwrap_or_else(|| PathBuf::from(".hotspots/report.html"));
write_html_report(&output_path, &html)?;
eprintln!("HTML report written to: {}", output_path.display());
Ok(())
}
fn compute_pr_delta(repo_root: &Path, snapshot: &Snapshot) -> anyhow::Result<delta::Delta> {
let merge_base_sha = git::resolve_merge_base_auto();
let fallback_sha = snapshot.commit.parents.first().map(|s| s.as_str());
let parent = load_merge_base_or_fallback(repo_root, merge_base_sha.as_deref(), fallback_sha)?;
delta::Delta::new(snapshot, parent.as_ref())
}
fn load_merge_base_or_fallback(
repo_root: &Path,
merge_base_sha: Option<&str>,
fallback_sha: Option<&str>,
) -> anyhow::Result<Option<Snapshot>> {
if let Some(sha) = merge_base_sha {
match delta::load_parent_snapshot(repo_root, sha)? {
Some(snap) => return Ok(Some(snap)),
None => {
eprintln!("Warning: merge-base snapshot not found, falling back to direct parent")
}
}
} else {
eprintln!("Warning: failed to resolve merge-base, falling back to direct parent");
}
if let Some(sha) = fallback_sha {
delta::load_parent_snapshot(repo_root, sha)
} else {
Ok(None)
}
}
pub(crate) fn build_snapshot_via_db(
repo_root: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
reports: Vec<hotspots_core::FunctionRiskReport>,
touch_mode: TouchMode,
callgraph_skip_above: Option<usize>,
skip_touch_metrics: bool,
) -> anyhow::Result<Snapshot> {
use hotspots_core::db::TempDb;
use hotspots_core::snapshot::{AnalysisInfo, CommitInfo, SNAPSHOT_SCHEMA_VERSION};
let git_context =
git::extract_git_context_at(repo_root).context("failed to extract git context")?;
let merge_base = hotspots_core::git::find_merge_base(repo_root);
let commit_info = CommitInfo::from(git_context.clone());
let sha = commit_info.sha.clone();
let db = TempDb::new().context("failed to create pipeline TempDb")?;
db.insert_reports(&commit_info, &reports)
.context("failed to insert reports into pipeline DB")?;
drop(reports);
if !git_context.parent_shas.is_empty() {
match git::extract_commit_churn_at(repo_root, &sha) {
Ok(churns) => {
let churn_map: std::collections::HashMap<String, _> = churns
.into_iter()
.map(|c| {
let absolute_path = repo_root.join(&c.file);
let normalized_path = absolute_path.to_string_lossy().to_string();
(normalized_path, c)
})
.collect();
db.update_churn(&sha, &churn_map)
.context("failed to update churn in pipeline DB")?;
}
Err(e) => eprintln!("Warning: failed to extract churn: {}", e),
}
}
let effective_skip_above = callgraph_skip_above.unwrap_or(resolved_config.callgraph_skip_above);
let function_count = db
.function_count(&sha)
.context("failed to count functions in pipeline DB")?;
if function_count <= effective_skip_above {
let call_graph = hotspots_core::build_call_graph_from_db(&db, &sha, repo_root)
.context("failed to build call graph from DB")?;
db.update_callgraph_metrics(
&sha,
&call_graph,
resolved_config.betweenness_exact_threshold,
resolved_config.betweenness_approx_k,
)
.context("failed to update callgraph metrics in pipeline DB")?;
} else {
eprintln!(
"info: call graph skipped ({} functions > --callgraph-skip-above {})",
function_count, effective_skip_above
);
}
let functions = db
.load_snapshot_functions(&sha)
.context("failed to load snapshot functions from pipeline DB")?;
let total_functions = functions.len();
let snapshot = Snapshot {
schema_version: SNAPSHOT_SCHEMA_VERSION,
commit: commit_info,
analysis: AnalysisInfo {
scope: "full".to_string(),
tool_version: env!("CARGO_PKG_VERSION").to_string(),
},
functions,
summary: None,
aggregates: None,
};
let mut enricher = snapshot::SnapshotEnricher::new(snapshot).with_subsystems(repo_root);
if !skip_touch_metrics {
let needs_progress = matches!(
touch_mode,
TouchMode::PerFunction | TouchMode::Hybrid { .. }
);
let progress = if needs_progress {
Some(make_progress_reporter(total_functions))
} else {
None
};
enricher = enricher.with_touch_metrics(repo_root, touch_mode, progress);
enricher = enricher.with_branch_recency_adjustment(repo_root, merge_base.as_ref());
}
let result = enricher
.enrich(
Some(&resolved_config.scoring_weights),
resolved_config.driver_threshold_percentile,
)
.build();
Ok(result)
}
pub(crate) fn build_enriched_snapshot(
repo_root: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
mut reports: Vec<hotspots_core::FunctionRiskReport>,
touch_mode: TouchMode,
callgraph_skip_above: Option<usize>,
skip_touch_metrics: bool,
) -> anyhow::Result<Snapshot> {
let git_context =
git::extract_git_context_at(repo_root).context("failed to extract git context")?;
let merge_base = hotspots_core::git::find_merge_base(repo_root);
let effective_skip_above = callgraph_skip_above.unwrap_or(resolved_config.callgraph_skip_above);
let call_graph = if reports.len() > effective_skip_above {
eprintln!(
"info: call graph skipped ({} functions > --callgraph-skip-above {})",
reports.len(),
effective_skip_above
);
None
} else {
hotspots_core::build_call_graph(&reports, repo_root).ok()
};
for r in &mut reports {
r.callees.clear();
r.callees.shrink_to_fit();
}
let total_functions = reports.len();
let mut enricher = snapshot::SnapshotEnricher::new(Snapshot::new(git_context.clone(), reports))
.with_subsystems(repo_root);
if !git_context.parent_shas.is_empty() {
match git::extract_commit_churn_at(repo_root, &git_context.head_sha) {
Ok(churns) => {
let churn_map: std::collections::HashMap<String, _> = churns
.into_iter()
.map(|c| {
let absolute_path = repo_root.join(&c.file);
let normalized_path = absolute_path.to_string_lossy().to_string();
(normalized_path, c)
})
.collect();
enricher = enricher.with_churn(&churn_map);
}
Err(e) => {
eprintln!("Warning: failed to extract churn: {}", e);
}
}
}
if !skip_touch_metrics {
let needs_progress = matches!(
touch_mode,
TouchMode::PerFunction | TouchMode::Hybrid { .. }
);
let progress = if needs_progress {
Some(make_progress_reporter(total_functions))
} else {
None
};
enricher = enricher.with_touch_metrics(repo_root, touch_mode, progress);
enricher = enricher.with_branch_recency_adjustment(repo_root, merge_base.as_ref());
}
if let Some(ref graph) = call_graph {
enricher = enricher.with_callgraph(
graph,
resolved_config.betweenness_exact_threshold,
resolved_config.betweenness_approx_k,
);
}
Ok(enricher
.enrich(
Some(&resolved_config.scoring_weights),
resolved_config.driver_threshold_percentile,
)
.build())
}
fn make_progress_reporter(total: usize) -> Box<dyn Fn(usize, usize)> {
use std::io::IsTerminal;
if total == 0 {
return Box::new(|_i: usize, _total: usize| {});
}
if std::io::stderr().is_terminal() {
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(total as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("Building touch cache [{bar:40}] {pos}/{len}")
.unwrap()
.progress_chars("##-"),
);
Box::new(move |i: usize, t: usize| {
pb.set_position(i as u64);
if i >= t {
pb.finish_and_clear();
}
})
} else {
let last_print = std::sync::Mutex::new(std::time::Instant::now());
Box::new(move |i: usize, t: usize| {
if i >= t {
eprintln!("touch cache: {t}/{t} functions (100%) [done]");
return;
}
if let Ok(mut last) = last_print.try_lock() {
if last.elapsed().as_secs() >= 30 {
let pct = (i * 100).checked_div(t).unwrap_or(0);
eprintln!("touch cache: {i}/{t} functions ({pct}%)");
*last = std::time::Instant::now();
}
}
})
}
}
pub(crate) fn analyze_and_persist_at_ref(
repo_root: &Path,
sha: &str,
resolved_config: &hotspots_core::ResolvedConfig,
) -> anyhow::Result<hotspots_core::snapshot::Snapshot> {
let worktree = hotspots_core::git::create_worktree(repo_root, sha)
.with_context(|| format!("failed to create worktree for {sha}"))?;
let options = AnalysisOptions {
min_lrs: None,
top_n: None,
};
let progress = make_analysis_progress();
let reports = analyze_with_progress(
&worktree.path,
options,
Some(resolved_config),
Some(progress.as_ref()),
)
.with_context(|| format!("analysis failed for ref {sha}"))?;
let mut snapshot = build_enriched_snapshot(
&worktree.path,
resolved_config,
reports,
TouchMode::File,
None,
false,
)
.with_context(|| format!("enrichment failed for ref {sha}"))?;
let worktree_prefix = worktree.path.to_string_lossy().replace('\\', "/");
let repo_prefix = repo_root.to_string_lossy().replace('\\', "/");
rewrite_worktree_paths(&mut snapshot, &worktree_prefix, &repo_prefix);
snapshot.populate_patterns(&resolved_config.pattern_thresholds);
hotspots_core::snapshot::persist_snapshot(repo_root, &snapshot, false)
.with_context(|| format!("failed to persist snapshot for {sha}"))?;
hotspots_core::snapshot::append_to_index(repo_root, &snapshot)
.with_context(|| format!("failed to update index for {sha}"))?;
Ok(snapshot)
}
fn rewrite_worktree_paths(snapshot: &mut Snapshot, worktree_prefix: &str, repo_prefix: &str) {
for f in &mut snapshot.functions {
if f.file.starts_with(worktree_prefix) {
let rel = f.file[worktree_prefix.len()..].to_string();
let old_file = f.file.clone();
f.file = format!("{}{}", repo_prefix, rel);
if let Some(symbol) = f.function_id.strip_prefix(&format!("{old_file}::")) {
f.function_id = format!("{}::{}", f.file, symbol);
}
}
}
}
fn resolve_touch_mode(
no_per_function: bool,
per_function: bool,
hybrid_threshold: Option<usize>,
config_per_function: bool,
) -> TouchMode {
const DEFAULT_HYBRID_THRESHOLD: usize = 5;
if no_per_function {
TouchMode::File
} else if let Some(threshold) = hybrid_threshold {
TouchMode::Hybrid { threshold }
} else if per_function || config_per_function {
TouchMode::PerFunction
} else {
TouchMode::Hybrid {
threshold: DEFAULT_HYBRID_THRESHOLD,
}
}
}
pub(crate) fn make_analysis_progress() -> Box<dyn Fn(usize, usize) + Send + Sync> {
use std::io::IsTerminal;
if !std::io::stderr().is_terminal() {
let last_print = std::sync::Mutex::new(std::time::Instant::now());
return Box::new(move |done: usize, total: usize| {
if done == 0 {
eprintln!("Analyzing: 0/{total} files");
return;
}
if done >= total {
eprintln!("Analyzing: {done}/{total} files [done]");
return;
}
if let Ok(mut last) = last_print.try_lock() {
if last.elapsed().as_secs() >= 30 {
let pct = (done as f64 / total as f64 * 100.0) as usize;
eprintln!("Analyzing: {done}/{total} files ({pct}%)");
*last = std::time::Instant::now();
}
}
});
}
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(0);
pb.set_style(
ProgressStyle::default_bar()
.template("Analyzing [{bar:40}] {pos}/{len} files {elapsed} ยท ~{eta} remaining")
.unwrap()
.progress_chars("##-"),
);
Box::new(move |done: usize, total: usize| {
if done == 0 {
if total == 0 {
pb.finish_and_clear();
return;
}
pb.set_length(total as u64);
pb.set_position(0);
} else {
pb.set_position(done as u64);
if done >= total {
pb.finish_and_clear();
}
}
})
}