Skip to main content

rigg_core/
normalize.rs

1//! JSON normalization for consistent Git diffs
2
3use serde_json::{Map, Value};
4
5use crate::resources::traits::ResourceKind;
6
7/// Normalize a JSON value for consistent Git diffs
8///
9/// This performs:
10/// 1. Strips volatile fields (@odata.etag, @odata.context, credentials, etc.)
11/// 2. Preserves the property order from the Azure API response
12/// 3. Preserves array element order as returned by the API
13pub fn normalize(value: &Value, volatile_fields: &[&str]) -> Value {
14    normalize_value(value, volatile_fields)
15}
16
17fn normalize_value(value: &Value, volatile_fields: &[&str]) -> Value {
18    match value {
19        Value::Object(map) => {
20            // Preserve original key order, just filter out volatile fields
21            let filtered: Map<String, Value> = map
22                .iter()
23                .filter(|(k, _)| !volatile_fields.contains(&k.as_str()))
24                .map(|(k, v)| (k.clone(), normalize_value(v, volatile_fields)))
25                .collect();
26
27            Value::Object(filtered)
28        }
29        Value::Array(arr) => {
30            let normalized: Vec<Value> = arr
31                .iter()
32                .map(|v| normalize_value(v, volatile_fields))
33                .collect();
34
35            Value::Array(normalized)
36        }
37        _ => value.clone(),
38    }
39}
40
41/// Normalize a resource for storage on disk: strips the kind's volatile and
42/// read-only fields (registry-driven) while preserving Azure's property order.
43/// Rigg-local `x-rigg-*` annotations are kept.
44pub fn normalize_for_disk(kind: ResourceKind, value: &Value) -> Value {
45    let meta = crate::registry::meta(kind);
46    let mut out = value.clone();
47    for field in meta.volatile_fields.iter().chain(meta.read_only_fields) {
48        strip_field(&mut out, field);
49    }
50    out
51}
52
53/// Normalize a resource for pushing to Azure: everything `normalize_for_disk`
54/// strips, plus all `x-rigg-*` annotation keys at any depth.
55pub fn normalize_for_push(kind: ResourceKind, value: &Value) -> Value {
56    let mut out = normalize_for_disk(kind, value);
57    strip_x_rigg_keys(&mut out);
58    out
59}
60
61/// Are two documents semantically equal for this kind (after normalization)?
62pub fn semantic_eq(kind: ResourceKind, a: &Value, b: &Value) -> bool {
63    let na = normalize_for_push(kind, a);
64    let nb = normalize_for_push(kind, b);
65    rigg_diff::semantic::diff(&na, &nb, "name").is_equal
66}
67
68/// Strip one registry field spec from a document.
69///
70/// - Specs containing `.` or `[]` are paths from the root (e.g.
71///   `properties.provisioningState`, `models[].apiKey`).
72/// - Bare names are removed at any depth (e.g. `@odata.etag` — note the
73///   leading `@` key itself contains dots but is matched as a literal key).
74fn strip_field(value: &mut Value, spec: &str) {
75    let is_literal_key = spec.starts_with('@') || (!spec.contains('.') && !spec.contains("[]"));
76    if is_literal_key {
77        remove_key_recursive(value, spec);
78    } else {
79        remove_path(value, &spec.split('.').collect::<Vec<_>>());
80    }
81}
82
83fn remove_key_recursive(value: &mut Value, key: &str) {
84    match value {
85        Value::Object(map) => {
86            map.remove(key);
87            for (_, v) in map.iter_mut() {
88                remove_key_recursive(v, key);
89            }
90        }
91        Value::Array(arr) => {
92            for item in arr {
93                remove_key_recursive(item, key);
94            }
95        }
96        _ => {}
97    }
98}
99
100fn remove_path(value: &mut Value, segments: &[&str]) {
101    let Some((head, rest)) = segments.split_first() else {
102        return;
103    };
104    if let Some(key) = head.strip_suffix("[]") {
105        let target = if key.is_empty() {
106            Some(value)
107        } else {
108            value.get_mut(key)
109        };
110        if let Some(Value::Array(arr)) = target {
111            for item in arr {
112                if rest.is_empty() {
113                    continue; // removing whole array elements is not a thing
114                }
115                remove_path(item, rest);
116            }
117        }
118    } else if rest.is_empty() {
119        if let Value::Object(map) = value {
120            map.remove(*head);
121        }
122    } else if let Some(next) = value.get_mut(*head) {
123        remove_path(next, rest);
124    }
125}
126
127/// Remove every `x-rigg-*` key at any depth (Rigg-local annotations).
128pub fn strip_x_rigg_keys(value: &mut Value) {
129    match value {
130        Value::Object(map) => {
131            map.retain(|k, _| !k.starts_with("x-rigg-"));
132            for (_, v) in map.iter_mut() {
133                strip_x_rigg_keys(v);
134            }
135        }
136        Value::Array(arr) => {
137            for item in arr {
138                strip_x_rigg_keys(item);
139            }
140        }
141        _ => {}
142    }
143}
144
145/// Format JSON with consistent formatting (2-space indent, trailing newline, sorted keys)
146pub fn format_json(value: &Value) -> String {
147    let mut output = serde_json::to_string_pretty(value).unwrap_or_default();
148    if !output.ends_with('\n') {
149        output.push('\n');
150    }
151    output
152}
153
154/// Strip sensitive fields from credentials objects
155pub fn redact_credentials(value: &mut Value) {
156    if let Some(obj) = value.as_object_mut() {
157        // Redact connection strings
158        if let Some(creds) = obj.get_mut("credentials") {
159            if let Some(creds_obj) = creds.as_object_mut() {
160                if creds_obj.contains_key("connectionString") {
161                    creds_obj.insert(
162                        "connectionString".to_string(),
163                        Value::String("<REDACTED>".to_string()),
164                    );
165                }
166            }
167        }
168
169        // Redact storage connection strings
170        if obj.contains_key("storageConnectionStringSecret") {
171            obj.insert(
172                "storageConnectionStringSecret".to_string(),
173                Value::String("<REDACTED>".to_string()),
174            );
175        }
176
177        // Recursively process nested objects
178        for (_, v) in obj.iter_mut() {
179            redact_credentials(v);
180        }
181    } else if let Some(arr) = value.as_array_mut() {
182        for item in arr {
183            redact_credentials(item);
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use serde_json::json;
192
193    #[test]
194    fn disk_normalization_strips_volatile_and_read_only() {
195        let indexer = json!({
196            "@odata.etag": "0x123",
197            "name": "idxr",
198            "dataSourceName": "ds",
199            "status": "running",
200            "lastResult": {"status": "success"},
201            "nested": {"@odata.etag": "0x456", "keep": true}
202        });
203        let out = normalize_for_disk(ResourceKind::Indexer, &indexer);
204        assert!(out.get("@odata.etag").is_none());
205        assert!(out.get("status").is_none(), "read-only stripped");
206        assert!(out.get("lastResult").is_none());
207        assert!(
208            out["nested"].get("@odata.etag").is_none(),
209            "etag stripped at depth"
210        );
211        assert_eq!(out["nested"]["keep"], json!(true));
212        assert_eq!(out["dataSourceName"], json!("ds"));
213    }
214
215    #[test]
216    fn dotted_path_stripping_for_arm_kinds() {
217        let dep = json!({
218            "name": "gpt-5-mini",
219            "properties": {
220                "model": {"name": "gpt-5-mini", "callRateLimit": {"count": 1}},
221                "provisioningState": "Succeeded",
222                "raiPolicyName": "default"
223            },
224            "systemData": {"createdAt": "2026-01-01"}
225        });
226        let out = normalize_for_disk(ResourceKind::Deployment, &dep);
227        assert!(out.get("systemData").is_none());
228        assert!(out["properties"].get("provisioningState").is_none());
229        assert!(out["properties"]["model"].get("callRateLimit").is_none());
230        assert_eq!(out["properties"]["raiPolicyName"], json!("default"));
231    }
232
233    #[test]
234    fn push_normalization_strips_x_rigg_but_disk_keeps() {
235        let agent = json!({
236            "name": "a",
237            "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb", "server_url": ""}]
238        });
239        let disk = normalize_for_disk(ResourceKind::Agent, &agent);
240        assert_eq!(disk["tools"][0]["x-rigg-ref"], json!("knowledge-bases/kb"));
241        let push = normalize_for_push(ResourceKind::Agent, &agent);
242        assert!(push["tools"][0].get("x-rigg-ref").is_none());
243        assert_eq!(push["tools"][0]["type"], json!("mcp"));
244    }
245
246    #[test]
247    fn semantic_eq_ignores_volatile_and_order() {
248        let a = json!({"name": "i", "@odata.etag": "1", "fields": [{"name": "f1"}]});
249        let b = json!({"@odata.etag": "2", "fields": [{"name": "f1"}], "name": "i"});
250        assert!(semantic_eq(ResourceKind::Index, &a, &b));
251        let c = json!({"name": "i", "fields": [{"name": "f2"}]});
252        assert!(!semantic_eq(ResourceKind::Index, &a, &c));
253    }
254
255    #[test]
256    fn test_strips_volatile_fields() {
257        let input = json!({
258            "@odata.etag": "abc123",
259            "@odata.context": "https://...",
260            "name": "test",
261            "fields": []
262        });
263
264        let result = normalize(&input, &["@odata.etag", "@odata.context"]);
265
266        assert!(result.get("@odata.etag").is_none());
267        assert!(result.get("@odata.context").is_none());
268        assert_eq!(result.get("name"), Some(&json!("test")));
269    }
270
271    #[test]
272    fn test_preserves_key_order() {
273        // Build a map with explicit insertion order
274        let mut map = serde_json::Map::new();
275        map.insert("zebra".to_string(), json!(1));
276        map.insert("apple".to_string(), json!(2));
277        map.insert("mango".to_string(), json!(3));
278        let input = Value::Object(map);
279
280        let result = normalize(&input, &[]);
281        let formatted = serde_json::to_string(&result).unwrap();
282
283        // Keys should preserve insertion order (not alphabetical)
284        let zebra_pos = formatted.find("zebra").unwrap();
285        let apple_pos = formatted.find("apple").unwrap();
286        let mango_pos = formatted.find("mango").unwrap();
287
288        assert!(zebra_pos < apple_pos);
289        assert!(apple_pos < mango_pos);
290    }
291
292    #[test]
293    fn test_preserves_array_order() {
294        let input = json!({
295            "items": [
296                {"name": "charlie", "value": 3},
297                {"name": "alice", "value": 1},
298                {"name": "bob", "value": 2}
299            ]
300        });
301
302        let result = normalize(&input, &[]);
303        let items = result.get("items").unwrap().as_array().unwrap();
304
305        // Order should be preserved as-is, not sorted
306        assert_eq!(items[0].get("name").unwrap(), "charlie");
307        assert_eq!(items[1].get("name").unwrap(), "alice");
308        assert_eq!(items[2].get("name").unwrap(), "bob");
309    }
310
311    #[test]
312    fn test_redact_credentials() {
313        let mut input = json!({
314            "name": "test",
315            "credentials": {
316                "connectionString": "secret-connection-string"
317            }
318        });
319
320        redact_credentials(&mut input);
321
322        assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
323    }
324
325    #[test]
326    fn test_deeply_nested_volatile_fields() {
327        let input = json!({
328            "name": "top",
329            "@odata.etag": "top-etag",
330            "nested": {
331                "@odata.etag": "nested-etag",
332                "value": 1,
333                "deeper": {
334                    "@odata.context": "ctx",
335                    "keep": true
336                }
337            }
338        });
339
340        let result = normalize(&input, &["@odata.etag", "@odata.context"]);
341
342        assert!(result.get("@odata.etag").is_none());
343        let nested = result.get("nested").unwrap();
344        assert!(nested.get("@odata.etag").is_none());
345        assert_eq!(nested.get("value"), Some(&json!(1)));
346        let deeper = nested.get("deeper").unwrap();
347        assert!(deeper.get("@odata.context").is_none());
348        assert_eq!(deeper.get("keep"), Some(&json!(true)));
349    }
350
351    #[test]
352    fn test_primitive_array_order_preserved() {
353        let input = json!({
354            "values": [3, 1, 2]
355        });
356
357        let result = normalize(&input, &[]);
358        let values = result.get("values").unwrap().as_array().unwrap();
359
360        assert_eq!(values[0], json!(3));
361        assert_eq!(values[1], json!(1));
362        assert_eq!(values[2], json!(2));
363    }
364
365    #[test]
366    fn test_empty_object_preserved() {
367        let input = json!({});
368        let result = normalize(&input, &[]);
369        assert_eq!(result, json!({}));
370    }
371
372    #[test]
373    fn test_empty_array_preserved() {
374        let input = json!({
375            "items": []
376        });
377
378        let result = normalize(&input, &[]);
379        let items = result.get("items").unwrap().as_array().unwrap();
380        assert!(items.is_empty());
381    }
382
383    #[test]
384    fn test_redact_nested_credentials() {
385        let mut input = json!({
386            "name": "test",
387            "outer": {
388                "credentials": {
389                    "connectionString": "nested-secret"
390                }
391            }
392        });
393
394        redact_credentials(&mut input);
395
396        assert_eq!(
397            input["outer"]["credentials"]["connectionString"],
398            "<REDACTED>"
399        );
400    }
401
402    #[test]
403    fn test_redact_storage_connection_string() {
404        let mut input = json!({
405            "name": "test",
406            "storageConnectionStringSecret": "my-storage-secret"
407        });
408
409        redact_credentials(&mut input);
410
411        assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
412    }
413
414    #[test]
415    fn test_redact_multiple_targets() {
416        let mut input = json!({
417            "name": "test",
418            "credentials": {
419                "connectionString": "secret-conn"
420            },
421            "storageConnectionStringSecret": "secret-storage"
422        });
423
424        redact_credentials(&mut input);
425
426        assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
427        assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
428    }
429
430    #[test]
431    fn test_redact_credentials_in_array() {
432        let mut input = json!({
433            "dataSources": [
434                {
435                    "name": "ds1",
436                    "credentials": {
437                        "connectionString": "secret1"
438                    }
439                },
440                {
441                    "name": "ds2",
442                    "credentials": {
443                        "connectionString": "secret2"
444                    }
445                }
446            ]
447        });
448
449        redact_credentials(&mut input);
450
451        assert_eq!(
452            input["dataSources"][0]["credentials"]["connectionString"],
453            "<REDACTED>"
454        );
455        assert_eq!(
456            input["dataSources"][1]["credentials"]["connectionString"],
457            "<REDACTED>"
458        );
459    }
460
461    #[test]
462    fn test_format_json_trailing_newline() {
463        let input = json!({"key": "value"});
464        let output = format_json(&input);
465        assert!(output.ends_with('\n'));
466    }
467
468    #[test]
469    fn test_format_json_empty_object() {
470        let input = json!({});
471        let output = format_json(&input);
472        assert_eq!(output, "{}\n");
473    }
474}