Skip to main content

fallow_engine/baseline/prune/
mod.rs

1//! Prune a saved baseline to the entries that still match a current finding.
2//!
3//! Each function keeps exactly the entries that `--baseline` matches for the
4//! same findings, in the old file order. A pruned file therefore hides the same
5//! findings as before, and `--fail-on-stale-baseline` finds no stale entry in
6//! it. Prune never adds an entry: a new finding stays outside the baseline, and
7//! the normal gates report it.
8
9use 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/// One baseline entry that matched no current finding.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PrunedEntry {
26    /// The baseline field that held the entry, for example `unused_exports`.
27    /// A health count names the bucket field and the category, for example
28    /// `finding_counts.complexity_high`.
29    pub category: String,
30    /// The entry key as the file stores it.
31    pub key: String,
32    /// The number of occurrences removed under this key.
33    pub count: usize,
34}
35
36/// The result of pruning one baseline file.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BaselinePrune {
39    /// Entries in the file before the prune, counted as `--baseline` counts
40    /// them for the stale-baseline check.
41    pub entries_before: usize,
42    /// Entries left after the prune.
43    pub entries_after: usize,
44    /// The removed entries, in file order.
45    pub removed: Vec<PrunedEntry>,
46    /// The new file content. `None` when nothing was removed, so the caller
47    /// leaves the file as it is.
48    pub content: Option<String>,
49}
50
51/// Why a baseline file cannot be pruned. The file stays as it is.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum BaselinePruneRefusal {
54    /// The file is not valid JSON or not a valid baseline of this kind.
55    Parse(String),
56    /// Another command saved the file, or nothing in it names this kind.
57    NotThisKind {
58        /// The command that saved the file, when the file names one.
59        saved_by: Option<BaselineKind>,
60    },
61    /// The file uses a key form from an older version, which prune cannot
62    /// match entry by entry.
63    LegacyKeys,
64    /// The file uses a key scheme that this version does not know.
65    UnknownKeyScheme(String),
66    /// The file was saved with another analysis identity, for example a
67    /// type-aware run. The fields that differ are listed.
68    IncompatibleIdentity(Vec<&'static str>),
69    /// The duplication baseline holds clone keys that older versions shared
70    /// between unrelated groups (issue #3290).
71    SharedCloneKeys,
72    /// The file has fields that this version does not know, probably from a
73    /// newer version. A rewrite would drop them.
74    UnknownFields(Vec<String>),
75}
76
77impl BaselinePruneRefusal {
78    /// A stable token for machine output.
79    #[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    /// A one-sentence reason for people.
93    #[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
141/// Parse the file and refuse it when a rewrite would drop a field with data.
142///
143/// The structs skip empty fields on save, so a field that is absent from the
144/// round trip is unknown only when the file holds data in it.
145fn 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
189/// Keep the saved keys that a current key matches, one saved occurrence for
190/// each current occurrence, and record the rest.
191fn 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
214/// Prune a dead-code baseline (the format `fallow dead-code --save-baseline`
215/// writes) against the results of a whole-project run.
216///
217/// # Errors
218///
219/// Returns [`BaselinePruneRefusal`] when the file is not a canonical dead-code
220/// baseline of the same analysis identity.
221pub 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    // A finding that the cascade filter hid still exists, so its entry stays.
244    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                    &current,
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
276/// Prune a duplication baseline against the report of a whole-project run.
277///
278/// The three key arrays of the format hold one row for each clone group. A row
279/// stays when its normalized key matches a current group, and the older arrays
280/// lose the same rows so they stay aligned.
281///
282/// # Errors
283///
284/// Returns [`BaselinePruneRefusal`] when the file is not a duplication baseline
285/// with normalized keys, or when it holds the shared clone keys of issue #3290.
286pub 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 &current {
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    // A hand-edited file can break the row alignment. Older binaries read the
346    // older arrays only, so leave a misaligned array as it is.
347    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
371/// Prune a health baseline against the findings of a whole-project run.
372///
373/// `finding_counts` and `identity_finding_counts` lose the slots that matched
374/// no current finding. Identity buckets follow a moved file as `--baseline`
375/// does and keep their saved key. An emptied identity bucket stays as an empty
376/// map when the run reports its key, so the move matching does not change. The runtime-coverage keys and the refactoring
377/// target keys stay as they are, because this run does not compute them.
378///
379/// # Errors
380///
381/// Returns [`BaselinePruneRefusal`] when the file is not a health baseline with
382/// count buckets.
383pub 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        &current_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            &current_identity,
412            root,
413        )
414        .into_iter()
415        .collect();
416        prune_count_buckets(
417            &mut baseline.identity_finding_counts,
418            &current_identity,
419            &remaps,
420            "identity_finding_counts",
421            &mut removed,
422        );
423        // A saved identity key that the run also reports is never a move
424        // candidate. Dropping its emptied bucket would make it one, and a
425        // second candidate with the same function name stops another bucket
426        // from following its move. So the key stays, with no slots.
427        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
446/// Reduce each saved bucket to the slots that the `--baseline` match uses.
447fn 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
495/// The saved slots per severity that the greedy match of
496/// [`super::overflowing_severities`] uses for the current findings.
497///
498/// A saved slot covers a current finding of the same or a lower severity. The
499/// match takes the lowest compatible slot first, so keeping exactly the used
500/// slots leaves every match, and every overflow, as it was.
501fn 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;