1use std::path::Path;
4
5use fallow_config::{AuditGate, RulesConfig, Severity};
6
7use super::{AuditAnalysesView, AuditKeySnapshot};
8use crate::audit_keys::{
9 AuditComparison, AuditDomainLedger, DeadCodeAuditLedger, classify_complexity_findings,
10 complexity_ledger_keys, dead_code_audit_ledger, dupe_group_key, preexisting_dupe_group_keys,
11 styling_finding_key,
12};
13use crate::{AuditAttribution, AuditSummary, AuditVerdict};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum DupeDemotionDiffSource {
19 Shared(String),
23 Worktree,
25 Skipped,
28}
29
30impl DupeDemotionDiffSource {
31 #[must_use]
33 pub fn label(&self, base_ref: &str) -> String {
34 match self {
35 Self::Shared(label) => label.clone(),
36 Self::Worktree => format!("merge-base worktree diff vs {base_ref}"),
37 Self::Skipped => "skipped: no diff available".to_string(),
38 }
39 }
40}
41
42#[derive(Clone, Copy)]
44pub struct SharedDiff<'a> {
45 pub index: &'a fallow_output::DiffIndex,
47 pub label: &'a str,
49}
50
51#[must_use]
58pub fn compare(
59 view: &AuditAnalysesView<'_>,
60 base: Option<&AuditKeySnapshot>,
61 syntactic_fallback: bool,
62) -> AuditComparison {
63 let dead_code =
64 view.dead_code
65 .as_ref()
66 .map_or_else(DeadCodeAuditLedger::default, |dead_code| {
67 let base_keys = base.map(|snapshot| {
70 if syntactic_fallback {
71 snapshot
72 .syntactic_dead_code
73 .as_ref()
74 .unwrap_or(&snapshot.dead_code)
75 } else {
76 &snapshot.dead_code
77 }
78 });
79 let mut ledger = dead_code_audit_ledger(
80 dead_code.results,
81 dead_code.root,
82 dead_code.config,
83 base_keys,
84 );
85 if syntactic_fallback && let Some(head_syntactic) = dead_code.syntactic_keys {
86 ledger.demote_unattributable_introductions(head_syntactic);
87 }
88 ledger
89 });
90 let health = view
91 .health
92 .as_ref()
93 .map_or_else(AuditDomainLedger::default, |health| {
94 let findings = &health.report.findings;
95 let keys = complexity_ledger_keys(findings, health.root);
96 let introduced = base.map_or_else(
97 || vec![false; findings.len()],
98 |snapshot| classify_complexity_findings(findings, health.root, &snapshot.health),
99 );
100 AuditDomainLedger::from_classified(keys.into_iter().zip(introduced), base.is_some())
101 });
102 let dupes = AuditDomainLedger::compare(
103 view.duplication.iter().flat_map(|duplication| {
104 duplication
105 .clone_groups
106 .iter()
107 .map(|group| dupe_group_key(group, duplication.root))
108 }),
109 base.map(|snapshot| &snapshot.dupes),
110 );
111 let styling = AuditDomainLedger::compare(
112 view.health.iter().flat_map(|health| {
113 health
114 .report
115 .styling_findings
116 .iter()
117 .map(|finding| styling_finding_key(finding, health.root))
118 }),
119 base.map(|snapshot| &snapshot.styling),
120 );
121 AuditComparison {
122 dead_code,
123 health,
124 dupes,
125 styling,
126 }
127}
128
129pub fn demote_preexisting_dupe_introductions(
138 comparison: &mut AuditComparison,
139 view: &AuditAnalysesView<'_>,
140 root: &Path,
141 base_ref: &str,
142 shared: Option<SharedDiff<'_>>,
143) -> Option<DupeDemotionDiffSource> {
144 if comparison.dupes.introduced_count() == 0 {
145 return None;
146 }
147 let duplication = view.duplication.as_ref()?;
148 let worktree_index;
149 let (index, source) = if let Some(shared) = shared {
150 (
151 shared.index,
152 DupeDemotionDiffSource::Shared(shared.label.to_owned()),
153 )
154 } else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
155 worktree_index = fallow_output::DiffIndex::from_unified_diff(&diff);
156 (&worktree_index, DupeDemotionDiffSource::Worktree)
157 } else {
158 return Some(DupeDemotionDiffSource::Skipped);
159 };
160 let demote = preexisting_dupe_group_keys(
161 duplication.clone_groups.iter().copied(),
162 duplication.root,
163 index,
164 );
165 comparison.dupes.demote_introductions(&demote);
166 Some(source)
167}
168
169#[must_use]
172pub fn styling_rule_severity(rules: &RulesConfig, code: &str) -> Severity {
173 match code {
174 "css-token-drift" => rules.css_token_drift,
175 "css-duplicate-block" => rules.css_duplicate_block,
176 "css-selector-complexity" => rules.css_selector_complexity,
177 "css-dead-surface" => rules.css_dead_surface,
178 "css-broken-reference" => rules.css_broken_reference,
179 _ => Severity::Warn,
180 }
181}
182
183#[must_use]
185pub fn styling_finding_gates(rules: &RulesConfig, code: &str) -> bool {
186 styling_rule_severity(rules, code) == Severity::Error
187}
188
189#[must_use]
191pub fn outcome(
192 gate: AuditGate,
193 view: &AuditAnalysesView<'_>,
194 comparison: &AuditComparison,
195 has_base: bool,
196) -> (AuditAttribution, AuditVerdict, AuditSummary) {
197 let summary = summary(view, comparison);
198 (
199 attribution(gate, comparison, has_base),
200 verdict(gate, view, comparison, &summary),
201 summary,
202 )
203}
204
205fn verdict(
206 gate: AuditGate,
207 view: &AuditAnalysesView<'_>,
208 comparison: &AuditComparison,
209 summary: &AuditSummary,
210) -> AuditVerdict {
211 let new_only = matches!(gate, AuditGate::NewOnly);
212 let dead_code_errors = if new_only {
213 comparison.dead_code.has_introduced_errors()
214 } else {
215 dead_code_has_errors(view)
216 };
217 let dead_code_warnings = if new_only {
218 comparison.dead_code.has_introduced_warnings()
219 } else {
220 comparison
221 .dead_code
222 .records()
223 .iter()
224 .any(|record| record.effective_severity == Severity::Warn)
225 };
226 let (complexity_errors, complexity_warnings) =
230 view.health.as_ref().map_or((false, false), |health| {
231 health
232 .report
233 .findings
234 .iter()
235 .zip(comparison.health.introduced())
236 .filter(|(_, introduced)| !new_only || *introduced)
237 .fold((false, false), |(errors, warnings), (finding, _)| {
238 if finding.blocks() {
239 (true, warnings)
240 } else {
241 (errors, true)
242 }
243 })
244 });
245 let styling_errors = view.health.as_ref().is_some_and(|health| {
246 health
247 .report
248 .styling_findings
249 .iter()
250 .zip(comparison.styling.introduced())
251 .any(|(finding, introduced)| {
252 (!new_only || introduced) && styling_finding_gates(health.rules, &finding.code)
253 })
254 });
255 let duplication_findings = if new_only {
256 comparison.dupes.introduced_count()
257 } else {
258 summary.duplication_clone_groups
259 };
260 let duplication_errors = view.duplication.as_ref().is_some_and(|duplication| {
261 duplication_findings > 0
262 && duplication.threshold > 0.0
263 && duplication.duplication_percentage > duplication.threshold
264 });
265 if dead_code_errors || complexity_errors || styling_errors || duplication_errors {
266 AuditVerdict::Fail
267 } else if dead_code_warnings || complexity_warnings || duplication_findings > 0 {
268 AuditVerdict::Warn
269 } else {
270 AuditVerdict::Pass
271 }
272}
273
274fn dead_code_has_errors(view: &AuditAnalysesView<'_>) -> bool {
277 view.dead_code.as_ref().is_some_and(|dead_code| {
278 fallow_engine::error_severity::has_error_severity_issues(
279 dead_code.results,
280 &dead_code.config.rules,
281 Some(dead_code.config),
282 false,
283 )
284 })
285}
286
287fn attribution(gate: AuditGate, comparison: &AuditComparison, has_base: bool) -> AuditAttribution {
288 if !has_base {
289 return AuditAttribution {
290 gate,
291 ..AuditAttribution::default()
292 };
293 }
294 AuditAttribution {
295 gate,
296 dead_code_introduced: comparison.dead_code.introduced_count(),
297 dead_code_inherited: comparison.dead_code.inherited_count(),
298 complexity_introduced: comparison.health.introduced_count(),
299 complexity_inherited: comparison.health.inherited_count(),
300 duplication_introduced: comparison.dupes.introduced_count(),
301 duplication_inherited: comparison.dupes.inherited_count(),
302 }
303}
304
305fn summary(view: &AuditAnalysesView<'_>, comparison: &AuditComparison) -> AuditSummary {
306 AuditSummary {
307 dead_code_issues: comparison.dead_code.visible_count(),
308 dead_code_has_errors: dead_code_has_errors(view),
309 complexity_findings: view
310 .health
311 .as_ref()
312 .map_or(0, |health| health.report.findings.len()),
313 max_cyclomatic: view.health.as_ref().and_then(|health| {
314 health
315 .report
316 .findings
317 .iter()
318 .map(|finding| finding.cyclomatic)
319 .max()
320 }),
321 duplication_clone_groups: view
322 .duplication
323 .as_ref()
324 .map_or(0, |duplication| duplication.clone_groups.len()),
325 }
326}