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