use crate::report::sink::outln;
use std::io::IsTerminal;
use std::path::Path;
use std::process::ExitCode;
use colored::Colorize;
use fallow_api::{CombinedCheckJsonSection, CombinedJsonOutputInput, DupesReportPayload};
use fallow_config::OutputFormat;
use fallow_output::{
CiIssue, CodeClimateIssue, PR_DECISION_SCHEMA, PrCommentEnvelope, PrDecisionConclusion,
PrDecisionDetails, PrDecisionGate, PrDecisionSurface, PrSummaryArea, PrSummaryFinding,
PrSummaryInput, PrSummaryScope, PrSummaryStatus, codeclimate_issues_to_value,
issues_from_codeclimate_issues, render_pr_summary,
};
use crate::check::CheckResult;
use crate::dupes::DupesResult;
use crate::error::emit_error;
use crate::health::HealthResult;
use crate::regression;
use crate::report;
use super::CombinedOptions;
use super::orientation::{
is_non_production_path, print_entry_point_summary, print_orientation_header,
};
pub(super) 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 config = check_result
.map(|r| &r.config)
.or_else(|| health_result.map(|r| &r.config))
.or_else(|| dupes_result.map(|r| &r.config));
let codeowners_cfg = config.and_then(|c| c.codeowners.as_deref());
let workspace_patterns = config.map_or(&[][..], |c| c.workspace_patterns.as_slice());
let resolver = crate::build_ownership_resolver(
opts.group_by,
opts.root,
codeowners_cfg,
workspace_patterns,
opts.output,
)?;
if let Some(code) = print_machine_combined_report(
opts,
check_result,
dupes_result,
health_result,
total_elapsed,
)? {
let code = machine_combined_code_with_stale_baseline_gate(
opts,
check_result,
dupes_result,
health_result,
code,
);
return Ok(code.max(machine_combined_fail_on_issues_code(
opts,
check_result,
dupes_result,
health_result,
)));
}
Ok(print_human_sections(
opts,
check_result,
dupes_result,
health_result,
resolver,
))
}
fn machine_combined_code_with_stale_baseline_gate(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
code: u8,
) -> u8 {
let dead_code = crate::baseline_gate::gate_failed(
check_result.and_then(|result| result.baseline_staleness.as_ref()),
opts.fail_on_stale_baseline,
fallow_engine::baseline::BaselineKind::DeadCode,
);
let dupes = crate::baseline_gate::gate_failed(
dupes_result.and_then(|result| result.baseline_staleness.as_ref()),
opts.fail_on_stale_baseline,
fallow_engine::baseline::BaselineKind::Dupes,
);
let health = health_result
.and_then(|result| result.report.summary.baseline_staleness.as_ref())
.is_some_and(|staleness| {
crate::baseline_gate::gate_failed_from_envelope(
staleness,
opts.health_baseline,
opts.fail_on_stale_baseline,
fallow_engine::baseline::BaselineKind::Health,
)
});
code.max(crate::exit_codes::gate_failed_exit_code(
fallow_output::GateName::StaleBaseline,
dead_code || dupes || health,
))
}
fn machine_combined_fail_on_issues_code(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) -> u8 {
if !opts.fail_on_issues {
return 0;
}
crate::gates::fail_on_issues_exit_code(
combined_gate_outcomes(
check_result,
dupes_result,
health_result,
CombinedGateFlags {
fail_on_stale_baseline: opts.fail_on_stale_baseline,
fail_on_issues: true,
},
)
.as_ref(),
)
}
fn print_machine_combined_report(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
total_elapsed: std::time::Duration,
) -> Result<Option<u8>, ExitCode> {
let json_input = || CombinedJsonPrintInput {
check_result,
dupes_result,
health_result,
root: opts.root,
elapsed: total_elapsed,
explain: opts.explain,
config_fixable: opts.config_path.is_some()
|| fallow_config::FallowConfig::find_config_path(opts.root).is_some(),
gate_flags: CombinedGateFlags {
fail_on_stale_baseline: opts.fail_on_stale_baseline,
fail_on_issues: opts.fail_on_issues,
},
};
if let Some(check) = check_result
&& combined_machine_format(opts.output)
{
report::config_pattern_text::print_stderr_notes(
&check.workspace_diagnostics,
opts.output,
opts.quiet,
);
}
match opts.output {
OutputFormat::Json => {
let code = print_combined_json(json_input(), opts.json_style);
combined_machine_success(code)
}
OutputFormat::Sarif => {
let code = print_combined_sarif(check_result, dupes_result, health_result);
combined_machine_success(code)
}
OutputFormat::CodeClimate => {
let code = print_combined_codeclimate(check_result, dupes_result, health_result);
combined_machine_success(code)
}
OutputFormat::PrCommentGithub | OutputFormat::PrCommentGitlab => print_combined_pr_comment(
opts,
json_input(),
matches!(opts.output, OutputFormat::PrCommentGithub),
),
OutputFormat::ReviewGithub | OutputFormat::ReviewGitlab => print_combined_review(
opts,
json_input(),
matches!(opts.output, OutputFormat::ReviewGithub),
),
OutputFormat::GithubAnnotations | OutputFormat::GithubSummary => {
let code = print_combined_github_format(
json_input(),
matches!(opts.output, OutputFormat::GithubSummary),
);
combined_machine_success(code)
}
_ => Ok(None),
}
}
const fn combined_machine_format(format: OutputFormat) -> bool {
matches!(
format,
OutputFormat::Json
| OutputFormat::Sarif
| OutputFormat::CodeClimate
| OutputFormat::PrCommentGithub
| OutputFormat::PrCommentGitlab
| OutputFormat::ReviewGithub
| OutputFormat::ReviewGitlab
| OutputFormat::GithubAnnotations
| OutputFormat::GithubSummary
)
}
fn print_combined_github_format(input: CombinedJsonPrintInput<'_>, summary: bool) -> ExitCode {
let root = input.root;
match build_combined_json_output(input) {
Ok(envelope) => {
if summary {
report::github_summary::print_summary(
report::github_annotations::EnvelopeKind::Combined,
&envelope,
root,
)
} else {
report::github_annotations::print_annotations(
report::github_annotations::EnvelopeKind::Combined,
&envelope,
root,
)
}
}
Err(code) => code,
}
}
fn combined_machine_success(code: ExitCode) -> Result<Option<u8>, ExitCode> {
if code != ExitCode::SUCCESS {
return Err(code);
}
Ok(Some(0))
}
fn combined_provider(github: bool) -> report::ci::pr_comment::Provider {
if github {
report::ci::pr_comment::Provider::Github
} else {
report::ci::pr_comment::Provider::Gitlab
}
}
fn print_combined_pr_comment(
opts: &CombinedOptions<'_>,
input: CombinedJsonPrintInput<'_>,
github: bool,
) -> Result<Option<u8>, ExitCode> {
let CombinedJsonPrintInput {
check_result,
dupes_result,
health_result,
..
} = input;
let status_note = combined_status_note(input, opts.output)?;
let envelope = build_combined_pr_summary(
opts.fail_on_issues,
check_result,
dupes_result,
health_result,
combined_provider(github),
status_note.as_deref(),
);
let mut decision = build_combined_pr_decision(
opts.fail_on_issues,
opts.fail_on_stale_baseline,
check_result,
dupes_result,
health_result,
&envelope,
);
if let Some(note) = status_note.as_deref() {
decision.details.summary_markdown =
format!("{}\n\n> {note}", decision.details.summary_markdown);
}
let details = build_combined_pr_details(check_result, dupes_result, health_result);
report::ci::pr_comment::write_pr_comment_envelope_sidecar(&envelope);
report::ci::pr_comment::write_pr_decision_sidecar(&decision);
report::ci::pr_comment::write_pr_details_sidecar(&details);
outln!("{}", envelope.body());
combined_machine_success(ExitCode::SUCCESS)
}
fn combined_status_note(
input: CombinedJsonPrintInput<'_>,
output: OutputFormat,
) -> Result<Option<String>, ExitCode> {
let envelope = build_combined_json_output(input)?;
crate::cli_report::envelope_status_note(
report::github_annotations::EnvelopeKind::Combined,
&envelope,
None,
)
.map_err(|error| {
crate::emit_known_failure(
&error,
2,
output,
crate::telemetry::FailureReason::Validation,
)
})
}
fn build_combined_pr_summary(
fail_on_issues: bool,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
provider: report::ci::pr_comment::Provider,
status_note: Option<&str>,
) -> PrCommentEnvelope {
let codeclimate = build_combined_codeclimate_issues(check_result, dupes_result, health_result);
let findings = combined_pr_summary_findings(&codeclimate);
let areas =
combined_pr_summary_areas(fail_on_issues, check_result, dupes_result, health_result);
render_pr_summary(&PrSummaryInput {
command: "combined",
provider,
marker_id: report::ci::pr_comment::sticky_marker_id(),
scope: PrSummaryScope::Project,
areas: &areas,
findings: &findings,
max_findings: report::ci::pr_comment::max_comments(),
details_url: None,
layout: report::ci::pr_comment::pr_comment_layout_from_env(),
status_note,
trailing_section: check_result
.and_then(|check| {
report::config_pattern_text::markdown_section(&check.workspace_diagnostics)
})
.as_deref(),
})
}
fn build_combined_pr_decision(
fail_on_issues: bool,
fail_on_stale_baseline: bool,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
envelope: &PrCommentEnvelope,
) -> PrDecisionSurface {
let codeclimate = build_combined_codeclimate_issues(check_result, dupes_result, health_result);
let issues = report::ci::diff_filter::filter_issues_for_summary(
issues_from_codeclimate_issues(&codeclimate),
);
let mut gates =
combined_pr_summary_areas(fail_on_issues, check_result, dupes_result, health_result)
.into_iter()
.map(pr_decision_gate_from_summary_area)
.collect::<Vec<_>>();
let conclusion = combined_decision_conclusion(&gates);
let summary_markdown = combined_decision_summary(conclusion, issues.len(), &gates);
gates.extend(report::gate_outcome_text::gate_rows_for_gates(
combined_gate_outcomes(
check_result,
dupes_result,
health_result,
CombinedGateFlags {
fail_on_stale_baseline,
fail_on_issues,
},
)
.as_ref(),
));
PrDecisionSurface {
schema: PR_DECISION_SCHEMA.to_owned(),
title: "Fallow".to_owned(),
conclusion,
gates,
annotations: issues
.iter()
.take(report::ci::pr_comment::max_comments())
.map(report::ci::pr_comment::decision_annotation_from_issue)
.collect(),
details: PrDecisionDetails {
summary_markdown,
full_report_path: None,
details_url: envelope.details_url.clone(),
},
}
}
fn build_combined_pr_details(
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) -> fallow_output::PrDetailsArtifact {
let codeclimate = build_combined_codeclimate_issues(check_result, dupes_result, health_result);
let issues = report::ci::diff_filter::filter_issues_for_summary(
issues_from_codeclimate_issues(&codeclimate),
);
report::ci::pr_comment::build_pr_details_artifact("combined", &issues)
}
fn pr_decision_gate_from_summary_area(area: PrSummaryArea) -> PrDecisionGate {
PrDecisionGate {
id: area.name.to_ascii_lowercase().replace(' ', "-"),
label: area.name,
status: pr_decision_conclusion_from_summary_status(area.status),
observed: area.result,
threshold: area.threshold,
scope: "new code".to_owned(),
}
}
fn pr_decision_conclusion_from_summary_status(status: PrSummaryStatus) -> PrDecisionConclusion {
match status {
PrSummaryStatus::Fail => PrDecisionConclusion::Failure,
PrSummaryStatus::Warn | PrSummaryStatus::Info => PrDecisionConclusion::Neutral,
PrSummaryStatus::Pass => PrDecisionConclusion::Success,
}
}
fn combined_decision_conclusion(gates: &[PrDecisionGate]) -> PrDecisionConclusion {
if gates
.iter()
.any(|gate| gate.status == PrDecisionConclusion::Failure)
{
return PrDecisionConclusion::Failure;
}
if gates
.iter()
.any(|gate| gate.status == PrDecisionConclusion::Neutral)
{
return PrDecisionConclusion::Neutral;
}
PrDecisionConclusion::Success
}
fn combined_decision_summary(
conclusion: PrDecisionConclusion,
issue_count: usize,
gates: &[PrDecisionGate],
) -> String {
if issue_count == 0
&& gates
.iter()
.all(|gate| gate.status == PrDecisionConclusion::Success)
{
return "Fallow found no actionable PR findings.".to_owned();
}
let notable = gates
.iter()
.filter(|gate| gate.status != PrDecisionConclusion::Success)
.map(|gate| format!("{}: {}", gate.label, gate.observed))
.collect::<Vec<_>>()
.join("; ");
let findings = (issue_count > 0).then(|| count_label(issue_count, "finding", "findings"));
match conclusion {
PrDecisionConclusion::Failure => match findings {
Some(findings) => format!("Fallow quality gates failed with {findings}. {notable}"),
None => format!("Fallow quality gates failed. {notable}"),
},
PrDecisionConclusion::Neutral => match findings {
Some(findings) => format!("Fallow found {findings} for review. {notable}"),
None => format!("Fallow found gate warnings for review. {notable}"),
},
PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => findings.map_or_else(
|| "Fallow found no actionable PR findings.".to_owned(),
|findings| format!("Fallow found {findings}."),
),
}
}
fn combined_pr_summary_findings(codeclimate: &[CodeClimateIssue]) -> Vec<PrSummaryFinding> {
let issues = report::ci::diff_filter::filter_issues_for_summary(
issues_from_codeclimate_issues(codeclimate),
);
issues.iter().map(pr_summary_finding_from_ci).collect()
}
fn pr_summary_finding_from_ci(issue: &CiIssue) -> PrSummaryFinding {
PrSummaryFinding {
severity: issue.severity.clone(),
rule_id: issue.rule_id.clone(),
location: format!("{}:{}", issue.path, issue.line),
description: issue.description.clone(),
fix: report::ci::suggestion::fix_intent(issue).map(str::to_owned),
}
}
fn combined_pr_summary_areas(
fail_on_issues: bool,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) -> Vec<PrSummaryArea> {
let mut areas = Vec::new();
if let Some(result) = check_result {
areas.push(dead_code_pr_summary_area(result, fail_on_issues));
}
if let Some(result) = dupes_result {
areas.push(dupes_pr_summary_area(result, fail_on_issues));
}
if let Some(result) = health_result {
areas.push(health_pr_summary_area(result));
}
areas
}
fn dead_code_pr_summary_area(result: &CheckResult, fail_on_issues: bool) -> PrSummaryArea {
let count = result.results.total_issues();
PrSummaryArea {
name: "Dead code".to_owned(),
status: if count == 0 {
PrSummaryStatus::Pass
} else if fail_on_issues {
PrSummaryStatus::Fail
} else {
PrSummaryStatus::Warn
},
result: count_label(count, "issue", "issues"),
threshold: Some("configured rules".to_owned()),
details: result
.baseline_deltas
.as_ref()
.map(|deltas| baseline_delta_label(deltas.total_delta)),
}
}
fn dupes_pr_summary_area(result: &DupesResult, fail_on_issues: bool) -> PrSummaryArea {
let count = result.report.clone_groups.len();
let exceeds_threshold =
result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold;
PrSummaryArea {
name: "Duplication".to_owned(),
status: if count == 0 {
PrSummaryStatus::Pass
} else if exceeds_threshold || fail_on_issues {
PrSummaryStatus::Fail
} else {
PrSummaryStatus::Warn
},
result: count_label(count, "clone group", "clone groups"),
threshold: dupes_threshold_label(result.threshold),
details: Some(format!(
"{:.1}% duplicated lines",
result.report.stats.duplication_percentage
)),
}
}
fn health_pr_summary_area(result: &HealthResult) -> PrSummaryArea {
let count = result.report.findings.len();
PrSummaryArea {
name: "Health".to_owned(),
status: if count == 0 {
PrSummaryStatus::Pass
} else if result.report.findings.iter().any(|f| f.blocks()) {
PrSummaryStatus::Fail
} else {
PrSummaryStatus::Warn
},
result: count_label(count, "finding", "findings"),
threshold: Some("configured complexity gates".to_owned()),
details: result
.report
.health_score
.as_ref()
.map(|score| format!("score {:.0} ({})", score.score.round(), score.grade)),
}
}
fn count_label(count: usize, singular: &str, plural: &str) -> String {
let noun = if count == 1 { singular } else { plural };
format!("{count} {noun}")
}
fn baseline_delta_label(delta: i64) -> String {
match delta.cmp(&0) {
std::cmp::Ordering::Greater => format!("+{delta} since baseline"),
std::cmp::Ordering::Less => format!("{delta} since baseline"),
std::cmp::Ordering::Equal => "no baseline change".to_owned(),
}
}
fn dupes_threshold_label(threshold: f64) -> Option<String> {
(threshold > 0.0).then(|| format!("<= {threshold:.1}% duplicated lines"))
}
fn print_combined_review(
opts: &CombinedOptions<'_>,
input: CombinedJsonPrintInput<'_>,
github: bool,
) -> Result<Option<u8>, ExitCode> {
let CombinedJsonPrintInput {
check_result,
dupes_result,
health_result,
..
} = input;
let status_note = combined_status_note(input, opts.output)?;
let issues = build_combined_codeclimate_issues(check_result, dupes_result, health_result);
let code = report::ci::review::print_review_envelope_from_codeclimate_issues(
"combined",
combined_provider(github),
&issues,
report::ci::review::ReviewSummaryNotes {
message: status_note.as_deref(),
config_patterns: check_result
.map_or(&[], |check| check.workspace_diagnostics.as_slice()),
},
);
combined_machine_success(code)
}
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, opts.root);
} else if let Some(result) = check_result {
print_entry_point_summary(&result.results);
}
}
if let Some(result) = check_result {
crate::discovery_note::print_all_source_excluded_warning(
&result.workspace_diagnostics,
result.discovered_file_count,
result.explain_skipped,
opts.quiet,
opts.output,
);
crate::discovery_note::print_default_ignore_exclusion_note(
opts.root,
&result.workspace_diagnostics,
result.explain_skipped,
opts.quiet,
opts.output,
);
}
let has_any_findings = check_result.is_some_and(|result| result.results.total_issues() > 0)
|| dupes_result.is_some_and(|result| !result.report.clone_groups.is_empty())
|| health_result.is_some_and(|result| !result.report.findings.is_empty());
print_combined_hints(
opts,
check_result,
dupes_result,
health_result,
show_headers,
has_any_findings,
);
max_exit = max_exit.max(print_check_section(
opts,
check_result,
resolver,
show_headers,
));
max_exit = max_exit.max(print_dupes_section(opts, dupes_result, show_headers));
max_exit = max_exit.max(print_health_section(opts, health_result, show_headers));
max_exit
}
fn print_combined_hints(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
show_headers: bool,
has_any_findings: bool,
) {
if !show_headers
|| !has_any_findings
|| !std::io::stdout().is_terminal()
|| crate::report::sink::is_redirected()
{
return;
}
println!(
"{}",
"Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
.dimmed()
);
println!();
let dupes_payload = dupes_result.map(|result| DupesReportPayload::from_report(&result.report));
if let Some(step) = crate::report::suggestions::top_combined_next_step(
check_result.map(|result| &result.results),
dupes_payload.as_ref(),
health_result.map(|result| &result.report),
opts.root,
) {
println!(
"{}",
format!("Next: {} ({})", step.command, step.reason).dimmed()
);
println!();
}
}
fn print_check_section(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
resolver: Option<report::OwnershipResolver>,
show_headers: bool,
) -> u8 {
let Some(result) = check_result else {
return 0;
};
let only_architecture = opts.architecture == crate::check::ArchitectureSelection::Only;
if show_headers && !only_architecture {
eprintln!();
eprintln!("── Dead Code ──────────────────────────────────────");
}
let code = crate::check::print_check_result(
result,
crate::check::PrintCheckOptions {
quiet: opts.quiet,
explain: opts.explain,
regression_json: false,
group_by: resolver,
top: None,
summary: opts.summary,
summary_heading: !show_headers,
show_explain_tip: false,
type_aware_scope: Some("dead-code"),
json_style: crate::json_style::JsonStyle::Compact,
fail_on_parse_error: false,
exit_reason: false,
architecture_layout: if only_architecture {
report::ArchitectureLayout::Only
} else {
report::ArchitectureLayout::Split
},
},
);
exit_code_to_u8(code)
}
fn print_dupes_section(
opts: &CombinedOptions<'_>,
dupes_result: Option<&DupesResult>,
show_headers: bool,
) -> u8 {
let Some(result) = dupes_result else {
return 0;
};
if show_headers {
eprintln!();
eprintln!("── Duplication ────────────────────────────────────");
}
let code = crate::dupes::print_dupes_result(
result,
opts.quiet,
opts.explain,
opts.summary,
!show_headers,
false,
opts.json_style,
);
exit_code_to_u8(code)
}
fn print_health_section(
opts: &CombinedOptions<'_>,
health_result: Option<&HealthResult>,
show_headers: bool,
) -> u8 {
let Some(result) = health_result else {
return 0;
};
if show_headers {
eprintln!();
eprintln!("── Complexity ─────────────────────────────────────");
}
if let Some(ref timings) = result.timings {
report::print_health_performance(timings, opts.output, opts.json_style);
}
let code = crate::health::print_health_result(
result,
crate::health::HealthPrintOptions {
quiet: opts.quiet,
explain: opts.explain,
gates: fallow_engine::health::HealthGateOptions {
fail_on_stale_baseline: opts.fail_on_stale_baseline,
..fallow_engine::health::HealthGateOptions::default()
},
baseline_path: opts.health_baseline,
summary: opts.summary,
summary_heading: !show_headers,
show_explain_tip: false,
type_aware_scope: Some("health"),
skip_score_and_trend: true,
css_requested: false,
json_style: opts.json_style,
exit_reason: false,
},
);
exit_code_to_u8(code)
}
pub(super) fn print_combined_exit_reason(
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
code: u8,
) {
if code == 0 {
return;
}
let machine = combined_machine_format(opts.output);
let mut gates = combined_gate_outcomes(
check_result,
dupes_result,
health_result,
CombinedGateFlags {
fail_on_stale_baseline: opts.fail_on_stale_baseline,
fail_on_issues: opts.fail_on_issues,
},
);
if !machine && let Some(gates) = gates.as_mut() {
enforce_human_section_rules(gates);
}
let own_lines = combined_own_lines(machine, opts.quiet, check_result);
crate::gates::print_exit_reason(&crate::gates::ExitReason {
gates: gates.as_ref(),
code,
fail_on_issues: opts.fail_on_issues,
quiet: opts.quiet,
output: opts.output,
own_lines: &own_lines,
});
}
fn enforce_human_section_rules(gates: &mut fallow_output::GateOutcomes) {
use fallow_output::GateName;
for name in [
GateName::ErrorSeverityFindings,
GateName::HealthFindings,
GateName::DuplicationThreshold,
] {
if let Some(outcome) = gates.get(name).cloned() {
gates.insert(
name,
fallow_output::GateOutcome {
enforced: true,
..outcome
},
);
}
}
}
fn combined_own_lines(
machine: bool,
quiet: bool,
check_result: Option<&CheckResult>,
) -> Vec<fallow_output::GateName> {
use fallow_output::GateName;
let mut own = Vec::new();
if !quiet {
own.push(GateName::Regression);
}
if !machine {
own.push(GateName::DuplicationThreshold);
if !quiet && check_result.is_some_and(crate::check::type_aware_completeness_incomplete) {
own.push(GateName::TypeAwareRequire);
}
}
own
}
pub(super) fn handle_regression_and_summary(
max_exit: &mut u8,
opts: &CombinedOptions<'_>,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) {
let quiet = opts.quiet;
if let Some(result) = check_result
&& let Some(ref outcome) = result.regression
{
if !quiet && combined_machine_format(opts.output) {
regression::print_regression_outcome(outcome);
}
*max_exit = (*max_exit).max(crate::exit_codes::gate_failed_exit_code(
fallow_output::GateName::Regression,
outcome.is_failure(),
));
}
if *max_exit > 0 && !quiet {
let check_section = if opts.architecture == crate::check::ArchitectureSelection::Only {
"architecture"
} else {
"dead-code"
};
print_failure_summary(
opts.root,
check_section,
check_result,
dupes_result,
health_result,
);
}
}
fn print_failure_summary(
root: &Path,
check_section: &str,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) {
let parts = failure_summary_parts(check_section, check_result, dupes_result, health_result);
if parts.is_empty() {
return;
}
let nudge = health_failure_nudge(root, health_result);
eprintln!("\nFailed: {}{nudge}", parts.join(", "));
print_failure_followups(root);
}
fn failure_summary_parts(
check_section: &str,
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
) -> Vec<String> {
let mut parts = Vec::new();
if let Some(r) = check_result
&& let Some(part) = check_failure_summary_part(r, check_section)
{
parts.push(part);
}
if let Some(r) = dupes_result {
let groups = r.report.clone_groups.len();
if groups > 0 {
parts.push(dupes_failure_label(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)"));
}
}
parts
}
fn check_failure_summary_part(result: &CheckResult, section: &str) -> Option<String> {
let issues = result.results.total_issues();
if issues == 0 {
return None;
}
let total_delta = result.baseline_deltas.as_ref().map(|d| d.total_delta);
Some(check_failure_label(section, issues, total_delta))
}
fn check_failure_label(section: &str, issues: usize, total_delta: Option<i64>) -> String {
let delta_suffix = total_delta.map_or_else(String::new, |delta| match delta.cmp(&0) {
std::cmp::Ordering::Greater => format!(", +{delta} since baseline"),
std::cmp::Ordering::Less => format!(", {delta} since baseline"),
std::cmp::Ordering::Equal => ", \u{00b1}0 since baseline".to_string(),
});
format!(
"{section} ({}{delta_suffix})",
count_label(issues, "issue", "issues")
)
}
fn dupes_failure_label(groups: usize) -> String {
format!(
"dupes ({})",
count_label(groups, "clone group", "clone groups")
)
}
fn health_failure_nudge(root: &Path, health_result: Option<&HealthResult>) -> String {
health_result
.filter(|r| !r.report.targets.is_empty())
.map(|r| {
if let Some(top) = r
.report
.targets
.iter()
.find(|t| !is_non_production_path(&t.path, root))
{
let name = report::format_display_path(&top.path, root);
format!(": start with {name}")
} else {
String::new()
}
})
.unwrap_or_default()
}
fn print_failure_followups(root: &Path) {
if let Some(digest) = crate::report::suggestions::due_impact_digest(root) {
eprintln!(
"{}",
crate::report::suggestions::impact_digest_line(digest).dimmed()
);
}
if crate::report::suggestions::suggestions_enabled()
&& crate::report::suggestions::setup_pointer_applicable(root)
{
eprintln!("{}", crate::report::suggestions::SETUP_HINT.dimmed());
}
}
#[derive(Clone, Copy)]
struct CombinedJsonPrintInput<'a> {
check_result: Option<&'a CheckResult>,
dupes_result: Option<&'a DupesResult>,
health_result: Option<&'a HealthResult>,
root: &'a std::path::Path,
elapsed: std::time::Duration,
explain: bool,
config_fixable: bool,
gate_flags: CombinedGateFlags,
}
#[derive(Clone, Copy)]
struct CombinedGateFlags {
fail_on_stale_baseline: bool,
fail_on_issues: bool,
}
fn print_combined_json(
input: CombinedJsonPrintInput<'_>,
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
let output = match build_combined_json_output(input) {
Ok(output) => output,
Err(code) => return code,
};
emit_combined_json_output(&output, json_style)
}
fn combined_workspace_diagnostics(
input: &CombinedJsonPrintInput<'_>,
) -> Vec<fallow_config::WorkspaceDiagnostic> {
[
input
.check_result
.map(|result| result.workspace_diagnostics.clone()),
input
.health_result
.map(|result| result.workspace_diagnostics.clone()),
input
.dupes_result
.map(|result| result.workspace_diagnostics.clone()),
]
.into_iter()
.flatten()
.fold(
Vec::new(),
fallow_types::workspace::merge_workspace_diagnostics,
)
}
fn build_combined_json_output(
input: CombinedJsonPrintInput<'_>,
) -> Result<serde_json::Value, ExitCode> {
let dupes_payload = input
.dupes_result
.map(|result| DupesReportPayload::from_report(&result.report));
let next_steps = combined_next_steps(
input.check_result,
dupes_payload.as_ref(),
input.health_result,
input.root,
);
let workspace_diagnostics = combined_workspace_diagnostics(&input);
fallow_api::serialize_combined_json(CombinedJsonOutputInput {
package_baselines: fallow_api::first_package_baselines([
input
.check_result
.map(|run| run.package_baselines.as_slice()),
input
.dupes_result
.map(|run| run.package_baselines.as_slice()),
]),
gate_outcomes: combined_gate_outcomes(
input.check_result,
input.dupes_result,
input.health_result,
input.gate_flags,
),
request_outcomes: crate::requests::request_outcomes(),
check: input.check_result.map(|result| CombinedCheckJsonSection {
results: &result.results,
root: &result.config.root,
elapsed: result.elapsed,
config_fixable: input.config_fixable,
extras: check_json_extras_for_combined(result),
}),
dupes: dupes_payload.as_ref(),
health: input.health_result.map(|result| &result.report),
root: input.root,
elapsed: input.elapsed,
explain: input.explain,
type_aware: input
.check_result
.and_then(|result| result.type_aware_meta.clone()),
workspace_diagnostics,
next_steps,
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
})
.map(|mut envelope| {
insert_dupes_baseline_staleness(&mut envelope, input.dupes_result);
envelope
})
.map_err(|err| json_output_error(&err))
}
fn insert_dupes_baseline_staleness(
envelope: &mut serde_json::Value,
dupes_result: Option<&DupesResult>,
) {
let Some(loaded) = dupes_result.and_then(|result| result.baseline_staleness.as_ref()) else {
return;
};
let Some(section) = envelope
.get_mut("dupes")
.and_then(serde_json::Value::as_object_mut)
else {
return;
};
if let Ok(staleness) = serde_json::to_value(loaded.to_envelope(0)) {
section.insert("baseline_staleness".into(), staleness);
}
}
fn combined_next_steps(
check: Option<&CheckResult>,
dupes_payload: Option<&DupesReportPayload>,
health: Option<&HealthResult>,
root: &std::path::Path,
) -> Vec<fallow_types::output::NextStep> {
crate::report::suggestions::build_combined_next_steps(
check.map(|result| &result.results),
dupes_payload,
health.map(|result| &result.report),
root,
crate::report::suggestions::setup_pointer_applicable(root),
crate::report::suggestions::due_impact_digest(root),
)
}
fn emit_combined_json_output(
output: &serde_json::Value,
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
match json_style.serialize(output) {
Ok(json) => {
outln!("{json}");
ExitCode::SUCCESS
}
Err(e) => emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
),
}
}
pub fn combined_type_aware_gate_failed(
check_result: Option<&CheckResult>,
health_result: Option<&crate::health::HealthResult>,
) -> bool {
combined_identity_incomplete(check_result, health_result)
|| check_result.is_some_and(crate::check::type_aware_completeness_incomplete)
|| health_result.is_some_and(crate::health::type_aware_completeness_incomplete)
}
fn combined_identity_incomplete(
check_result: Option<&CheckResult>,
health_result: Option<&crate::health::HealthResult>,
) -> bool {
combined_type_aware_requested(check_result, health_result)
&& check_result
.and_then(|result| result.type_aware_meta.as_ref())
.or_else(|| health_result.and_then(|result| result.type_aware_meta.as_ref()))
.and_then(|meta| meta.identity.as_ref())
.is_some_and(|identity| {
identity.completeness != fallow_types::semantic::SemanticCompleteness::Complete
})
}
fn combined_gate_outcomes(
check_result: Option<&CheckResult>,
dupes_result: Option<&DupesResult>,
health_result: Option<&HealthResult>,
flags: CombinedGateFlags,
) -> Option<fallow_output::GateOutcomes> {
crate::gates::combined_gate_outcomes(&crate::gates::CombinedGateInputs {
regression: check_result.and_then(|result| result.regression.as_ref()),
baselines: [
check_result
.and_then(|result| result.baseline_staleness.as_ref())
.map(|loaded| loaded.to_envelope(0)),
dupes_result
.and_then(|result| result.baseline_staleness.as_ref())
.map(|loaded| loaded.to_envelope(0)),
health_result.and_then(|result| result.report.summary.baseline_staleness),
],
fail_on_stale_baseline: flags.fail_on_stale_baseline,
type_aware_failed: combined_type_aware_gate_outcome(check_result, health_result),
duplication: dupes_result
.map(|result| (result.threshold, result.report.stats.duplication_percentage)),
clone_groups: dupes_result.map(|result| result.report.stats.clone_groups),
error_findings: check_result.map(|result| {
crate::check::rules::count_error_severity_issues(
&result.results,
&crate::check::effective_check_rules(result),
Some(&result.config),
result.fail_on_issues,
)
}),
health_blocking_findings: health_result
.map(|result| result.report.findings.iter().filter(|f| f.blocks()).count()),
parse_error: combined_parse_error_outcome(check_result, health_result),
fail_on_issues: flags.fail_on_issues,
})
}
pub fn combined_parse_error_outcome(
check_result: Option<&CheckResult>,
health_result: Option<&crate::health::HealthResult>,
) -> Option<fallow_output::GateOutcome> {
let sections: Vec<_> = check_result
.map(|result| (&result.config, result.workspace_diagnostics.as_slice()))
.into_iter()
.chain(
health_result.map(|result| (&result.config, result.workspace_diagnostics.as_slice())),
)
.collect();
crate::gates::sections_parse_error_outcome(§ions)
}
fn combined_type_aware_gate_outcome(
check_result: Option<&CheckResult>,
health_result: Option<&HealthResult>,
) -> Option<bool> {
let failed = combined_type_aware_gate_failed(check_result, health_result);
(failed || combined_type_aware_requested(check_result, health_result)).then_some(failed)
}
fn combined_type_aware_requested(
check_result: Option<&CheckResult>,
health_result: Option<&HealthResult>,
) -> bool {
check_result
.map(|result| result.config.type_aware.require)
.or_else(|| health_result.map(|result| result.config.type_aware.require))
== Some(fallow_config::TypeAwareRequire::Complete)
}
fn check_json_extras_for_combined(result: &CheckResult) -> fallow_api::CheckJsonExtraOutputs {
let baseline_deltas = result.baseline_deltas.as_ref().map(|deltas| {
report::build_baseline_deltas_output(
deltas.total_delta,
deltas
.per_category
.iter()
.map(|(cat, delta)| (cat.as_str(), delta.current, delta.baseline, delta.delta)),
)
});
report::check_json_extras(
result.regression.as_ref(),
baseline_deltas,
result.baseline_matched,
result
.baseline_staleness
.as_ref()
.map(|loaded| loaded.to_envelope(0)),
)
}
fn json_output_error(error: &serde_json::Error) -> ExitCode {
emit_error(
&format!("JSON serialization error: {error}"),
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 mut sarif =
report::api_sarif_document(&result.results, &result.config.root, &result.config.rules);
report::sarif::annotate_type_aware_sarif(&mut sarif, result.type_aware_meta.as_ref());
report::sarif::annotate_config_pattern_sarif(&mut sarif, &result.workspace_diagnostics);
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::api_health_sarif_document(&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) => {
outln!("{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 value = build_combined_codeclimate(check, dupes, health);
match serde_json::to_string_pretty(&value) {
Ok(json) => {
outln!("{json}");
ExitCode::SUCCESS
}
Err(e) => emit_error(
&format!("CodeClimate serialization error: {e}"),
2,
OutputFormat::CodeClimate,
),
}
}
fn build_combined_codeclimate(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> serde_json::Value {
let mut all_issues = build_combined_codeclimate_issues(check, dupes, health);
crate::report::codeclimate::rebase_codeclimate_paths(&mut all_issues);
codeclimate_issues_to_value(&all_issues)
}
fn build_combined_codeclimate_issues(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> Vec<CodeClimateIssue> {
let mut all_issues: Vec<CodeClimateIssue> = Vec::new();
if let Some(result) = check {
all_issues.extend(fallow_api::build_codeclimate(
&result.results,
&result.config.root,
&result.config.rules,
));
}
if let Some(result) = dupes {
all_issues.extend(fallow_api::build_duplication_codeclimate(
&result.report,
&result.config.root,
));
}
if let Some(result) = health {
all_issues.extend(fallow_api::build_health_codeclimate(
&result.report,
&result.config.root,
));
}
all_issues
}
pub(super) fn exit_code_to_u8(code: ExitCode) -> u8 {
u8::from(code != ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use std::process::ExitCode;
use super::{
build_combined_codeclimate, build_combined_pr_summary, combined_decision_summary,
combined_machine_success, emit_combined_json_output, exit_code_to_u8,
print_combined_codeclimate, print_combined_sarif,
};
use fallow_output::{PrDecisionConclusion, PrDecisionGate};
#[test]
fn combined_machine_success_maps_success_to_zero_exit() {
assert_eq!(combined_machine_success(ExitCode::SUCCESS), Ok(Some(0)));
}
#[test]
fn combined_machine_success_preserves_output_error() {
let code = ExitCode::from(2);
assert_eq!(combined_machine_success(code), Err(code));
}
#[test]
fn exit_code_to_u8_collapses_non_success_codes() {
assert_eq!(exit_code_to_u8(ExitCode::SUCCESS), 0);
assert_eq!(exit_code_to_u8(ExitCode::from(1)), 1);
assert_eq!(exit_code_to_u8(ExitCode::from(2)), 1);
}
#[test]
fn empty_combined_codeclimate_output_is_an_empty_issue_list() {
assert_eq!(
build_combined_codeclimate(None, None, None),
serde_json::json!([])
);
}
#[test]
fn empty_combined_machine_printers_succeed() {
assert_eq!(print_combined_sarif(None, None, None), ExitCode::SUCCESS);
assert_eq!(
print_combined_codeclimate(None, None, None),
ExitCode::SUCCESS
);
}
#[test]
fn empty_combined_pr_summary_is_clean() {
let envelope = build_combined_pr_summary(
false,
None,
None,
None,
crate::report::ci::pr_comment::Provider::Github,
None,
);
assert!(envelope.is_clean);
assert!(envelope.body.contains("No review-visible findings"));
}
#[test]
fn combined_decision_summary_names_notable_gates() {
let gates = [PrDecisionGate {
id: "health".to_owned(),
label: "Health".to_owned(),
status: PrDecisionConclusion::Failure,
observed: "1 finding".to_owned(),
threshold: Some("configured complexity gates".to_owned()),
scope: "new code".to_owned(),
}];
let summary = combined_decision_summary(PrDecisionConclusion::Failure, 1, &gates);
assert!(summary.contains("quality gates failed"));
assert!(summary.contains("Health: 1 finding"));
}
#[test]
fn insert_empty_optional_sections_leaves_combined_map_unchanged() {
let dupes = fallow_api::serialize_combined_dupes_json(None, std::path::Path::new("."))
.expect("empty dupes section should serialize");
let health = fallow_api::serialize_combined_health_json(None, std::path::Path::new("."))
.expect("empty health section should serialize");
assert!(dupes.is_none());
assert!(health.is_none());
}
fn health_result_with_incomplete_type_aware_meta() -> crate::health::HealthResult {
crate::health::HealthResult {
branching_by_file: fallow_engine::health::BranchingByFile::default(),
report: fallow_output::HealthReport::default(),
grouping: None,
group_resolver: None,
config: fallow_config::FallowConfig::default().resolve(
std::path::PathBuf::from("/project"),
fallow_config::OutputFormat::Json,
1,
true,
true,
None,
),
workspace_diagnostics: Vec::new(),
elapsed: std::time::Duration::default(),
timings: None,
type_aware_meta: Some(fallow_types::envelope::TypeAwareMeta {
required_completeness: Some(
fallow_types::semantic::SemanticCompletenessRequirement::Complete,
),
queries: vec![fallow_types::semantic::SemanticQuerySummary {
query_id: 0,
capability: fallow_types::semantic::SemanticCapability::TypeCoupling,
assertion: "type coupling".to_string(),
status: fallow_types::semantic::SemanticCompleteness::Partial,
reason_code: None,
total_evidence_count: 0,
truncated: false,
omissions: Vec::new(),
actions: Vec::new(),
}],
..Default::default()
}),
coverage_gaps_has_findings: false,
should_fail_on_coverage_gaps: false,
changed_files_analyzed: None,
}
}
#[test]
fn the_type_aware_gate_entry_agrees_with_the_health_section_exit() {
let health = health_result_with_incomplete_type_aware_meta();
let section_code = crate::health::print_health_result(
&health,
crate::health::HealthPrintOptions {
quiet: true,
explain: false,
gates: fallow_engine::health::HealthGateOptions::default(),
baseline_path: None,
summary: false,
summary_heading: true,
show_explain_tip: false,
type_aware_scope: Some("health"),
skip_score_and_trend: true,
css_requested: false,
json_style: crate::json_style::JsonStyle::Compact,
exit_reason: false,
},
);
assert_eq!(section_code, ExitCode::from(1));
assert!(super::combined_type_aware_gate_failed(None, Some(&health)));
let gates = super::combined_gate_outcomes(
None,
None,
Some(&health),
super::CombinedGateFlags {
fail_on_stale_baseline: false,
fail_on_issues: false,
},
);
let entry = gates
.as_ref()
.and_then(|gates| gates.get(fallow_output::GateName::TypeAwareRequire))
.expect("the run publishes the type-aware gate entry");
assert_eq!(entry.status, fallow_output::GateStatus::Fail);
let line =
crate::report::gate_outcome_text::exit_reason_line(gates.as_ref(), 1, false, &[]);
assert!(
line.as_deref()
.is_some_and(|line| line.contains("type-aware-require")),
"{line:?}"
);
}
#[test]
fn emit_combined_json_output_can_attach_empty_meta() {
let combined = serde_json::json!({
"kind": "combined",
"schema_version": 7,
"version": env!("CARGO_PKG_VERSION"),
"elapsed_ms": 0,
});
assert_eq!(
emit_combined_json_output(&combined, crate::json_style::JsonStyle::Compact),
ExitCode::SUCCESS
);
}
#[test]
fn check_failure_label_names_the_section_and_counts_in_the_right_number() {
use super::check_failure_label;
assert_eq!(
check_failure_label("dead-code", 1, None),
"dead-code (1 issue)"
);
assert_eq!(
check_failure_label("architecture", 3, None),
"architecture (3 issues)"
);
assert_eq!(
check_failure_label("dead-code", 2, Some(1)),
"dead-code (2 issues, +1 since baseline)"
);
assert_eq!(
check_failure_label("dead-code", 1, Some(0)),
"dead-code (1 issue, \u{00b1}0 since baseline)"
);
assert_eq!(
check_failure_label("dead-code", 4, Some(-2)),
"dead-code (4 issues, -2 since baseline)"
);
}
#[test]
fn dupes_failure_label_counts_in_the_right_number() {
use super::dupes_failure_label;
assert_eq!(dupes_failure_label(1), "dupes (1 clone group)");
assert_eq!(dupes_failure_label(2), "dupes (2 clone groups)");
}
}