1use crate::{
8 Added, Allowance, Allowlist, Basis, Kind, Reason, Removed, Renamed, Report, Snapshot, Unused,
9 allow::Intent, snapshot::Fingerprint,
10};
11use std::collections::BTreeMap;
12
13const PAIRING_ORDER: [Kind; 7] = [
16 Kind::Plane,
17 Kind::Anchor,
18 Kind::Definition,
19 Kind::Object,
20 Kind::RenderLayer,
21 Kind::Stairwell,
22 Kind::Zone,
23];
24
25type Groups<'a> = BTreeMap<(&'a str, &'a Fingerprint), (Vec<&'a str>, Vec<&'a str>)>;
27
28#[must_use]
31pub fn diff(old: &Snapshot, new: &Snapshot, allowlist: &Allowlist) -> Report {
32 let mut removed = Vec::new();
33 let mut renamed = Vec::new();
34 let mut added = Vec::new();
35 let mut planes: BTreeMap<&str, &str> = BTreeMap::new();
37 for kind in PAIRING_ORDER {
38 let mut gone: BTreeMap<&str, &Fingerprint> = old
39 .entries(kind)
40 .filter(|(id, _)| !new.contains(kind, id))
41 .collect();
42 let mut fresh: BTreeMap<&str, &Fingerprint> = new
43 .entries(kind)
44 .filter(|(id, _)| !old.contains(kind, id))
45 .collect();
46 let mut pairs = Vec::new();
47 for from in gone.keys() {
49 if let Some(Intent::Rename(to)) = allowlist.get(kind, from)
50 && new.contains(kind, to)
51 {
52 pairs.push((*from, to, Basis::Declared));
53 }
54 }
55 for (from, to, _) in &pairs {
56 gone.remove(from);
57 fresh.remove(to);
58 }
59 if kind.pairs_by_content() {
62 for (from, to) in content_pairs(kind, &gone, &fresh, &planes) {
63 gone.remove(from);
64 fresh.remove(to);
65 pairs.push((from, to, Basis::Content));
66 }
67 }
68 if kind == Kind::Plane {
69 planes = pairs.iter().map(|(from, to, _)| (*from, *to)).collect();
70 }
71 removed.extend(gone.into_keys().map(|id| Removed {
72 kind,
73 id: id.to_owned(),
74 allowed: allowed(kind, id, allowlist, old, new),
75 }));
76 renamed.extend(pairs.into_iter().map(|(from, to, basis)| Renamed {
77 kind,
78 from: from.to_owned(),
79 to: to.to_owned(),
80 basis,
81 allowed: allowed(kind, from, allowlist, old, new),
82 }));
83 added.extend(fresh.into_keys().map(|id| Added {
84 kind,
85 id: id.to_owned(),
86 }));
87 }
88 removed.sort();
89 renamed.sort();
90 added.sort();
91 let unused = allowlist
92 .allowances()
93 .filter_map(|allowance| {
94 let kind = allowance.kind();
95 let reason =
96 if !old.contains(kind, allowance.id()) || new.contains(kind, allowance.id()) {
97 Reason::NotRemoved
98 } else {
99 match &allowance {
100 Allowance::Rename { to, .. } if !new.contains(kind, to) => {
101 Reason::TargetMissing
102 }
103 _ => return None,
104 }
105 };
106 Some(Unused { allowance, reason })
107 })
108 .collect();
109 Report {
110 old: old.side().clone(),
111 new: new.side().clone(),
112 removed,
113 renamed,
114 added,
115 unused,
116 }
117}
118
119fn content_pairs<'a>(
123 kind: Kind,
124 gone: &BTreeMap<&'a str, &'a Fingerprint>,
125 fresh: &BTreeMap<&'a str, &'a Fingerprint>,
126 planes: &BTreeMap<&'a str, &'a str>,
127) -> Vec<(&'a str, &'a str)> {
128 let mut groups = Groups::new();
129 for (id, fingerprint) in gone {
130 let scope = kind.scope(id);
131 let scope = if kind.in_plane() {
132 planes.get(scope).copied().unwrap_or(scope)
133 } else {
134 scope
135 };
136 groups.entry((scope, fingerprint)).or_default().0.push(id);
137 }
138 for (id, fingerprint) in fresh {
139 groups
140 .entry((kind.scope(id), fingerprint))
141 .or_default()
142 .1
143 .push(id);
144 }
145 groups
146 .into_values()
147 .filter_map(|group| match group {
148 (from, to) if from.len() == 1 && to.len() == 1 => Some((from[0], to[0])),
149 _ => None,
150 })
151 .collect()
152}
153
154fn allowed(kind: Kind, id: &str, allowlist: &Allowlist, old: &Snapshot, new: &Snapshot) -> bool {
161 match allowlist.get(kind, id) {
162 Some(Intent::Remove) => return true,
163 Some(Intent::Rename(to)) if new.contains(kind, to) => return true,
164 _ => {}
165 }
166 if kind == Kind::Object {
167 return old.object_plane(id).is_some_and(|plane| {
170 !new.contains(Kind::Plane, plane)
171 && matches!(allowlist.get(Kind::Plane, plane), Some(Intent::Remove))
172 });
173 }
174 let Some((plane, local)) = id.split_once('/').filter(|_| kind.in_plane()) else {
175 return false;
176 };
177 if new.contains(Kind::Plane, plane) {
178 return false;
179 }
180 match allowlist.get(Kind::Plane, plane) {
181 Some(Intent::Remove) => true,
182 Some(Intent::Rename(to)) => {
183 new.contains(Kind::Plane, to) && new.contains(kind, &format!("{to}/{local}"))
184 }
185 None => false,
186 }
187}