use std::process::ExitCode;
use std::time::Instant;
use colored::Colorize;
use fallow_config::OutputFormat;
use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
use crate::dupes::{DupesMode, DupesOptions, DupesResult};
use crate::health::{HealthOptions, HealthResult, SortBy};
use crate::regression;
use crate::report;
use crate::{AnalysisKind, error::emit_error, load_config};
pub struct CombinedOptions<'a> {
pub root: &'a std::path::Path,
pub config_path: &'a Option<std::path::PathBuf>,
pub output: OutputFormat,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub fail_on_issues: bool,
pub sarif_file: Option<&'a std::path::Path>,
pub changed_since: Option<&'a str>,
pub baseline: Option<&'a std::path::Path>,
pub save_baseline: Option<&'a std::path::Path>,
pub production: bool,
pub workspace: Option<&'a str>,
pub group_by: Option<crate::GroupBy>,
pub explain: bool,
pub performance: bool,
pub summary: bool,
pub run_check: bool,
pub run_dupes: bool,
pub run_health: bool,
pub score: bool,
pub trend: bool,
pub save_snapshot: Option<&'a Option<String>>,
pub regression_opts: regression::RegressionOpts<'a>,
}
pub fn resolve_analyses(only: &[AnalysisKind], skip: &[AnalysisKind]) -> (bool, bool, bool) {
if !only.is_empty() {
(
only.contains(&AnalysisKind::DeadCode),
only.contains(&AnalysisKind::Dupes),
only.contains(&AnalysisKind::Health),
)
} else if !skip.is_empty() {
(
!skip.contains(&AnalysisKind::DeadCode),
!skip.contains(&AnalysisKind::Dupes),
!skip.contains(&AnalysisKind::Health),
)
} else {
(true, true, true)
}
}
pub fn run_combined(opts: &CombinedOptions<'_>) -> ExitCode {
let start = Instant::now();
let mut check_result: Option<CheckResult> = None;
let mut dupes_result: Option<DupesResult> = None;
let mut health_result: Option<HealthResult> = None;
if opts.run_check {
let filters = IssueFilters::default();
let trace_opts = TraceOptions {
trace_export: None,
trace_file: None,
trace_dependency: None,
performance: opts.performance,
};
let check_opts = CheckOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
fail_on_issues: opts.fail_on_issues,
filters: &filters,
changed_since: opts.changed_since,
baseline: opts.baseline,
save_baseline: opts.save_baseline,
sarif_file: opts.sarif_file,
production: opts.production,
workspace: opts.workspace,
group_by: opts.group_by,
include_dupes: false,
trace_opts: &trace_opts,
explain: opts.explain,
top: None,
file: &[],
include_entry_exports: false,
summary: opts.summary,
regression_opts: opts.regression_opts,
retain_modules_for_health: opts.run_health,
};
match crate::check::execute_check(&check_opts) {
Ok(result) => {
check_result = Some(result);
}
Err(code) => return code,
}
}
if opts.run_dupes {
let dupes_cfg = match load_config(
opts.root,
opts.config_path,
opts.output,
opts.no_cache,
opts.threads,
opts.production,
opts.quiet,
) {
Ok(c) => c.duplicates,
Err(code) => return code,
};
let dupes_opts = DupesOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
mode: DupesMode::from(dupes_cfg.mode),
min_tokens: dupes_cfg.min_tokens,
min_lines: dupes_cfg.min_lines,
threshold: dupes_cfg.threshold,
skip_local: dupes_cfg.skip_local,
cross_language: dupes_cfg.cross_language,
ignore_imports: dupes_cfg.ignore_imports,
top: None,
baseline_path: None,
save_baseline_path: None,
production: opts.production,
trace: None,
changed_since: opts.changed_since,
explain: opts.explain,
summary: opts.summary,
group_by: opts.group_by,
};
match crate::dupes::execute_dupes(&dupes_opts) {
Ok(result) => {
dupes_result = Some(result);
}
Err(code) => return code,
}
}
if opts.run_health {
let health_opts = build_health_opts(opts);
let shared = check_result.as_mut().and_then(|r| r.shared_parse.take());
let health_run = if let Some(shared_data) = shared {
crate::health::execute_health_with_shared_parse(&health_opts, shared_data)
} else {
crate::health::execute_health(&health_opts)
};
match health_run {
Ok(result) => {
health_result = Some(result);
}
Err(code) => return code,
}
}
let total_elapsed = start.elapsed();
let mut max_exit = match print_combined_report(
opts,
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
total_elapsed,
) {
Ok(exit) => exit,
Err(code) => return code,
};
handle_regression_and_summary(
&mut max_exit,
opts.quiet,
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
);
ExitCode::from(max_exit)
}
fn print_combined_report(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
total_elapsed: std::time::Duration,
) -> Result<u8, ExitCode> {
let codeowners_cfg = check_result
.map(|r| &r.config)
.or_else(|| health_result.map(|r| &r.config))
.or_else(|| dupes_result.map(|r| &r.config))
.and_then(|c| c.codeowners.as_deref());
let resolver =
crate::build_ownership_resolver(opts.group_by, opts.root, codeowners_cfg, opts.output)?;
match opts.output {
OutputFormat::Json => {
let code = print_combined_json(
check_result,
dupes_result,
health_result,
opts.root,
total_elapsed,
opts.explain,
);
if code != ExitCode::SUCCESS {
return Err(code);
}
}
OutputFormat::Sarif => {
let code = print_combined_sarif(check_result, dupes_result, health_result);
if code != ExitCode::SUCCESS {
return Err(code);
}
}
OutputFormat::CodeClimate => {
let code = print_combined_codeclimate(check_result, dupes_result, health_result);
if code != ExitCode::SUCCESS {
return Err(code);
}
}
_ => {
return Ok(print_human_sections(
opts,
check_result,
dupes_result,
health_result,
resolver,
));
}
}
Ok(0)
}
fn print_human_sections(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
resolver: Option<report::OwnershipResolver>,
) -> u8 {
let mut max_exit: u8 = 0;
let show_headers = matches!(opts.output, OutputFormat::Human) && !opts.quiet;
if show_headers {
if let Some(result) = health_result {
print_orientation_header(result, check_result);
} else if let Some(result) = check_result {
print_entry_point_summary(&result.results);
}
}
if let Some(result) = check_result {
if show_headers {
eprintln!();
eprintln!("── Dead Code ──────────────────────────────────────");
}
let code = crate::check::print_check_result(
result,
opts.quiet,
opts.explain,
false,
resolver,
None,
opts.summary,
);
max_exit = max_exit.max(exit_code_to_u8(code));
}
if let Some(result) = dupes_result {
if show_headers {
eprintln!();
eprintln!("── Duplication ────────────────────────────────────");
}
let code = crate::dupes::print_dupes_result(result, opts.quiet, opts.explain, opts.summary);
max_exit = max_exit.max(exit_code_to_u8(code));
}
if let Some(result) = health_result {
if show_headers {
eprintln!();
eprintln!("── Complexity ─────────────────────────────────────");
}
if let Some(ref timings) = result.timings {
report::print_health_performance(timings, opts.output);
}
let code = crate::health::print_health_result(
result,
opts.quiet,
opts.explain,
None,
None,
opts.summary,
);
max_exit = max_exit.max(exit_code_to_u8(code));
}
max_exit
}
fn handle_regression_and_summary(
max_exit: &mut u8,
quiet: bool,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) {
if let Some(result) = check_result
&& let Some(ref outcome) = result.regression
{
if !quiet {
regression::print_regression_outcome(outcome);
}
if outcome.is_failure() {
*max_exit = (*max_exit).max(1);
}
}
if *max_exit > 0 && !quiet {
print_failure_summary(check_result, dupes_result, health_result);
}
}
fn print_failure_summary(
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) {
let mut parts = Vec::new();
if let Some(r) = check_result {
let issues = r.results.total_issues();
if issues > 0 {
let delta_suffix = r.baseline_deltas.as_ref().map_or_else(String::new, |d| {
match d.total_delta.cmp(&0) {
std::cmp::Ordering::Greater => {
format!(", +{} since baseline", d.total_delta)
}
std::cmp::Ordering::Less => format!(", {} since baseline", d.total_delta),
std::cmp::Ordering::Equal => ", \u{00b1}0 since baseline".to_string(),
}
});
parts.push(format!("dead-code ({issues} issues{delta_suffix})"));
}
}
if let Some(r) = dupes_result {
let groups = r.report.clone_groups.len();
if groups > 0 {
parts.push(format!("dupes ({groups} clone groups)"));
}
}
if let Some(r) = health_result {
let above = r.report.summary.functions_above_threshold;
if above > 0 {
parts.push(format!("health ({above} above threshold)"));
}
}
if !parts.is_empty() {
let nudge = health_result
.filter(|r| !r.report.targets.is_empty())
.map(|r| {
if let Some(top) = r.report.targets.iter().find(|t| !is_test_path(&t.path)) {
let name = top
.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
format!(" \u{2014} start with {name}")
} else {
String::new()
}
})
.unwrap_or_default();
eprintln!("\nFailed: {}{nudge}", parts.join(", "));
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "elapsed milliseconds won't exceed u64::MAX"
)]
fn print_combined_json(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
root: &std::path::Path,
elapsed: std::time::Duration,
_explain: bool,
) -> ExitCode {
let mut combined = serde_json::Map::new();
combined.insert("schema_version".into(), serde_json::Value::Number(3.into()));
combined.insert(
"version".into(),
serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
);
combined.insert(
"elapsed_ms".into(),
serde_json::Value::Number(serde_json::Number::from(elapsed.as_millis() as u64)),
);
if let Some(result) = check {
match report::build_json(&result.results, &result.config.root, result.elapsed) {
Ok(mut json) => {
if let Some(ref outcome) = result.regression
&& let serde_json::Value::Object(ref mut map) = json
{
map.insert("regression".to_string(), outcome.to_json());
}
if let Some(ref deltas) = result.baseline_deltas
&& let serde_json::Value::Object(ref mut map) = json
{
map.insert(
"baseline_deltas".to_string(),
report::build_baseline_deltas_json(
deltas.total_delta,
deltas
.per_category
.iter()
.map(|(cat, d)| (cat.as_str(), d.current, d.baseline, d.delta)),
),
);
}
if let Some((entries, matched)) = result.baseline_matched
&& let serde_json::Value::Object(ref mut map) = json
{
map.insert(
"baseline".to_string(),
serde_json::json!({
"entries": entries,
"matched": matched,
}),
);
}
combined.insert("check".into(), json);
}
Err(e) => {
return emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
);
}
}
}
let root_prefix = format!("{}/", root.display());
if let Some(result) = dupes {
match serde_json::to_value(&result.report) {
Ok(mut json) => {
report::strip_root_prefix(&mut json, &root_prefix);
combined.insert("dupes".into(), json);
}
Err(e) => {
return emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
);
}
}
}
if let Some(result) = health {
match serde_json::to_value(&result.report) {
Ok(mut json) => {
report::strip_root_prefix(&mut json, &root_prefix);
report::inject_health_actions(&mut json);
combined.insert("health".into(), json);
}
Err(e) => {
return emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
);
}
}
}
match serde_json::to_string_pretty(&serde_json::Value::Object(combined)) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
),
}
}
fn print_combined_sarif(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> ExitCode {
let mut all_runs = Vec::new();
if let Some(result) = check {
let sarif = report::build_sarif(&result.results, &result.config.root, &result.config.rules);
if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
all_runs.extend(runs.iter().cloned());
}
}
if let Some(result) = dupes.filter(|r| !r.report.clone_groups.is_empty()) {
let run = serde_json::json!({
"tool": {
"driver": {
"name": "fallow",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/fallow-rs/fallow",
}
},
"automationDetails": { "id": "fallow/dupes" },
"results": result.report.clone_groups.iter().enumerate().map(|(i, g)| {
serde_json::json!({
"ruleId": "fallow/code-duplication",
"level": "warning",
"message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
})
}).collect::<Vec<_>>()
});
all_runs.push(run);
}
if let Some(result) = health {
let sarif = report::build_health_sarif(&result.report, &result.config.root);
if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
all_runs.extend(runs.iter().cloned());
}
}
let combined = serde_json::json!({
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": all_runs,
});
match serde_json::to_string_pretty(&combined) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => emit_error(
&format!("SARIF serialization error: {e}"),
2,
OutputFormat::Sarif,
),
}
}
fn print_combined_codeclimate(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> ExitCode {
let mut all_issues = Vec::new();
if let Some(result) = check
&& let serde_json::Value::Array(items) =
report::build_codeclimate(&result.results, &result.config.root, &result.config.rules)
{
all_issues.extend(items);
}
if let Some(result) = dupes
&& let serde_json::Value::Array(items) =
report::build_duplication_codeclimate(&result.report, &result.config.root)
{
all_issues.extend(items);
}
if let Some(result) = health
&& let serde_json::Value::Array(items) =
report::build_health_codeclimate(&result.report, &result.config.root)
{
all_issues.extend(items);
}
match serde_json::to_string_pretty(&serde_json::Value::Array(all_issues)) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => emit_error(
&format!("CodeClimate serialization error: {e}"),
2,
OutputFormat::CodeClimate,
),
}
}
fn build_health_opts<'a>(opts: &'a CombinedOptions<'a>) -> HealthOptions<'a> {
HealthOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
max_cyclomatic: None,
max_cognitive: None,
top: None,
sort: SortBy::Cyclomatic,
production: opts.production,
changed_since: opts.changed_since,
workspace: opts.workspace,
baseline: None,
save_baseline: None,
complexity: true,
file_scores: true,
coverage_gaps: false,
config_activates_coverage_gaps: false,
hotspots: true,
ownership: false,
ownership_emails: None,
targets: true,
force_full: false,
score_only_output: false,
enforce_coverage_gap_gate: false,
effort: None,
score: opts.score || opts.trend,
min_score: None,
since: None,
min_commits: None,
explain: opts.explain,
summary: opts.summary,
save_snapshot: opts
.save_snapshot
.map(|opt| std::path::PathBuf::from(opt.as_deref().unwrap_or_default())),
trend: opts.trend,
group_by: opts.group_by,
coverage: None,
coverage_root: None,
performance: opts.performance,
min_severity: None,
production_coverage: None,
}
}
fn print_orientation_header(health: &HealthResult, check: Option<&CheckResult>) {
if let Some(ref vs) = health.report.vital_signs
&& health.report.health_trend.is_none()
{
let mut parts = Vec::new();
if let Some(dfp) = vs.dead_file_pct {
if let Some(ref c) = vs.counts {
parts.push(format!(
"dead files {dfp:.1}% ({} of {})",
c.dead_files, c.total_files
));
} else {
parts.push(format!("dead files {dfp:.1}%"));
}
}
if let Some(dep) = vs.dead_export_pct {
if let Some(ref c) = vs.counts {
parts.push(format!(
"dead exports {dep:.1}% ({} of {})",
c.dead_exports, c.total_exports
));
} else {
parts.push(format!("dead exports {dep:.1}%"));
}
}
if let Some(mi) = vs.maintainability_avg {
let label = if mi >= 85.0 {
"good"
} else if mi >= 65.0 {
"moderate"
} else {
"low"
};
parts.push(format!("MI {mi:.1} ({label})"));
}
if let Some(hc) = vs.hotspot_count
&& hc > 0
{
parts.push(format!(
"{hc} churn hotspot{}",
if hc == 1 { "" } else { "s" }
));
}
if let Some(cd) = vs.circular_dep_count
&& cd > 0
{
parts.push(format!(
"{cd} circular {}",
if cd == 1 {
"dependency"
} else {
"dependencies"
}
));
}
if !parts.is_empty() {
eprintln!();
eprintln!(
"{} {} {}",
"\u{25a0}".dimmed(),
"Metrics:".dimmed(),
parts.join(" \u{00b7} ").dimmed()
);
}
}
let files = health.report.summary.files_analyzed;
let config = check.map_or(&health.config, |c| &c.config);
let plugin_count = config.external_plugins.len();
if files > 0 {
use std::fmt::Write as _;
let mut scope = format!(" {files} files analyzed");
if plugin_count > 0 {
let names: Vec<&str> = config
.external_plugins
.iter()
.take(5)
.map(|p| p.name.as_str())
.collect();
let _ = write!(
scope,
", {plugin_count} plugin{}",
if plugin_count == 1 { "" } else { "s" }
);
let _ = write!(scope, " ({})", names.join(", "));
if plugin_count > 5 {
let _ = write!(scope, " +{}", plugin_count - 5);
}
}
eprintln!("{}", scope.dimmed());
}
if let Some(result) = check {
print_entry_point_summary(&result.results);
}
if !health.report.targets.is_empty() {
let target_count = health.report.targets.len();
let total_issues = check.map_or(0, |c| c.results.total_issues());
if total_issues > 500 {
eprintln!(
"{}",
format!(
" {target_count} refactoring target{} \u{2014} try `fallow dead-code --workspace <name>` to scope",
if target_count == 1 { "" } else { "s" },
)
.dimmed()
);
} else {
if let Some(top) = health
.report
.targets
.iter()
.find(|t| !is_test_path(&t.path))
{
let file_name = top
.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
eprintln!(
"{}",
format!(
" {target_count} refactoring target{} \u{2014} start with {file_name} ({})",
if target_count == 1 { "" } else { "s" },
top.category.label()
)
.dimmed()
);
} else {
eprintln!(
"{}",
format!(
" {target_count} refactoring target{}",
if target_count == 1 { "" } else { "s" },
)
.dimmed()
);
}
}
}
}
fn is_test_path(path: &std::path::Path) -> bool {
if path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
matches!(
s.as_ref(),
"test"
| "tests"
| "__tests__"
| "__test__"
| "spec"
| "specs"
| "__mocks__"
| "__fixtures__"
| "fixtures"
| "examples"
| "example"
| "__snapshots__"
| "snapshots"
| "benchmark"
| "benchmarks"
| "bench"
| "e2e"
| "playground"
| "playgrounds"
)
}) {
return true;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(".test.")
|| name.contains(".spec.")
|| name.contains(".fixture.")
|| name.contains(".e2e.")
|| name.contains(".bench.")
|| name.contains(".story.")
|| name.contains(".stories.")
{
return true;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if stem.len() <= 3
&& stem.starts_with(|c: char| c.is_ascii_lowercase())
&& stem[1..].bytes().all(|b| b.is_ascii_digit())
{
return true;
}
}
false
}
pub fn print_entry_point_summary(results: &fallow_core::results::AnalysisResults) {
let Some(ref summary) = results.entry_point_summary else {
return;
};
if summary.total == 0 {
eprintln!(
"{}",
" \u{26a0} No entry points detected \u{2014} exports may appear unused. Run: fallow list --entry-points"
.yellow()
);
return;
}
use std::fmt::Write as _;
let mut line = format!(
" {} entry point{} detected",
summary.total,
if summary.total == 1 { "" } else { "s" }
);
if !summary.by_source.is_empty() {
let parts: Vec<String> = summary
.by_source
.iter()
.map(|(source, count)| format!("{count} {source}"))
.collect();
let _ = write!(line, " ({})", parts.join(", "));
}
eprintln!("{}", line.dimmed());
}
fn exit_code_to_u8(code: ExitCode) -> u8 {
u8::from(code != ExitCode::SUCCESS)
}