Skip to main content

weavatrix_scan/
delta.rs

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