Skip to main content

weavatrix_scan/
delta.rs

1use crate::file_version::reusable;
2use crate::report::{ScanReport, ScannedFile};
3use std::collections::{BTreeMap, BTreeSet};
4
5/// Strength of the evidence used to classify a scan delta.
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum DeltaQuality {
9    /// Both complete reports contain a content hash for every selected file.
10    ContentHash,
11    /// At least one file was compared by size because a content hash was absent.
12    Metadata,
13    /// At least one report is partial or terminated.
14    Partial,
15}
16
17/// A selected file whose stable relative path remained but content changed.
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ModifiedFile {
21    pub previous: ScannedFile,
22    pub current: ScannedFile,
23}
24
25/// A uniquely content-matched file whose stable relative path changed.
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RenamedFile {
29    pub previous: ScannedFile,
30    pub current: ScannedFile,
31}
32
33/// Deterministic changes between two repository manifests.
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ScanDelta {
37    pub from_revision: String,
38    pub to_revision: String,
39    pub added: Vec<ScannedFile>,
40    pub removed: Vec<ScannedFile>,
41    pub modified: Vec<ModifiedFile>,
42    pub renamed: Vec<RenamedFile>,
43    pub unchanged: u64,
44    pub selection_inputs_changed: bool,
45    pub scan_state_changed: bool,
46    pub quality: DeltaQuality,
47}
48
49impl ScanDelta {
50    #[must_use]
51    pub fn between(previous: &ScanReport, current: &ScanReport) -> Self {
52        let quality = delta_quality(previous, current);
53        let mut previous_files = previous.files.iter().collect::<Vec<_>>();
54        let mut current_files = current.files.iter().collect::<Vec<_>>();
55        previous_files.sort_unstable_by(|left, right| left.relative.cmp(&right.relative));
56        current_files.sort_unstable_by(|left, right| left.relative.cmp(&right.relative));
57        let mut delta = Self {
58            from_revision: previous.revision.clone(),
59            to_revision: current.revision.clone(),
60            added: Vec::new(),
61            removed: Vec::new(),
62            modified: Vec::new(),
63            renamed: Vec::new(),
64            unchanged: 0,
65            selection_inputs_changed: previous.ignore_sources != current.ignore_sources,
66            scan_state_changed: previous.root != current.root
67                || previous.complete != current.complete
68                || previous.termination != current.termination
69                || previous.portable != current.portable,
70            quality,
71        };
72        merge_files(&previous_files, &current_files, &mut delta);
73        detect_unique_renames(previous, current, &mut delta);
74        delta
75    }
76
77    #[must_use]
78    pub fn is_empty(&self) -> bool {
79        self.added.is_empty()
80            && self.removed.is_empty()
81            && self.modified.is_empty()
82            && self.renamed.is_empty()
83            && !self.selection_inputs_changed
84            && !self.scan_state_changed
85    }
86}
87
88fn delta_quality(previous: &ScanReport, current: &ScanReport) -> DeltaQuality {
89    if !previous.complete
90        || !current.complete
91        || previous.termination.is_some()
92        || current.termination.is_some()
93    {
94        return DeltaQuality::Partial;
95    }
96    if previous
97        .files
98        .iter()
99        .chain(&current.files)
100        .any(|file| file.content_hash.is_none())
101    {
102        DeltaQuality::Metadata
103    } else {
104        DeltaQuality::ContentHash
105    }
106}
107
108fn merge_files(previous: &[&ScannedFile], current: &[&ScannedFile], delta: &mut ScanDelta) {
109    let (mut previous_index, mut current_index) = (0, 0);
110    while previous_index < previous.len() || current_index < current.len() {
111        match (previous.get(previous_index), current.get(current_index)) {
112            (Some(before), Some(after)) if before.relative == after.relative => {
113                if same_content(before, after) {
114                    delta.unchanged = delta.unchanged.saturating_add(1);
115                } else {
116                    delta.modified.push(ModifiedFile {
117                        previous: (*before).clone(),
118                        current: (*after).clone(),
119                    });
120                }
121                previous_index += 1;
122                current_index += 1;
123            }
124            (Some(before), Some(after)) if before.relative < after.relative => {
125                delta.removed.push((*before).clone());
126                previous_index += 1;
127            }
128            (Some(_) | None, Some(after)) => {
129                delta.added.push((*after).clone());
130                current_index += 1;
131            }
132            (Some(before), None) => {
133                delta.removed.push((*before).clone());
134                previous_index += 1;
135            }
136            (None, None) => break,
137        }
138    }
139}
140
141fn same_content(previous: &ScannedFile, current: &ScannedFile) -> bool {
142    previous.bytes == current.bytes
143        && match (&previous.content_hash, &current.content_hash) {
144            (Some(previous), Some(current)) => previous == current,
145            _ => reusable(&previous.version, &current.version),
146        }
147}
148
149fn detect_unique_renames(previous: &ScanReport, current: &ScanReport, delta: &mut ScanDelta) {
150    let previous_counts = hash_counts(&previous.files);
151    let current_counts = hash_counts(&current.files);
152    let added_by_hash = unique_indices(&delta.added);
153    let removed_by_hash = unique_indices(&delta.removed);
154    let mut added_renames = vec![false; delta.added.len()];
155    let mut removed_renames = vec![false; delta.removed.len()];
156    for (hash, &removed_index) in &removed_by_hash {
157        let Some(&added_index) = added_by_hash.get(hash) else {
158            continue;
159        };
160        if previous_counts.get(hash) != Some(&1) || current_counts.get(hash) != Some(&1) {
161            continue;
162        }
163        removed_renames[removed_index] = true;
164        added_renames[added_index] = true;
165        delta.renamed.push(RenamedFile {
166            previous: delta.removed[removed_index].clone(),
167            current: delta.added[added_index].clone(),
168        });
169    }
170    delta.renamed.sort_unstable_by(|left, right| {
171        left.previous
172            .relative
173            .cmp(&right.previous.relative)
174            .then_with(|| left.current.relative.cmp(&right.current.relative))
175    });
176    retain_unmarked(&mut delta.added, &added_renames);
177    retain_unmarked(&mut delta.removed, &removed_renames);
178}
179
180fn hash_counts(files: &[ScannedFile]) -> BTreeMap<&str, usize> {
181    let mut counts = BTreeMap::new();
182    for hash in files.iter().filter_map(|file| file.content_hash.as_deref()) {
183        *counts.entry(hash).or_default() += 1;
184    }
185    counts
186}
187
188fn unique_indices(files: &[ScannedFile]) -> BTreeMap<&str, usize> {
189    let mut indices = BTreeMap::new();
190    let mut duplicates = BTreeSet::new();
191    for (index, hash) in files
192        .iter()
193        .enumerate()
194        .filter_map(|(index, file)| file.content_hash.as_deref().map(|hash| (index, hash)))
195    {
196        if indices.insert(hash, index).is_some() {
197            duplicates.insert(hash);
198        }
199    }
200    indices.retain(|hash, _| !duplicates.contains(hash));
201    indices
202}
203
204fn retain_unmarked(files: &mut Vec<ScannedFile>, marked: &[bool]) {
205    let mut index = 0;
206    files.retain(|_| {
207        let retain = !marked[index];
208        index += 1;
209        retain
210    });
211}