engenho_diff/
normalize.rs1use serde_json::Value;
9
10use crate::path::{JsonPath, PathSeg};
11
12pub type CanonFn = fn(&Value) -> Value;
14
15#[derive(Clone)]
17pub enum Mask {
18 Drop(JsonPath),
20 Canonicalize(JsonPath, CanonFn),
22 Sort(JsonPath, String),
24}
25
26#[derive(Clone, Default)]
28pub struct Normalizer {
29 masks: Vec<Mask>,
30}
31
32impl Normalizer {
33 #[must_use]
35 pub fn new() -> Self {
36 Self::default()
37 }
38
39 #[must_use]
41 pub fn with(mut self, m: Mask) -> Self {
42 self.masks.push(m);
43 self
44 }
45
46 #[must_use]
48 pub fn masks(&self) -> &[Mask] {
49 &self.masks
50 }
51
52 #[must_use]
54 pub fn apply(&self, v: &Value) -> Value {
55 let mut out = v.clone();
56 for m in &self.masks {
57 match m {
58 Mask::Drop(p) => walk_drop(&mut out, p.segs()),
59 Mask::Canonicalize(p, f) => walk_canon(&mut out, p.segs(), *f),
60 Mask::Sort(p, key) => walk_sort(&mut out, p.segs(), key),
61 }
62 }
63 out
64 }
65}
66
67fn walk_drop(v: &mut Value, segs: &[PathSeg]) {
68 let Some((seg, rest)) = segs.split_first() else {
69 return;
70 };
71 if rest.is_empty() {
72 match seg {
73 PathSeg::Key(k) => {
74 if let Some(o) = v.as_object_mut() {
75 o.remove(k);
76 }
77 }
78 PathSeg::Index(i) => {
79 if let Some(a) = v.as_array_mut()
80 && *i < a.len()
81 {
82 a.remove(*i);
83 }
84 }
85 PathSeg::Wildcard => {
86 if let Some(a) = v.as_array_mut() {
87 a.clear();
88 }
89 }
90 }
91 return;
92 }
93 descend_each(v, seg, |child| walk_drop(child, rest));
94}
95
96fn walk_canon(v: &mut Value, segs: &[PathSeg], f: CanonFn) {
97 let Some((seg, rest)) = segs.split_first() else {
98 *v = f(v);
99 return;
100 };
101 if rest.is_empty() {
102 match seg {
103 PathSeg::Key(k) => {
104 if let Some(child) = v.as_object_mut().and_then(|o| o.get_mut(k)) {
105 *child = f(child);
106 }
107 }
108 PathSeg::Index(i) => {
109 if let Some(child) = v.as_array_mut().and_then(|a| a.get_mut(*i)) {
110 *child = f(child);
111 }
112 }
113 PathSeg::Wildcard => {
114 if let Some(a) = v.as_array_mut() {
115 for child in a.iter_mut() {
116 *child = f(child);
117 }
118 }
119 }
120 }
121 return;
122 }
123 descend_each(v, seg, |child| walk_canon(child, rest, f));
124}
125
126fn walk_sort(v: &mut Value, segs: &[PathSeg], key: &str) {
127 let Some((seg, rest)) = segs.split_first() else {
128 sort_array_by_key(v, key);
129 return;
130 };
131 if rest.is_empty() {
132 match seg {
133 PathSeg::Key(k) => {
134 if let Some(child) = v.as_object_mut().and_then(|o| o.get_mut(k)) {
135 sort_array_by_key(child, key);
136 }
137 }
138 PathSeg::Index(i) => {
139 if let Some(child) = v.as_array_mut().and_then(|a| a.get_mut(*i)) {
140 sort_array_by_key(child, key);
141 }
142 }
143 PathSeg::Wildcard => {
144 if let Some(a) = v.as_array_mut() {
145 for child in a.iter_mut() {
146 sort_array_by_key(child, key);
147 }
148 }
149 }
150 }
151 return;
152 }
153 descend_each(v, seg, |child| walk_sort(child, rest, key));
154}
155
156fn descend_each(v: &mut Value, seg: &PathSeg, mut f: impl FnMut(&mut Value)) {
157 match seg {
158 PathSeg::Key(k) => {
159 if let Some(child) = v.as_object_mut().and_then(|o| o.get_mut(k)) {
160 f(child);
161 }
162 }
163 PathSeg::Index(i) => {
164 if let Some(child) = v.as_array_mut().and_then(|a| a.get_mut(*i)) {
165 f(child);
166 }
167 }
168 PathSeg::Wildcard => {
169 if let Some(a) = v.as_array_mut() {
170 for child in a.iter_mut() {
171 f(child);
172 }
173 }
174 }
175 }
176}
177
178fn sort_array_by_key(v: &mut Value, key: &str) {
179 if let Some(arr) = v.as_array_mut() {
180 arr.sort_by(|a, b| {
181 let ak = a.get(key).and_then(Value::as_str).unwrap_or_default();
182 let bk = b.get(key).and_then(Value::as_str).unwrap_or_default();
183 ak.cmp(bk)
184 });
185 }
186}
187
188#[must_use]
203pub fn volatile_meta() -> Normalizer {
204 Normalizer::new()
205 .with(Mask::Drop(JsonPath::parse("metadata.uid")))
207 .with(Mask::Drop(JsonPath::parse("metadata.resourceVersion")))
208 .with(Mask::Drop(JsonPath::parse("metadata.generation")))
209 .with(Mask::Drop(JsonPath::parse("metadata.creationTimestamp")))
210 .with(Mask::Drop(JsonPath::parse("metadata.selfLink")))
211 .with(Mask::Drop(JsonPath::parse("metadata.managedFields")))
215 .with(Mask::Canonicalize(JsonPath::parse("status"), |_| {
231 Value::String("<present>".to_owned())
232 }))
233 .with(Mask::Drop(JsonPath::parse("spec.clusterIP")))
235 .with(Mask::Drop(JsonPath::parse("spec.clusterIPs")))
236 .with(Mask::Drop(JsonPath::parse("spec.nodeName")))
238 .with(Mask::Drop(JsonPath::parse("metadata.continue")))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use serde_json::json;
246
247 #[test]
248 fn drop_removes_nested_key() {
249 let mut norm = Normalizer::new().with(Mask::Drop(JsonPath::parse("metadata.uid")));
250 let v = json!({"metadata": {"name": "x", "uid": "abc"}});
251 let out = norm.apply(&v);
252 assert_eq!(out, json!({"metadata": {"name": "x"}}));
253 assert!(v["metadata"]["uid"].is_string());
255 norm = norm.with(Mask::Drop(JsonPath::parse("status")));
256 let out2 = norm.apply(&json!({"metadata": {"uid": "z"}, "status": {"phase": "Active"}}));
257 assert_eq!(out2, json!({"metadata": {}}));
258 }
259
260 #[test]
261 fn wildcard_drops_time_in_each_element() {
262 let norm = Normalizer::new().with(Mask::Drop(JsonPath::parse(
263 "metadata.managedFields[*].time",
264 )));
265 let v = json!({"metadata": {"managedFields": [
266 {"manager": "a", "time": "t1"},
267 {"manager": "b", "time": "t2"},
268 ]}});
269 let out = norm.apply(&v);
270 assert_eq!(
271 out,
272 json!({"metadata": {"managedFields": [{"manager": "a"}, {"manager": "b"}]}})
273 );
274 }
275
276 #[test]
277 fn volatile_meta_leaves_finalizers_and_data() {
278 let norm = volatile_meta();
279 let v = json!({
280 "metadata": {"name": "ns", "uid": "u", "finalizers": ["kubernetes"]},
281 "spec": {"finalizers": ["kubernetes"]},
282 "data": {"k": "v"},
283 "status": {"phase": "Active"},
284 });
285 let out = norm.apply(&v);
286 assert!(out["metadata"]["finalizers"].is_array());
287 assert!(out["spec"]["finalizers"].is_array());
288 assert_eq!(out["data"]["k"], json!("v"));
289 assert_eq!(
295 out["status"],
296 json!("<present>"),
297 "a present status must collapse to the marker, never vanish",
298 );
299 assert!(out["metadata"].get("uid").is_none());
300 }
301
302 #[test]
310 fn a_missing_status_is_distinguishable_from_a_present_one() {
311 let norm = volatile_meta();
312 let with = norm.apply(&json!({"kind":"Pod","status":{"phase":"Running"}}));
313 let without = norm.apply(&json!({"kind":"Pod"}));
314 assert_ne!(
315 with, without,
316 "an absent status MUST diverge from a present one — this is the \
317 single largest engenho gap and the harness has to be able to see it",
318 );
319 assert!(without.get("status").is_none());
320 }
321}