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_smoothed, 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 touch_mode: Option<TouchModeArg>,
pub skip_gate: bool,
pub cold_start: bool,
pub axes: 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,
cold_start,
axes,
..
} = args;
if *cold_start && mode.is_some() {
anyhow::bail!("--cold-start is not compatible with --mode (it bypasses the trained-ranker/snapshot pipeline entirely)");
}
if *axes && mode.is_some() {
anyhow::bail!("--axes is not compatible with --mode (it bypasses the trained-ranker/snapshot pipeline entirely)");
}
if *axes && *cold_start {
anyhow::bail!("--axes and --cold-start are mutually exclusive");
}
if *policy && *mode != Some(OutputMode::Delta) {
anyhow::bail!("--policy flag is only valid with --mode delta");
}
if *explain && mode.is_some() && *mode != Some(OutputMode::Snapshot) {
anyhow::bail!("--explain is not compatible with --mode delta or --mode models");
}
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");
}
if matches!(format, OutputFormat::Csv) && *mode != Some(OutputMode::Snapshot) {
anyhow::bail!("--format csv requires --mode snapshot");
}
if matches!(format, OutputFormat::Xlsx) && *mode != Some(OutputMode::Snapshot) {
anyhow::bail!("--format xlsx 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,
touch_mode,
all_functions,
include_models,
explain_patterns,
source_url,
jobs,
callgraph_skip_above,
skip_gate,
cold_start,
axes,
} = args;
warn_deprecated_touch_flags(
no_per_function_touches,
per_function_touches,
skip_touch_metrics,
hybrid_touches,
);
{
let mut builder = rayon::ThreadPoolBuilder::new().stack_size(32 * 1024 * 1024);
if let Some(n) = jobs {
builder = builder.num_threads(n);
}
let _ = builder.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 (effective_touch_mode, effective_skip_touch_metrics) = resolve_touch_mode_and_skip(
touch_mode,
no_per_function_touches,
per_function_touches,
hybrid_touches,
skip_touch_metrics,
resolved_config.per_function_touches,
resolved_config.hybrid_touch_threshold,
);
if cold_start {
return handle_cold_start(
&normalized_path,
&resolved_config,
effective_touch_mode,
effective_top,
);
}
if axes {
return handle_axes(
&normalized_path,
&resolved_config,
effective_touch_mode,
effective_top,
format,
);
}
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: effective_skip_touch_metrics,
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: effective_skip_touch_metrics,
skip_gate,
},
);
return result;
}
handle_default_output(
&normalized_path,
format,
explain_patterns,
effective_min_lrs,
effective_top,
&resolved_config,
)
}
fn handle_cold_start(
path: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
touch_mode: TouchMode,
top: Option<usize>,
) -> anyhow::Result<()> {
let repo_root = find_repo_root(path)?;
let analysis_progress = make_analysis_progress();
let reports = analyze_with_progress(
path,
AnalysisOptions {
min_lrs: None,
top_n: None,
},
Some(resolved_config),
Some(analysis_progress.as_ref()),
)?;
let mut snapshot = build_enriched_snapshot(
&repo_root,
resolved_config,
reports,
touch_mode,
None,
true, )
.context("failed to build snapshot for cold-start ranking")?;
snapshot.populate_history_signals(&repo_root);
snapshot.populate_authors_90d(&repo_root);
let train_cfg = hotspots_core::trainer::TrainConfig {
gini_dead_zone: resolved_config.cold_start_gini_dead_zone,
..Default::default()
};
let result = hotspots_core::trainer::cold_start_rank(&snapshot, &repo_root, &train_cfg);
let route_label = match result.route {
hotspots_core::trainer::ColdStartRoute::Formula => "formula",
hotspots_core::trainer::ColdStartRoute::Anomaly => "anomaly",
hotspots_core::trainer::ColdStartRoute::UniformPrior => "uniform-prior",
};
println!("cold-start route: {route_label}");
if result.ranked.is_empty() {
println!("(uniform prior — no ranking to show; all files equally likely)");
return Ok(());
}
let limit = match top {
Some(0) => result.ranked.len(),
Some(n) => n,
None => 20,
};
for scored in result.ranked.iter().take(limit) {
println!("{:.4} {}", scored.score, scored.function_id);
}
Ok(())
}
fn handle_axes(
path: &Path,
resolved_config: &hotspots_core::ResolvedConfig,
touch_mode: TouchMode,
top: Option<usize>,
format: OutputFormat,
) -> anyhow::Result<()> {
use hotspots_core::ranking::{rank_by_axis, HotspotAxis};
if !matches!(format, OutputFormat::Text | OutputFormat::Json) {
anyhow::bail!("--axes only supports --format text or --format json");
}
let repo_root = find_repo_root(path)?;
let analysis_progress = make_analysis_progress();
let reports = analyze_with_progress(
path,
AnalysisOptions {
min_lrs: None,
top_n: None,
},
Some(resolved_config),
Some(analysis_progress.as_ref()),
)?;
let mut snapshot = build_enriched_snapshot(
&repo_root,
resolved_config,
reports,
touch_mode,
None,
false, )
.context("failed to build snapshot for --axes ranking")?;
snapshot.populate_history_signals(&repo_root);
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);
let top_n = match top {
Some(0) => snapshot.functions.len(),
Some(n) => n,
None => 10,
};
let sections = [
(HotspotAxis::Risk, "Risk Hotspots", "risk"),
(HotspotAxis::Coupling, "Coupling Hotspots", "coupling"),
(HotspotAxis::Ownership, "Ownership Hotspots", "ownership"),
(HotspotAxis::Centrality, "Centrality Hotspots", "centrality"),
];
if matches!(format, OutputFormat::Json) {
let obj: serde_json::Map<String, serde_json::Value> = sections
.iter()
.map(|(axis, _, key)| {
let ranked = rank_by_axis(&snapshot.functions, *axis, top_n);
(key.to_string(), serde_json::to_value(ranked).unwrap())
})
.collect();
println!("{}", serde_json::to_string_pretty(&obj)?);
return Ok(());
}
for (axis, header, _) in sections {
println!("{header}");
let ranked = rank_by_axis(&snapshot.functions, axis, top_n);
if ranked.is_empty() {
println!(" (no qualifying files)");
}
for r in &ranked {
println!(" {:.4} {}", r.score, r.file);
}
println!();
}
Ok(())
}
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"),
OutputFormat::Csv => anyhow::bail!("--format csv requires --mode snapshot"),
OutputFormat::Xlsx => anyhow::bail!("--format xlsx 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);
}
if !skip_touch_metrics {
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 !skip_touch_metrics && matches!(format, OutputFormat::Csv | OutputFormat::Xlsx) {
snapshot.populate_history_signals(repo_root);
}
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 applied_model_class = apply_trained_ranker(repo_root, &mut snapshot);
let ranker_applied = applied_model_class.is_some();
if let Some(model_class) = &applied_model_class {
let label = match model_class {
hotspots_core::trainer::ModelClass::Ridge => "Ridge",
hotspots_core::trainer::ModelClass::RandomForest => "RandomForest",
};
eprintln!("hotspots: using trained ranker (model class: {label})");
}
if ranker_applied {
snapshot.compute_quadrants(resolved_config.driver_threshold_percentile, true);
}
if ranker_applied && explain {
populate_explanations(&mut snapshot);
}
let total_function_count = snapshot.functions.len();
if !skip_gate {
let gate_verdict =
check_gate_smoothed(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
| OutputFormat::Csv
| OutputFormat::Xlsx => {
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),
OutputFormat::Csv => emit_csv_output(snapshot, repo_root, opts),
OutputFormat::Xlsx => emit_xlsx_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 emit_csv_output(
snapshot: &mut Snapshot,
repo_root: &Path,
opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
let csv = hotspots_core::csv_report::render_csv(&snapshot.functions, repo_root)
.context("failed to render CSV report")?;
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, &csv)
.with_context(|| format!("failed to write CSV to {}", output_path.display()))?;
eprintln!("CSV report written to: {}", output_path.display());
} else {
print!("{csv}");
}
Ok(())
}
fn emit_xlsx_output(
snapshot: &mut Snapshot,
repo_root: &Path,
opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
let bytes = hotspots_core::xlsx_report::render_xlsx(&snapshot.functions, repo_root)
.context("failed to render XLSX report")?;
let output_path = opts
.output
.unwrap_or_else(|| snapshot::hotspots_dir(repo_root).join("report.xlsx"));
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, &bytes)
.with_context(|| format!("failed to write XLSX to {}", output_path.display()))?;
eprintln!("XLSX report written to: {}", output_path.display());
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);
let is_spreadsheet = matches!(format, OutputFormat::Csv | OutputFormat::Xlsx);
if !is_aggregate_level && !is_spreadsheet && (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,
) -> Option<hotspots_core::trainer::ModelClass> {
let model_path = snapshot::hotspots_dir(repo_root).join("ranker.json");
if !model_path.exists() {
return None;
}
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 None;
}
};
for func in &mut snapshot.functions {
func.activity_risk = Some(hotspots_core::trainer::score(&model, func));
}
Some(model.model_class.clone())
}
fn populate_explanations(snapshot: &mut Snapshot) {
use hotspots_core::phrases::{top_phrases, FeaturePercentiles};
use hotspots_core::risk::RiskBand;
use hotspots_core::trainer::extract_features;
let n = snapshot.functions.len();
if n == 0 {
return;
}
let feature_vecs: Vec<[f64; 10]> = snapshot.functions.iter().map(extract_features).collect();
let mut sorted: Vec<Vec<f64>> = (0..10usize).map(|_| Vec::with_capacity(n)).collect();
for fv in &feature_vecs {
for (j, &v) in fv.iter().enumerate() {
sorted[j].push(v);
}
}
for col in sorted.iter_mut() {
col.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
}
let pct = |col: usize, val: f64| -> f32 {
let pos = sorted[col].partition_point(|&x| x < val);
pos as f32 / n as f32
};
for (i, func) in snapshot.functions.iter_mut().enumerate() {
if !matches!(func.band, RiskBand::Critical | RiskBand::High) {
continue;
}
let fv = &feature_vecs[i];
let fp = FeaturePercentiles {
lrs: pct(0, fv[0]),
cc: pct(1, fv[1]),
nd: pct(2, fv[2]),
loc: pct(3, fv[3]),
fo: pct(4, fv[4]),
fan_in: if fv[5] >= 3.0 { pct(5, fv[5]) } else { 0.0 },
total_churn: pct(6, fv[6]),
authors_90d: pct(7, fv[7]),
directed_coupling: pct(8, fv[8]),
};
let phrase = top_phrases(&fp, 3);
if !phrase.is_empty() {
func.explanation = Some(phrase);
}
}
}
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)");
}
OutputFormat::Csv => {
anyhow::bail!("--format csv is not supported for delta mode (use --mode snapshot)");
}
OutputFormat::Xlsx => {
anyhow::bail!("--format xlsx 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, FORMULA_VERSION, 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(),
formula_version: FORMULA_VERSION,
},
functions,
summary: None,
aggregates: None,
};
let mut enricher = snapshot::SnapshotEnricher::new(snapshot).with_subsystems(repo_root);
if !skip_touch_metrics {
enricher = enricher.with_burst_score(repo_root);
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 {
enricher = enricher.with_burst_score(repo_root);
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);
}
}
}
}
const DEFAULT_HYBRID_THRESHOLD: usize = 5;
fn resolve_touch_mode(
no_per_function: bool,
per_function: bool,
hybrid_threshold: Option<usize>,
config_per_function: bool,
) -> TouchMode {
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,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TouchModeArg {
Auto,
PerFunction,
File,
Hybrid(Option<usize>),
None,
}
pub(crate) fn parse_touch_mode_arg(s: &str) -> Result<TouchModeArg, String> {
let s = s.trim();
if let Some(rest) = s.strip_prefix("hybrid") {
return if rest.is_empty() {
Ok(TouchModeArg::Hybrid(None))
} else {
let n_str = rest.strip_prefix(':').ok_or_else(|| {
format!("invalid --touch-mode '{s}': expected 'hybrid' or 'hybrid:N'")
})?;
let n: usize = n_str.parse().map_err(|_| {
format!("invalid --touch-mode '{s}': N must be a non-negative integer")
})?;
Ok(TouchModeArg::Hybrid(Some(n)))
};
}
match s {
"auto" => Ok(TouchModeArg::Auto),
"per-function" => Ok(TouchModeArg::PerFunction),
"file" => Ok(TouchModeArg::File),
"none" => Ok(TouchModeArg::None),
_ => Err(format!(
"invalid --touch-mode '{s}': expected one of auto, per-function, file, hybrid[:N], none"
)),
}
}
fn resolve_touch_mode_and_skip(
touch_mode_arg: Option<TouchModeArg>,
legacy_no_per_function: bool,
legacy_per_function: bool,
legacy_hybrid: Option<usize>,
legacy_skip: bool,
config_per_function: bool,
config_hybrid_threshold: Option<usize>,
) -> (TouchMode, bool) {
match touch_mode_arg {
Some(TouchModeArg::Auto) | None => {
let touch_mode = resolve_touch_mode(
legacy_no_per_function,
legacy_per_function,
legacy_hybrid.or(config_hybrid_threshold),
config_per_function,
);
(touch_mode, legacy_skip)
}
Some(TouchModeArg::PerFunction) => (TouchMode::PerFunction, false),
Some(TouchModeArg::File) => (TouchMode::File, false),
Some(TouchModeArg::Hybrid(threshold)) => (
TouchMode::Hybrid {
threshold: threshold.unwrap_or(DEFAULT_HYBRID_THRESHOLD),
},
false,
),
Some(TouchModeArg::None) => (TouchMode::File, true),
}
}
fn warn_deprecated_touch_flags(
no_per_function_touches: bool,
per_function_touches: bool,
skip_touch_metrics: bool,
hybrid_touches: Option<usize>,
) {
if no_per_function_touches {
eprintln!("warning: --no-per-function-touches is deprecated, use --touch-mode file");
}
if per_function_touches {
eprintln!("warning: --per-function-touches is deprecated, use --touch-mode per-function");
}
if skip_touch_metrics {
eprintln!("warning: --skip-touch-metrics is deprecated, use --touch-mode none");
}
if let Some(n) = hybrid_touches {
eprintln!("warning: --hybrid-touches is deprecated, use --touch-mode hybrid:{n}");
}
}
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();
}
}
})
}
#[cfg(test)]
mod touch_mode_tests {
use super::*;
#[test]
fn parse_touch_mode_arg_all_named_values() {
assert_eq!(parse_touch_mode_arg("auto"), Ok(TouchModeArg::Auto));
assert_eq!(
parse_touch_mode_arg("per-function"),
Ok(TouchModeArg::PerFunction)
);
assert_eq!(parse_touch_mode_arg("file"), Ok(TouchModeArg::File));
assert_eq!(parse_touch_mode_arg("none"), Ok(TouchModeArg::None));
}
#[test]
fn parse_touch_mode_arg_hybrid_bare_and_with_threshold() {
assert_eq!(
parse_touch_mode_arg("hybrid"),
Ok(TouchModeArg::Hybrid(None))
);
assert_eq!(
parse_touch_mode_arg("hybrid:12"),
Ok(TouchModeArg::Hybrid(Some(12)))
);
assert_eq!(
parse_touch_mode_arg("hybrid:0"),
Ok(TouchModeArg::Hybrid(Some(0)))
);
}
#[test]
fn parse_touch_mode_arg_rejects_garbage() {
assert!(parse_touch_mode_arg("bogus").is_err());
assert!(parse_touch_mode_arg("hybrid:").is_err());
assert!(parse_touch_mode_arg("hybrid:abc").is_err());
assert!(parse_touch_mode_arg("hybrid-5").is_err());
assert!(parse_touch_mode_arg("hybrid:-1").is_err());
}
#[test]
fn resolve_touch_mode_and_skip_auto_falls_back_to_config() {
let (mode, skip) = resolve_touch_mode_and_skip(None, false, false, None, false, true, None);
assert_eq!(mode, TouchMode::PerFunction);
assert!(!skip);
let (mode, skip) =
resolve_touch_mode_and_skip(None, false, false, None, false, false, None);
assert_eq!(
mode,
TouchMode::Hybrid {
threshold: DEFAULT_HYBRID_THRESHOLD
}
);
assert!(!skip);
}
#[test]
fn resolve_touch_mode_and_skip_explicit_auto_matches_none() {
let (mode, skip) = resolve_touch_mode_and_skip(
Some(TouchModeArg::Auto),
false,
false,
None,
false,
true,
Some(9),
);
assert_eq!(mode, TouchMode::Hybrid { threshold: 9 });
assert!(!skip);
}
#[test]
fn resolve_touch_mode_and_skip_per_function() {
let (mode, skip) = resolve_touch_mode_and_skip(
Some(TouchModeArg::PerFunction),
false,
false,
None,
false,
false,
None,
);
assert_eq!(mode, TouchMode::PerFunction);
assert!(!skip);
}
#[test]
fn resolve_touch_mode_and_skip_file() {
let (mode, skip) = resolve_touch_mode_and_skip(
Some(TouchModeArg::File),
false,
false,
None,
false,
true, None,
);
assert_eq!(mode, TouchMode::File);
assert!(!skip);
}
#[test]
fn resolve_touch_mode_and_skip_hybrid_default_and_explicit_threshold() {
let (mode, skip) = resolve_touch_mode_and_skip(
Some(TouchModeArg::Hybrid(None)),
false,
false,
None,
false,
false,
None,
);
assert_eq!(
mode,
TouchMode::Hybrid {
threshold: DEFAULT_HYBRID_THRESHOLD
}
);
assert!(!skip);
let (mode, skip) = resolve_touch_mode_and_skip(
Some(TouchModeArg::Hybrid(Some(42))),
false,
false,
None,
false,
false,
None,
);
assert_eq!(mode, TouchMode::Hybrid { threshold: 42 });
assert!(!skip);
}
#[test]
fn resolve_touch_mode_and_skip_none_sets_skip_true() {
let (_, skip) = resolve_touch_mode_and_skip(
Some(TouchModeArg::None),
false,
false,
None,
false,
true,
Some(3),
);
assert!(skip);
}
#[test]
fn resolve_touch_mode_and_skip_legacy_flags_unchanged_when_touch_mode_absent() {
let (_, skip) = resolve_touch_mode_and_skip(None, false, false, None, true, false, None);
assert!(skip);
let (mode, skip) = resolve_touch_mode_and_skip(None, true, false, None, false, true, None);
assert_eq!(mode, TouchMode::File);
assert!(!skip);
let (mode, skip) =
resolve_touch_mode_and_skip(None, false, false, Some(7), false, false, None);
assert_eq!(mode, TouchMode::Hybrid { threshold: 7 });
assert!(!skip);
}
}