1use std::path::Path;
4
5use fallow_config::{AuditGate, RulesConfig, Severity};
6
7use super::{AuditAnalysesView, AuditKeySnapshot};
8use crate::audit_keys::{
9 AuditComparison, AuditDomainLedger, DeadCodeAuditLedger,
10 classify_complexity_findings_against_all, complexity_ledger_keys, dead_code_audit_ledger,
11 dupe_group_key, preexisting_dupe_group_keys, 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| {
99 classify_complexity_findings_against_all(
100 findings,
101 health.root,
102 std::iter::once(&snapshot.health).chain(&snapshot.inherited_health),
103 )
104 },
105 );
106 AuditDomainLedger::from_classified(keys.into_iter().zip(introduced), base.is_some())
107 });
108 let dupes = AuditDomainLedger::compare(
109 view.duplication.iter().flat_map(|duplication| {
110 duplication
111 .clone_groups
112 .iter()
113 .map(|group| dupe_group_key(group, duplication.root))
114 }),
115 base.map(|snapshot| &snapshot.dupes),
116 );
117 let styling = AuditDomainLedger::compare(
118 view.health.iter().flat_map(|health| {
119 health
120 .report
121 .styling_findings
122 .iter()
123 .map(|finding| styling_finding_key(finding, health.root))
124 }),
125 base.map(|snapshot| &snapshot.styling),
126 );
127 AuditComparison {
128 dead_code,
129 health,
130 dupes,
131 styling,
132 }
133}
134
135pub fn demote_preexisting_dupe_introductions(
144 comparison: &mut AuditComparison,
145 view: &AuditAnalysesView<'_>,
146 root: &Path,
147 base_ref: &str,
148 shared: Option<SharedDiff<'_>>,
149) -> Option<DupeDemotionDiffSource> {
150 if comparison.dupes.introduced_count() == 0 {
151 return None;
152 }
153 let duplication = view.duplication.as_ref()?;
154 let worktree_index;
155 let (index, source) = if let Some(shared) = shared {
156 (
157 shared.index,
158 DupeDemotionDiffSource::Shared(shared.label.to_owned()),
159 )
160 } else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
161 worktree_index = fallow_output::DiffIndex::from_unified_diff(&diff);
162 (&worktree_index, DupeDemotionDiffSource::Worktree)
163 } else {
164 return Some(DupeDemotionDiffSource::Skipped);
165 };
166 let demote = preexisting_dupe_group_keys(
167 duplication.clone_groups.iter().copied(),
168 duplication.root,
169 index,
170 );
171 comparison.dupes.demote_introductions(&demote);
172 Some(source)
173}
174
175#[must_use]
178pub fn styling_rule_severity(rules: &RulesConfig, code: &str) -> Severity {
179 match code {
180 "css-token-drift" => rules.css_token_drift,
181 "css-duplicate-block" => rules.css_duplicate_block,
182 "css-selector-complexity" => rules.css_selector_complexity,
183 "css-dead-surface" => rules.css_dead_surface,
184 "css-broken-reference" => rules.css_broken_reference,
185 _ => Severity::Warn,
186 }
187}
188
189#[must_use]
191pub fn styling_finding_gates(rules: &RulesConfig, code: &str) -> bool {
192 styling_rule_severity(rules, code) == Severity::Error
193}
194
195#[must_use]
197pub fn outcome(
198 gate: AuditGate,
199 view: &AuditAnalysesView<'_>,
200 comparison: &AuditComparison,
201 has_base: bool,
202) -> (AuditAttribution, AuditVerdict, AuditSummary) {
203 let summary = summary(view, comparison);
204 (
205 attribution(gate, comparison, has_base),
206 verdict(gate, view, comparison, &summary),
207 summary,
208 )
209}
210
211fn verdict(
212 gate: AuditGate,
213 view: &AuditAnalysesView<'_>,
214 comparison: &AuditComparison,
215 summary: &AuditSummary,
216) -> AuditVerdict {
217 let new_only = matches!(gate, AuditGate::NewOnly);
218 let dead_code_errors = if new_only {
219 comparison.dead_code.has_introduced_errors()
220 } else {
221 dead_code_has_errors(view)
222 };
223 let dead_code_warnings = if new_only {
224 comparison.dead_code.has_introduced_warnings()
225 } else {
226 comparison
227 .dead_code
228 .records()
229 .iter()
230 .any(|record| record.effective_severity == Severity::Warn)
231 };
232 let (complexity_errors, complexity_warnings) =
236 view.health.as_ref().map_or((false, false), |health| {
237 health
238 .report
239 .findings
240 .iter()
241 .zip(comparison.health.introduced())
242 .filter(|(_, introduced)| !new_only || *introduced)
243 .fold((false, false), |(errors, warnings), (finding, _)| {
244 if finding.blocks() {
245 (true, warnings)
246 } else {
247 (errors, true)
248 }
249 })
250 });
251 let styling_errors = view.health.as_ref().is_some_and(|health| {
252 health
253 .report
254 .styling_findings
255 .iter()
256 .zip(comparison.styling.introduced())
257 .any(|(finding, introduced)| {
258 (!new_only || introduced) && styling_finding_gates(health.rules, &finding.code)
259 })
260 });
261 let duplication_findings = if new_only {
262 comparison.dupes.introduced_count()
263 } else {
264 summary.duplication_clone_groups
265 };
266 let duplication_errors = view.duplication.as_ref().is_some_and(|duplication| {
267 duplication_findings > 0
268 && duplication.threshold > 0.0
269 && duplication.duplication_percentage > duplication.threshold
270 });
271 if dead_code_errors || complexity_errors || styling_errors || duplication_errors {
272 AuditVerdict::Fail
273 } else if dead_code_warnings || complexity_warnings || duplication_findings > 0 {
274 AuditVerdict::Warn
275 } else {
276 AuditVerdict::Pass
277 }
278}
279
280fn dead_code_has_errors(view: &AuditAnalysesView<'_>) -> bool {
283 view.dead_code.as_ref().is_some_and(|dead_code| {
284 fallow_engine::error_severity::has_error_severity_issues(
285 dead_code.results,
286 &dead_code.config.rules,
287 Some(dead_code.config),
288 false,
289 )
290 })
291}
292
293fn attribution(gate: AuditGate, comparison: &AuditComparison, has_base: bool) -> AuditAttribution {
294 if !has_base {
295 return AuditAttribution {
296 gate,
297 ..AuditAttribution::default()
298 };
299 }
300 AuditAttribution {
301 gate,
302 dead_code_introduced: comparison.dead_code.introduced_count(),
303 dead_code_inherited: comparison.dead_code.inherited_count(),
304 complexity_introduced: comparison.health.introduced_count(),
305 complexity_inherited: comparison.health.inherited_count(),
306 duplication_introduced: comparison.dupes.introduced_count(),
307 duplication_inherited: comparison.dupes.inherited_count(),
308 }
309}
310
311fn summary(view: &AuditAnalysesView<'_>, comparison: &AuditComparison) -> AuditSummary {
312 AuditSummary {
313 dead_code_issues: comparison.dead_code.visible_count(),
314 dead_code_has_errors: dead_code_has_errors(view),
315 dead_code_skipped: view.dead_code.is_none(),
316 complexity_skipped: view.health.is_none(),
317 duplication_skipped: view.duplication.is_none(),
318 complexity_findings: view
319 .health
320 .as_ref()
321 .map_or(0, |health| health.report.findings.len()),
322 max_cyclomatic: view.health.as_ref().and_then(|health| {
323 health
324 .report
325 .findings
326 .iter()
327 .map(|finding| finding.cyclomatic)
328 .max()
329 }),
330 duplication_clone_groups: view
331 .duplication
332 .as_ref()
333 .map_or(0, |duplication| duplication.clone_groups.len()),
334 }
335}