use std::path::Path;
use fallow_config::{AuditGate, AuditWorsened, RulesConfig, Severity};
use super::{AuditAnalysesView, AuditKeySnapshot};
use crate::audit_keys::{
AuditComparison, AuditDomainLedger, DeadCodeAuditLedger,
classify_complexity_attribution_against_all, complexity_ledger_keys, dead_code_audit_ledger,
dupe_group_key, preexisting_dupe_group_keys, styling_finding_key,
};
use crate::{AuditAttribution, AuditSummary, AuditVerdict};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DupeDemotionDiffSource {
Shared(String),
Worktree,
Skipped,
}
impl DupeDemotionDiffSource {
#[must_use]
pub fn label(&self, base_ref: &str) -> String {
match self {
Self::Shared(label) => label.clone(),
Self::Worktree => format!("merge-base worktree diff vs {base_ref}"),
Self::Skipped => "skipped: no diff available".to_string(),
}
}
}
#[derive(Clone, Copy)]
pub struct SharedDiff<'a> {
pub index: &'a fallow_output::DiffIndex,
pub label: &'a str,
}
#[must_use]
pub fn compare(
view: &AuditAnalysesView<'_>,
base: Option<&AuditKeySnapshot>,
syntactic_fallback: bool,
) -> AuditComparison {
let dead_code =
view.dead_code
.as_ref()
.map_or_else(DeadCodeAuditLedger::default, |dead_code| {
let base_keys = base.map(|snapshot| {
if syntactic_fallback {
snapshot
.syntactic_dead_code
.as_ref()
.unwrap_or(&snapshot.dead_code)
} else {
&snapshot.dead_code
}
});
let mut ledger = dead_code_audit_ledger(
dead_code.results,
dead_code.root,
dead_code.config,
base_keys,
);
if syntactic_fallback && let Some(head_syntactic) = dead_code.syntactic_keys {
ledger.demote_unattributable_introductions(head_syntactic);
}
ledger
});
let health_classes =
view.health
.as_ref()
.zip(base)
.map_or_else(Vec::new, |(health, snapshot)| {
classify_complexity_attribution_against_all(
&health.report.findings,
health.root,
std::iter::once(&snapshot.health).chain(&snapshot.inherited_health),
)
});
let health = view
.health
.as_ref()
.map_or_else(AuditDomainLedger::default, |health| {
let findings = &health.report.findings;
let keys = complexity_ledger_keys(findings, health.root);
let introduced = (0..findings.len()).map(|index| {
health_classes
.get(index)
.is_some_and(|class| class.introduced)
});
AuditDomainLedger::from_classified(keys.into_iter().zip(introduced), base.is_some())
});
let dupes = AuditDomainLedger::compare(
view.duplication.iter().flat_map(|duplication| {
duplication
.clone_groups
.iter()
.map(|group| dupe_group_key(group, duplication.root))
}),
base.map(|snapshot| &snapshot.dupes),
);
let styling = AuditDomainLedger::compare(
view.health.iter().flat_map(|health| {
health
.report
.styling_findings
.iter()
.map(|finding| styling_finding_key(finding, health.root))
}),
base.map(|snapshot| &snapshot.styling),
);
AuditComparison {
dead_code,
health,
health_classes,
dupes,
styling,
}
}
pub fn demote_preexisting_dupe_introductions(
comparison: &mut AuditComparison,
view: &AuditAnalysesView<'_>,
root: &Path,
base_ref: &str,
shared: Option<SharedDiff<'_>>,
) -> Option<DupeDemotionDiffSource> {
if comparison.dupes.introduced_count() == 0 {
return None;
}
let duplication = view.duplication.as_ref()?;
let worktree_index;
let (index, source) = if let Some(shared) = shared {
(
shared.index,
DupeDemotionDiffSource::Shared(shared.label.to_owned()),
)
} else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
worktree_index = fallow_output::DiffIndex::from_unified_diff(&diff);
(&worktree_index, DupeDemotionDiffSource::Worktree)
} else {
return Some(DupeDemotionDiffSource::Skipped);
};
let demote = preexisting_dupe_group_keys(
duplication.clone_groups.iter().copied(),
duplication.root,
index,
);
comparison.dupes.demote_introductions(&demote);
Some(source)
}
#[must_use]
pub fn styling_rule_severity(rules: &RulesConfig, code: &str) -> Severity {
match code {
"css-token-drift" => rules.css_token_drift,
"css-duplicate-block" => rules.css_duplicate_block,
"css-selector-complexity" => rules.css_selector_complexity,
"css-dead-surface" => rules.css_dead_surface,
"css-broken-reference" => rules.css_broken_reference,
_ => Severity::Warn,
}
}
#[must_use]
pub fn styling_finding_gates(rules: &RulesConfig, code: &str) -> bool {
styling_rule_severity(rules, code) == Severity::Error
}
#[must_use]
pub fn outcome(
gate: AuditGate,
worsened: AuditWorsened,
view: &AuditAnalysesView<'_>,
comparison: &AuditComparison,
has_base: bool,
) -> (AuditAttribution, AuditVerdict, AuditSummary) {
let summary = summary(view, comparison);
(
attribution(gate, worsened, comparison, has_base),
verdict(gate, worsened, view, comparison, &summary),
summary,
)
}
fn complexity_verdict_flags(
gate: AuditGate,
worsened: AuditWorsened,
view: &AuditAnalysesView<'_>,
comparison: &AuditComparison,
) -> (bool, bool) {
let new_only = matches!(gate, AuditGate::NewOnly);
let Some(health) = view.health.as_ref() else {
return (false, false);
};
let mut errors = false;
let mut warnings = false;
for (index, (finding, introduced)) in health
.report
.findings
.iter()
.zip(comparison.health.introduced())
.enumerate()
{
let is_worsened = comparison
.health_classes
.get(index)
.is_some_and(crate::audit_keys::ComplexityClass::is_worsened);
let counts = !new_only || introduced || (is_worsened && worsened == AuditWorsened::Error);
if counts {
if finding.blocks() {
errors = true;
} else {
warnings = true;
}
} else if is_worsened {
warnings = true;
}
}
(errors, warnings)
}
fn verdict(
gate: AuditGate,
worsened: AuditWorsened,
view: &AuditAnalysesView<'_>,
comparison: &AuditComparison,
summary: &AuditSummary,
) -> AuditVerdict {
let new_only = matches!(gate, AuditGate::NewOnly);
let dead_code_errors = if new_only {
comparison.dead_code.has_introduced_errors()
} else {
dead_code_has_errors(view)
};
let dead_code_warnings = if new_only {
comparison.dead_code.has_introduced_warnings()
} else {
comparison
.dead_code
.records()
.iter()
.any(|record| record.effective_severity == Severity::Warn)
};
let (complexity_errors, complexity_warnings) =
complexity_verdict_flags(gate, worsened, view, comparison);
let styling_errors = view.health.as_ref().is_some_and(|health| {
health
.report
.styling_findings
.iter()
.zip(comparison.styling.introduced())
.any(|(finding, introduced)| {
(!new_only || introduced) && styling_finding_gates(health.rules, &finding.code)
})
});
let duplication_findings = if new_only {
comparison.dupes.introduced_count()
} else {
summary.duplication_clone_groups
};
let duplication_errors = view.duplication.as_ref().is_some_and(|duplication| {
duplication_findings > 0
&& duplication.threshold > 0.0
&& duplication.duplication_percentage > duplication.threshold
});
if dead_code_errors || complexity_errors || styling_errors || duplication_errors {
AuditVerdict::Fail
} else if dead_code_warnings || complexity_warnings || duplication_findings > 0 {
AuditVerdict::Warn
} else {
AuditVerdict::Pass
}
}
fn dead_code_has_errors(view: &AuditAnalysesView<'_>) -> bool {
view.dead_code.as_ref().is_some_and(|dead_code| {
fallow_engine::error_severity::has_error_severity_issues(
dead_code.results,
&dead_code.config.rules,
Some(dead_code.config),
false,
)
})
}
fn attribution(
gate: AuditGate,
worsened: AuditWorsened,
comparison: &AuditComparison,
has_base: bool,
) -> AuditAttribution {
if !has_base {
return AuditAttribution {
gate,
worsened,
..AuditAttribution::default()
};
}
AuditAttribution {
gate,
worsened,
complexity_worsened: comparison.complexity_worsened_count(),
dead_code_introduced: comparison.dead_code.introduced_count(),
dead_code_inherited: comparison.dead_code.inherited_count(),
complexity_introduced: comparison.health.introduced_count(),
complexity_inherited: comparison.health.inherited_count(),
duplication_introduced: comparison.dupes.introduced_count(),
duplication_inherited: comparison.dupes.inherited_count(),
}
}
fn summary(view: &AuditAnalysesView<'_>, comparison: &AuditComparison) -> AuditSummary {
AuditSummary {
dead_code_issues: comparison.dead_code.visible_count(),
dead_code_has_errors: dead_code_has_errors(view),
dead_code_skipped: view.dead_code.is_none(),
complexity_skipped: view.health.is_none(),
duplication_skipped: view.duplication.is_none(),
complexity_findings: view
.health
.as_ref()
.map_or(0, |health| health.report.findings.len()),
max_cyclomatic: view.health.as_ref().and_then(|health| {
health
.report
.findings
.iter()
.map(|finding| finding.cyclomatic)
.max()
}),
duplication_clone_groups: view
.duplication
.as_ref()
.map_or(0, |duplication| duplication.clone_groups.len()),
}
}