1use std::path::Path;
4
5use fallow_config::{AuditGate, AuditWorsened, RulesConfig, Severity};
6
7use super::{AuditAnalysesView, AuditKeySnapshot};
8use crate::audit_keys::{
9 AuditComparison, AuditDomainLedger, DeadCodeAuditLedger,
10 classify_complexity_attribution_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_classes =
91 view.health
92 .as_ref()
93 .zip(base)
94 .map_or_else(Vec::new, |(health, snapshot)| {
95 classify_complexity_attribution_against_all(
96 &health.report.findings,
97 health.root,
98 std::iter::once(&snapshot.health).chain(&snapshot.inherited_health),
99 )
100 });
101 let health = view
102 .health
103 .as_ref()
104 .map_or_else(AuditDomainLedger::default, |health| {
105 let findings = &health.report.findings;
106 let keys = complexity_ledger_keys(findings, health.root);
107 let introduced = (0..findings.len()).map(|index| {
108 health_classes
109 .get(index)
110 .is_some_and(|class| class.introduced)
111 });
112 AuditDomainLedger::from_classified(keys.into_iter().zip(introduced), base.is_some())
113 });
114 let dupes = AuditDomainLedger::compare(
115 view.duplication.iter().flat_map(|duplication| {
116 duplication
117 .clone_groups
118 .iter()
119 .map(|group| dupe_group_key(group, duplication.root))
120 }),
121 base.map(|snapshot| &snapshot.dupes),
122 );
123 let styling = AuditDomainLedger::compare(
124 view.health.iter().flat_map(|health| {
125 health
126 .report
127 .styling_findings
128 .iter()
129 .map(|finding| styling_finding_key(finding, health.root))
130 }),
131 base.map(|snapshot| &snapshot.styling),
132 );
133 AuditComparison {
134 dead_code,
135 health,
136 health_classes,
137 dupes,
138 styling,
139 }
140}
141
142pub fn demote_preexisting_dupe_introductions(
151 comparison: &mut AuditComparison,
152 view: &AuditAnalysesView<'_>,
153 root: &Path,
154 base_ref: &str,
155 shared: Option<SharedDiff<'_>>,
156) -> Option<DupeDemotionDiffSource> {
157 if comparison.dupes.introduced_count() == 0 {
158 return None;
159 }
160 let duplication = view.duplication.as_ref()?;
161 let worktree_index;
162 let (index, source) = if let Some(shared) = shared {
163 (
164 shared.index,
165 DupeDemotionDiffSource::Shared(shared.label.to_owned()),
166 )
167 } else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
168 worktree_index = fallow_output::DiffIndex::from_unified_diff(&diff);
169 (&worktree_index, DupeDemotionDiffSource::Worktree)
170 } else {
171 return Some(DupeDemotionDiffSource::Skipped);
172 };
173 let demote = preexisting_dupe_group_keys(
174 duplication.clone_groups.iter().copied(),
175 duplication.root,
176 index,
177 );
178 comparison.dupes.demote_introductions(&demote);
179 Some(source)
180}
181
182#[must_use]
185pub fn styling_rule_severity(rules: &RulesConfig, code: &str) -> Severity {
186 match code {
187 "css-token-drift" => rules.css_token_drift,
188 "css-duplicate-block" => rules.css_duplicate_block,
189 "css-selector-complexity" => rules.css_selector_complexity,
190 "css-dead-surface" => rules.css_dead_surface,
191 "css-broken-reference" => rules.css_broken_reference,
192 _ => Severity::Warn,
193 }
194}
195
196#[must_use]
198pub fn styling_finding_gates(rules: &RulesConfig, code: &str) -> bool {
199 styling_rule_severity(rules, code) == Severity::Error
200}
201
202#[must_use]
204pub fn outcome(
205 gate: AuditGate,
206 worsened: AuditWorsened,
207 view: &AuditAnalysesView<'_>,
208 comparison: &AuditComparison,
209 has_base: bool,
210) -> (AuditAttribution, AuditVerdict, AuditSummary) {
211 let summary = summary(view, comparison);
212 (
213 attribution(gate, worsened, comparison, has_base),
214 verdict(gate, worsened, view, comparison, &summary),
215 summary,
216 )
217}
218
219fn complexity_verdict_flags(
224 gate: AuditGate,
225 worsened: AuditWorsened,
226 view: &AuditAnalysesView<'_>,
227 comparison: &AuditComparison,
228) -> (bool, bool) {
229 let new_only = matches!(gate, AuditGate::NewOnly);
230 let Some(health) = view.health.as_ref() else {
231 return (false, false);
232 };
233 let mut errors = false;
234 let mut warnings = false;
235 for (index, (finding, introduced)) in health
236 .report
237 .findings
238 .iter()
239 .zip(comparison.health.introduced())
240 .enumerate()
241 {
242 let is_worsened = comparison
243 .health_classes
244 .get(index)
245 .is_some_and(crate::audit_keys::ComplexityClass::is_worsened);
246 let counts = !new_only || introduced || (is_worsened && worsened == AuditWorsened::Error);
247 if counts {
248 if finding.blocks() {
249 errors = true;
250 } else {
251 warnings = true;
252 }
253 } else if is_worsened {
254 warnings = true;
255 }
256 }
257 (errors, warnings)
258}
259
260fn verdict(
261 gate: AuditGate,
262 worsened: AuditWorsened,
263 view: &AuditAnalysesView<'_>,
264 comparison: &AuditComparison,
265 summary: &AuditSummary,
266) -> AuditVerdict {
267 let new_only = matches!(gate, AuditGate::NewOnly);
268 let dead_code_errors = if new_only {
269 comparison.dead_code.has_introduced_errors()
270 } else {
271 dead_code_has_errors(view)
272 };
273 let dead_code_warnings = if new_only {
274 comparison.dead_code.has_introduced_warnings()
275 } else {
276 comparison
277 .dead_code
278 .records()
279 .iter()
280 .any(|record| record.effective_severity == Severity::Warn)
281 };
282 let (complexity_errors, complexity_warnings) =
286 complexity_verdict_flags(gate, worsened, view, comparison);
287 let styling_errors = view.health.as_ref().is_some_and(|health| {
288 health
289 .report
290 .styling_findings
291 .iter()
292 .zip(comparison.styling.introduced())
293 .any(|(finding, introduced)| {
294 (!new_only || introduced) && styling_finding_gates(health.rules, &finding.code)
295 })
296 });
297 let duplication_findings = if new_only {
298 comparison.dupes.introduced_count()
299 } else {
300 summary.duplication_clone_groups
301 };
302 let duplication_errors = view.duplication.as_ref().is_some_and(|duplication| {
303 duplication_findings > 0
304 && duplication.threshold > 0.0
305 && duplication.duplication_percentage > duplication.threshold
306 });
307 if dead_code_errors || complexity_errors || styling_errors || duplication_errors {
308 AuditVerdict::Fail
309 } else if dead_code_warnings || complexity_warnings || duplication_findings > 0 {
310 AuditVerdict::Warn
311 } else {
312 AuditVerdict::Pass
313 }
314}
315
316fn dead_code_has_errors(view: &AuditAnalysesView<'_>) -> bool {
319 view.dead_code.as_ref().is_some_and(|dead_code| {
320 fallow_engine::error_severity::has_error_severity_issues(
321 dead_code.results,
322 &dead_code.config.rules,
323 Some(dead_code.config),
324 false,
325 )
326 })
327}
328
329fn attribution(
330 gate: AuditGate,
331 worsened: AuditWorsened,
332 comparison: &AuditComparison,
333 has_base: bool,
334) -> AuditAttribution {
335 if !has_base {
336 return AuditAttribution {
337 gate,
338 worsened,
339 ..AuditAttribution::default()
340 };
341 }
342 AuditAttribution {
343 gate,
344 worsened,
345 complexity_worsened: comparison.complexity_worsened_count(),
346 dead_code_introduced: comparison.dead_code.introduced_count(),
347 dead_code_inherited: comparison.dead_code.inherited_count(),
348 complexity_introduced: comparison.health.introduced_count(),
349 complexity_inherited: comparison.health.inherited_count(),
350 duplication_introduced: comparison.dupes.introduced_count(),
351 duplication_inherited: comparison.dupes.inherited_count(),
352 }
353}
354
355fn summary(view: &AuditAnalysesView<'_>, comparison: &AuditComparison) -> AuditSummary {
356 AuditSummary {
357 dead_code_issues: comparison.dead_code.visible_count(),
358 dead_code_has_errors: dead_code_has_errors(view),
359 dead_code_skipped: view.dead_code.is_none(),
360 complexity_skipped: view.health.is_none(),
361 duplication_skipped: view.duplication.is_none(),
362 complexity_findings: view
363 .health
364 .as_ref()
365 .map_or(0, |health| health.report.findings.len()),
366 max_cyclomatic: view.health.as_ref().and_then(|health| {
367 health
368 .report
369 .findings
370 .iter()
371 .map(|finding| finding.cyclomatic)
372 .max()
373 }),
374 duplication_clone_groups: view
375 .duplication
376 .as_ref()
377 .map_or(0, |duplication| duplication.clone_groups.len()),
378 }
379}