1use rustc_hash::FxHashMap;
10use std::path::Path;
11
12use fallow_types::identity::IdentityPaths;
13
14use super::{
15 BASELINE_KEY_SCHEME, BaselineData, BaselineFileKind, BaselineKind, DuplicationBaselineData,
16 HEALTH_FINDING_DIMENSIONS, HealthBaselineCount, HealthBaselineData, HealthBaselineMode,
17 HealthFindingCategory, HealthFindingCountMap, canonical_keys, classify_baseline_value,
18 clone_group_fingerprint_key, consume_baseline_key, health_finding_counts,
19 moved_identity_bucket_remaps, severity_counts_for_dimension, severity_index,
20};
21use crate::duplicates::{CloneFingerprintSet, DuplicationReport};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PrunedEntry {
26 pub category: String,
30 pub key: String,
32 pub count: usize,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BaselinePrune {
39 pub entries_before: usize,
42 pub entries_after: usize,
44 pub removed: Vec<PrunedEntry>,
46 pub content: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum BaselinePruneRefusal {
54 Parse(String),
56 NotThisKind {
58 saved_by: Option<BaselineKind>,
60 },
61 LegacyKeys,
64 UnknownKeyScheme(String),
66 IncompatibleIdentity(Vec<&'static str>),
69 SharedCloneKeys,
72 UnknownFields(Vec<String>),
75}
76
77impl BaselinePruneRefusal {
78 #[must_use]
80 pub const fn code(&self) -> &'static str {
81 match self {
82 Self::Parse(_) => "parse-error",
83 Self::NotThisKind { .. } => "not-this-kind",
84 Self::LegacyKeys => "legacy-keys",
85 Self::UnknownKeyScheme(_) => "unknown-key-scheme",
86 Self::IncompatibleIdentity(_) => "incompatible-identity",
87 Self::SharedCloneKeys => "shared-clone-keys",
88 Self::UnknownFields(_) => "unknown-fields",
89 }
90 }
91
92 #[must_use]
94 pub fn reason(&self) -> String {
95 match self {
96 Self::Parse(error) => format!("the file is not a valid baseline: {error}"),
97 Self::NotThisKind {
98 saved_by: Some(kind),
99 } => format!("`fallow {}` saved this file", kind.as_str()),
100 Self::NotThisKind { saved_by: None } => {
101 "nothing in the file names this baseline kind".to_owned()
102 }
103 Self::LegacyKeys => {
104 "the file uses an older key form that prune cannot match".to_owned()
105 }
106 Self::UnknownKeyScheme(scheme) => {
107 format!("the file uses the key scheme `{scheme}`, which this version does not know")
108 }
109 Self::IncompatibleIdentity(fields) => format!(
110 "the file was saved with another analysis mode ({})",
111 fields.join(", ")
112 ),
113 Self::SharedCloneKeys => {
114 "the file has clone keys that older versions shared between unrelated groups"
115 .to_owned()
116 }
117 Self::UnknownFields(fields) => format!(
118 "the file has fields that this version does not know ({}); a newer fallow version probably saved it",
119 fields.join(", ")
120 ),
121 }
122 }
123}
124
125fn parse_own_kind(
126 content: &str,
127 kind: BaselineKind,
128) -> Result<serde_json::Value, BaselinePruneRefusal> {
129 let parsed = serde_json::from_str::<serde_json::Value>(content)
130 .map_err(|error| BaselinePruneRefusal::Parse(error.to_string()))?;
131 match classify_baseline_value(&parsed, kind) {
132 BaselineFileKind::Own | BaselineFileKind::NotAnObject => Ok(parsed),
133 other @ (BaselineFileKind::Foreign(_) | BaselineFileKind::Unrecognised) => {
134 Err(BaselinePruneRefusal::NotThisKind {
135 saved_by: other.saved_by(),
136 })
137 }
138 }
139}
140
141fn parse_value<T: serde::de::DeserializeOwned + serde::Serialize>(
146 value: &serde_json::Value,
147) -> Result<T, BaselinePruneRefusal> {
148 let parsed =
149 T::deserialize(value).map_err(|error| BaselinePruneRefusal::Parse(error.to_string()))?;
150 let round_trip = serde_json::to_value(&parsed)
151 .map_err(|error| BaselinePruneRefusal::Parse(error.to_string()))?;
152 if let (Some(original), Some(known)) = (value.as_object(), round_trip.as_object()) {
153 let unknown: Vec<String> = original
154 .iter()
155 .filter(|(key, field)| {
156 key.as_str() != "kind" && !known.contains_key(key.as_str()) && !is_empty_json(field)
157 })
158 .map(|(key, _)| key.clone())
159 .collect();
160 if !unknown.is_empty() {
161 return Err(BaselinePruneRefusal::UnknownFields(unknown));
162 }
163 }
164 Ok(parsed)
165}
166
167fn is_empty_json(value: &serde_json::Value) -> bool {
168 match value {
169 serde_json::Value::Null => true,
170 serde_json::Value::Array(items) => items.is_empty(),
171 serde_json::Value::Object(fields) => fields.is_empty(),
172 serde_json::Value::String(text) => text.is_empty(),
173 serde_json::Value::Bool(_) | serde_json::Value::Number(_) => false,
174 }
175}
176
177fn serialize<T: serde::Serialize>(
178 baseline: &T,
179 trailing_newline: bool,
180) -> Result<String, BaselinePruneRefusal> {
181 let mut json = serde_json::to_string_pretty(baseline)
182 .map_err(|error| BaselinePruneRefusal::Parse(error.to_string()))?;
183 if trailing_newline {
184 json.push('\n');
185 }
186 Ok(json)
187}
188
189fn retain_matched(
192 saved: &mut Vec<String>,
193 current: &[String],
194 category: &str,
195 removed: &mut Vec<PrunedEntry>,
196) {
197 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
198 for key in current {
199 *remaining.entry(key.as_str()).or_default() += 1;
200 }
201 saved.retain(|key| {
202 if consume_baseline_key(&mut remaining, key) {
203 return true;
204 }
205 removed.push(PrunedEntry {
206 category: category.to_owned(),
207 key: key.clone(),
208 count: 1,
209 });
210 false
211 });
212}
213
214pub fn prune_dead_code_baseline(
222 content: &str,
223 results: &crate::results::AnalysisResults,
224 root: &Path,
225 identity: &fallow_types::semantic::SemanticAnalysisIdentity,
226) -> Result<BaselinePrune, BaselinePruneRefusal> {
227 let mut baseline: BaselineData =
228 parse_value(&parse_own_kind(content, BaselineKind::DeadCode)?)?;
229 match baseline.identity.as_deref() {
230 None => return Err(BaselinePruneRefusal::LegacyKeys),
231 Some(scheme) if scheme != BASELINE_KEY_SCHEME => {
232 return Err(BaselinePruneRefusal::UnknownKeyScheme(scheme.to_owned()));
233 }
234 Some(_) => {}
235 }
236 let incompatible = baseline.analysis_identity().incompatible_fields(identity);
237 if !incompatible.is_empty() {
238 return Err(BaselinePruneRefusal::IncompatibleIdentity(incompatible));
239 }
240
241 let entries_before = baseline.total_entries();
242 let paths = IdentityPaths::new(root);
243 let hidden = results.cascade.hidden.to_results();
245 let mut removed = Vec::new();
246 macro_rules! prune {
247 ($($field:ident),* $(,)?) => {
248 $({
249 let mut current = canonical_keys(&results.$field, &paths);
250 current.extend(canonical_keys(&hidden.$field, &paths));
251 retain_matched(
252 &mut baseline.$field,
253 ¤t,
254 stringify!($field),
255 &mut removed,
256 );
257 })*
258 };
259 }
260 with_baseline_fields!(prune);
261
262 let content = if removed.is_empty() {
263 None
264 } else {
265 baseline.kind = Some(BaselineKind::DeadCode);
266 Some(serialize(&baseline, true)?)
267 };
268 Ok(BaselinePrune {
269 entries_before,
270 entries_after: baseline.total_entries(),
271 removed,
272 content,
273 })
274}
275
276pub fn prune_dupes_baseline(
287 content: &str,
288 report: &DuplicationReport,
289) -> Result<BaselinePrune, BaselinePruneRefusal> {
290 let mut baseline: DuplicationBaselineData =
291 parse_value(&parse_own_kind(content, BaselineKind::Dupes)?)?;
292 if baseline.has_unparsed_collision_keys() {
293 return Err(BaselinePruneRefusal::SharedCloneKeys);
294 }
295 let entries_before = baseline.entry_count();
296 if baseline.normalized_clone_fingerprints.is_empty() {
297 if entries_before == 0 {
298 return Ok(BaselinePrune {
299 entries_before,
300 entries_after: 0,
301 removed: Vec::new(),
302 content: None,
303 });
304 }
305 return Err(BaselinePruneRefusal::LegacyKeys);
306 }
307
308 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
309 let current: Vec<String> = report
310 .clone_groups
311 .iter()
312 .map(|group| clone_group_fingerprint_key(group, &fingerprints))
313 .collect();
314 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
315 for key in ¤t {
316 *remaining.entry(key.as_str()).or_default() += 1;
317 }
318 let keep: Vec<bool> = baseline
319 .normalized_clone_fingerprints
320 .iter()
321 .map(|key| consume_baseline_key(&mut remaining, key))
322 .collect();
323 let removed: Vec<PrunedEntry> = baseline
324 .normalized_clone_fingerprints
325 .iter()
326 .zip(&keep)
327 .filter(|(_, kept)| !**kept)
328 .map(|(key, _)| PrunedEntry {
329 category: "normalized_clone_fingerprints".to_owned(),
330 key: key.clone(),
331 count: 1,
332 })
333 .collect();
334 if removed.is_empty() {
335 return Ok(BaselinePrune {
336 entries_before,
337 entries_after: entries_before,
338 removed,
339 content: None,
340 });
341 }
342
343 let rows = keep.len();
344 retain_rows(&mut baseline.normalized_clone_fingerprints, &keep);
345 if baseline.clone_groups.len() == rows {
348 retain_rows(&mut baseline.clone_groups, &keep);
349 }
350 if baseline.clone_fingerprints.len() == rows {
351 retain_rows(&mut baseline.clone_fingerprints, &keep);
352 }
353 baseline.kind = Some(BaselineKind::Dupes);
354 Ok(BaselinePrune {
355 entries_before,
356 entries_after: baseline.entry_count(),
357 removed,
358 content: Some(serialize(&baseline, false)?),
359 })
360}
361
362fn retain_rows(rows: &mut Vec<String>, keep: &[bool]) {
363 let mut index = 0;
364 rows.retain(|_| {
365 let kept = keep[index];
366 index += 1;
367 kept
368 });
369}
370
371pub fn prune_health_baseline(
384 content: &str,
385 findings: &[fallow_output::ComplexityViolation],
386 root: &Path,
387) -> Result<BaselinePrune, BaselinePruneRefusal> {
388 let mut baseline: HealthBaselineData =
389 parse_value(&parse_own_kind(content, BaselineKind::Health)?)?;
390 if baseline.finding_counts.is_empty() && !baseline.findings.is_empty() {
391 return Err(BaselinePruneRefusal::LegacyKeys);
392 }
393 let entries_before = baseline.finding_entry_count();
394
395 let mut removed = Vec::new();
396 let current_counts = health_finding_counts(findings, root, HealthBaselineMode::Count);
397 prune_count_buckets(
398 &mut baseline.finding_counts,
399 ¤t_counts,
400 &FxHashMap::default(),
401 "finding_counts",
402 &mut removed,
403 );
404 baseline
405 .finding_counts
406 .retain(|_, counts| !counts.is_empty());
407 if !baseline.identity_finding_counts.is_empty() {
408 let current_identity = health_finding_counts(findings, root, HealthBaselineMode::Identity);
409 let remaps: FxHashMap<String, String> = moved_identity_bucket_remaps(
410 &baseline.identity_finding_counts,
411 ¤t_identity,
412 root,
413 )
414 .into_iter()
415 .collect();
416 prune_count_buckets(
417 &mut baseline.identity_finding_counts,
418 ¤t_identity,
419 &remaps,
420 "identity_finding_counts",
421 &mut removed,
422 );
423 baseline
428 .identity_finding_counts
429 .retain(|key, counts| !counts.is_empty() || current_identity.contains_key(key));
430 }
431
432 let content = if removed.is_empty() {
433 None
434 } else {
435 baseline.kind = Some(BaselineKind::Health);
436 Some(serialize(&baseline, false)?)
437 };
438 Ok(BaselinePrune {
439 entries_before,
440 entries_after: baseline.finding_entry_count(),
441 removed,
442 content,
443 })
444}
445
446fn prune_count_buckets(
448 saved: &mut HealthFindingCountMap,
449 current: &HealthFindingCountMap,
450 remaps: &FxHashMap<String, String>,
451 field: &str,
452 removed: &mut Vec<PrunedEntry>,
453) {
454 for (bucket, saved_counts) in saved.iter_mut() {
455 let current_key = remaps.get(bucket).unwrap_or(bucket);
456 let current_counts = current.get(current_key);
457 for dimension in HEALTH_FINDING_DIMENSIONS {
458 let baseline = severity_counts_for_dimension(Some(saved_counts), dimension);
459 let used = consumed_severity_slots(
460 severity_counts_for_dimension(current_counts, dimension),
461 baseline,
462 );
463 for severity in [
464 fallow_output::FindingSeverity::Moderate,
465 fallow_output::FindingSeverity::High,
466 fallow_output::FindingSeverity::Critical,
467 ] {
468 let index = severity_index(severity);
469 if used[index] == baseline[index] {
470 continue;
471 }
472 let category = HealthFindingCategory {
473 dimension,
474 severity,
475 }
476 .key();
477 removed.push(PrunedEntry {
478 category: format!("{field}.{category}"),
479 key: bucket.clone(),
480 count: baseline[index] - used[index],
481 });
482 if used[index] == 0 {
483 saved_counts.remove(category);
484 } else {
485 saved_counts.insert(
486 category.to_owned(),
487 HealthBaselineCount { count: used[index] },
488 );
489 }
490 }
491 }
492 }
493}
494
495fn consumed_severity_slots(current: [usize; 3], baseline: [usize; 3]) -> [usize; 3] {
502 let mut available = baseline;
503 for severity_idx in 0..3 {
504 let compatible = available[severity_idx..].iter().sum::<usize>();
505 let mut matched = current[severity_idx].min(compatible);
506 for slot in available.iter_mut().skip(severity_idx) {
507 let taken = matched.min(*slot);
508 *slot -= taken;
509 matched -= taken;
510 if matched == 0 {
511 break;
512 }
513 }
514 }
515 [
516 baseline[0] - available[0],
517 baseline[1] - available[1],
518 baseline[2] - available[2],
519 ]
520}
521
522#[cfg(test)]
523mod tests;