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/// Normalize for comparison: like `normalize_for_push`, and additionally
62/// strips write-only fields (which the server never echoes back).
63pub fn normalize_for_compare(kind: ResourceKind, value: &Value) -> Value {
64    let mut out = normalize_for_push(kind, value);
65    for field in crate::registry::meta(kind).write_only_fields {
66        strip_field(&mut out, field);
67    }
68    out
69}
70
71/// Are two documents semantically equal for this kind (after normalization)?
72pub fn semantic_eq(kind: ResourceKind, a: &Value, b: &Value) -> bool {
73    let na = normalize_for_compare(kind, a);
74    let nb = normalize_for_compare(kind, b);
75    rigg_diff::semantic::diff(&na, &nb, "name").is_equal
76}
77
78/// Strip one registry field spec from a document.
79///
80/// - Specs containing `.` or `[]` are paths from the root (e.g.
81///   `properties.provisioningState`, `models[].apiKey`).
82/// - Bare names are removed at any depth (e.g. `@odata.etag` — note the
83///   leading `@` key itself contains dots but is matched as a literal key).
84fn strip_field(value: &mut Value, spec: &str) {
85    let is_literal_key = spec.starts_with('@') || (!spec.contains('.') && !spec.contains("[]"));
86    if is_literal_key {
87        remove_key_recursive(value, spec);
88    } else {
89        remove_path(value, &spec.split('.').collect::<Vec<_>>());
90    }
91}
92
93fn remove_key_recursive(value: &mut Value, key: &str) {
94    match value {
95        Value::Object(map) => {
96            map.remove(key);
97            for (_, v) in map.iter_mut() {
98                remove_key_recursive(v, key);
99            }
100        }
101        Value::Array(arr) => {
102            for item in arr {
103                remove_key_recursive(item, key);
104            }
105        }
106        _ => {}
107    }
108}
109
110fn remove_path(value: &mut Value, segments: &[&str]) {
111    let Some((head, rest)) = segments.split_first() else {
112        return;
113    };
114    if let Some(key) = head.strip_suffix("[]") {
115        let target = if key.is_empty() {
116            Some(value)
117        } else {
118            value.get_mut(key)
119        };
120        if let Some(Value::Array(arr)) = target {
121            for item in arr {
122                if rest.is_empty() {
123                    continue; // removing whole array elements is not a thing
124                }
125                remove_path(item, rest);
126            }
127        }
128    } else if rest.is_empty() {
129        if let Value::Object(map) = value {
130            map.remove(*head);
131        }
132    } else if let Some(next) = value.get_mut(*head) {
133        remove_path(next, rest);
134    }
135}
136
137/// Remove every `x-rigg-*` key at any depth (Rigg-local annotations).
138pub fn strip_x_rigg_keys(value: &mut Value) {
139    match value {
140        Value::Object(map) => {
141            map.retain(|k, _| !k.starts_with("x-rigg-"));
142            for (_, v) in map.iter_mut() {
143                strip_x_rigg_keys(v);
144            }
145        }
146        Value::Array(arr) => {
147            for item in arr {
148                strip_x_rigg_keys(item);
149            }
150        }
151        _ => {}
152    }
153}
154
155/// Format JSON with consistent formatting (2-space indent, trailing newline, sorted keys)
156pub fn format_json(value: &Value) -> String {
157    let mut output = serde_json::to_string_pretty(value).unwrap_or_default();
158    if !output.ends_with('\n') {
159        output.push('\n');
160    }
161    output
162}
163
164/// Strip sensitive fields from credentials objects
165pub fn redact_credentials(value: &mut Value) {
166    if let Some(obj) = value.as_object_mut() {
167        // Redact connection strings
168        if let Some(creds) = obj.get_mut("credentials")
169            && let Some(creds_obj) = creds.as_object_mut()
170            && creds_obj.contains_key("connectionString")
171        {
172            creds_obj.insert(
173                "connectionString".to_string(),
174                Value::String("<REDACTED>".to_string()),
175            );
176        }
177
178        // Redact storage connection strings
179        if obj.contains_key("storageConnectionStringSecret") {
180            obj.insert(
181                "storageConnectionStringSecret".to_string(),
182                Value::String("<REDACTED>".to_string()),
183            );
184        }
185
186        // Recursively process nested objects
187        for (_, v) in obj.iter_mut() {
188            redact_credentials(v);
189        }
190    } else if let Some(arr) = value.as_array_mut() {
191        for item in arr {
192            redact_credentials(item);
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use serde_json::json;
201
202    #[test]
203    fn disk_normalization_strips_volatile_and_read_only() {
204        // KnowledgeSource has a real read-only field: `createdResources`,
205        // nested under the active `<kind>Parameters` block. Azure returns it
206        // on GET but rejects it on PUT, so rigg strips it before writing to
207        // disk.
208        let ks = json!({
209            "@odata.etag": "0x123",
210            "name": "ks",
211            "kind": "azureBlob",
212            "azureBlobParameters": {
213                "containerName": "docs",
214                "createdResources": {"dataSourceName": "ks-auto-ds"}
215            },
216            "nested": {"@odata.etag": "0x456", "keep": true}
217        });
218        let out = normalize_for_disk(ResourceKind::KnowledgeSource, &ks);
219        assert!(out.get("@odata.etag").is_none());
220        assert!(
221            out["azureBlobParameters"].get("createdResources").is_none(),
222            "read-only stripped"
223        );
224        assert!(
225            out["nested"].get("@odata.etag").is_none(),
226            "etag stripped at depth"
227        );
228        assert_eq!(out["nested"]["keep"], json!(true));
229        assert_eq!(out["azureBlobParameters"]["containerName"], json!("docs"));
230        assert_eq!(out["kind"], json!("azureBlob"));
231    }
232
233    #[test]
234    fn dotted_path_stripping_for_arm_kinds() {
235        let dep = json!({
236            "name": "gpt-5-mini",
237            "properties": {
238                "model": {"name": "gpt-5-mini", "callRateLimit": {"count": 1}},
239                "provisioningState": "Succeeded",
240                "raiPolicyName": "default"
241            },
242            "systemData": {"createdAt": "2026-01-01"}
243        });
244        let out = normalize_for_disk(ResourceKind::Deployment, &dep);
245        assert!(out.get("systemData").is_none());
246        assert!(out["properties"].get("provisioningState").is_none());
247        assert!(out["properties"]["model"].get("callRateLimit").is_none());
248        assert_eq!(out["properties"]["raiPolicyName"], json!("default"));
249    }
250
251    #[test]
252    fn push_normalization_strips_x_rigg_but_disk_keeps() {
253        let agent = json!({
254            "name": "a",
255            "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb", "server_url": ""}]
256        });
257        let disk = normalize_for_disk(ResourceKind::Agent, &agent);
258        assert_eq!(disk["tools"][0]["x-rigg-ref"], json!("knowledge-bases/kb"));
259        let push = normalize_for_push(ResourceKind::Agent, &agent);
260        assert!(push["tools"][0].get("x-rigg-ref").is_none());
261        assert_eq!(push["tools"][0]["type"], json!("mcp"));
262    }
263
264    #[test]
265    fn push_normalization_strips_x_rigg_pin_annotation() {
266        // x-rigg-pin (rigg promote's per-resource pin list) is just another
267        // x-rigg-* key: normalize_for_push's generic strip covers it without
268        // needing a dedicated rule.
269        let doc = json!({
270            "name": "a",
271            "properties": {"target": "https://prod.example"},
272            "x-rigg-pin": ["properties.target"]
273        });
274        let disk = normalize_for_disk(ResourceKind::Connection, &doc);
275        assert_eq!(disk["x-rigg-pin"], json!(["properties.target"]));
276        let push = normalize_for_push(ResourceKind::Connection, &doc);
277        assert!(push.get("x-rigg-pin").is_none());
278    }
279
280    #[test]
281    fn semantic_eq_ignores_volatile_and_order() {
282        let a = json!({"name": "i", "@odata.etag": "1", "fields": [{"name": "f1"}]});
283        let b = json!({"@odata.etag": "2", "fields": [{"name": "f1"}], "name": "i"});
284        assert!(semantic_eq(ResourceKind::Index, &a, &b));
285        let c = json!({"name": "i", "fields": [{"name": "f2"}]});
286        assert!(!semantic_eq(ResourceKind::Index, &a, &c));
287    }
288
289    #[test]
290    fn test_strips_volatile_fields() {
291        let input = json!({
292            "@odata.etag": "abc123",
293            "@odata.context": "https://...",
294            "name": "test",
295            "fields": []
296        });
297
298        let result = normalize(&input, &["@odata.etag", "@odata.context"]);
299
300        assert!(result.get("@odata.etag").is_none());
301        assert!(result.get("@odata.context").is_none());
302        assert_eq!(result.get("name"), Some(&json!("test")));
303    }
304
305    #[test]
306    fn test_preserves_key_order() {
307        // Build a map with explicit insertion order
308        let mut map = serde_json::Map::new();
309        map.insert("zebra".to_string(), json!(1));
310        map.insert("apple".to_string(), json!(2));
311        map.insert("mango".to_string(), json!(3));
312        let input = Value::Object(map);
313
314        let result = normalize(&input, &[]);
315        let formatted = serde_json::to_string(&result).unwrap();
316
317        // Keys should preserve insertion order (not alphabetical)
318        let zebra_pos = formatted.find("zebra").unwrap();
319        let apple_pos = formatted.find("apple").unwrap();
320        let mango_pos = formatted.find("mango").unwrap();
321
322        assert!(zebra_pos < apple_pos);
323        assert!(apple_pos < mango_pos);
324    }
325
326    #[test]
327    fn test_preserves_array_order() {
328        let input = json!({
329            "items": [
330                {"name": "charlie", "value": 3},
331                {"name": "alice", "value": 1},
332                {"name": "bob", "value": 2}
333            ]
334        });
335
336        let result = normalize(&input, &[]);
337        let items = result.get("items").unwrap().as_array().unwrap();
338
339        // Order should be preserved as-is, not sorted
340        assert_eq!(items[0].get("name").unwrap(), "charlie");
341        assert_eq!(items[1].get("name").unwrap(), "alice");
342        assert_eq!(items[2].get("name").unwrap(), "bob");
343    }
344
345    #[test]
346    fn test_redact_credentials() {
347        let mut input = json!({
348            "name": "test",
349            "credentials": {
350                "connectionString": "secret-connection-string"
351            }
352        });
353
354        redact_credentials(&mut input);
355
356        assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
357    }
358
359    #[test]
360    fn test_deeply_nested_volatile_fields() {
361        let input = json!({
362            "name": "top",
363            "@odata.etag": "top-etag",
364            "nested": {
365                "@odata.etag": "nested-etag",
366                "value": 1,
367                "deeper": {
368                    "@odata.context": "ctx",
369                    "keep": true
370                }
371            }
372        });
373
374        let result = normalize(&input, &["@odata.etag", "@odata.context"]);
375
376        assert!(result.get("@odata.etag").is_none());
377        let nested = result.get("nested").unwrap();
378        assert!(nested.get("@odata.etag").is_none());
379        assert_eq!(nested.get("value"), Some(&json!(1)));
380        let deeper = nested.get("deeper").unwrap();
381        assert!(deeper.get("@odata.context").is_none());
382        assert_eq!(deeper.get("keep"), Some(&json!(true)));
383    }
384
385    #[test]
386    fn test_primitive_array_order_preserved() {
387        let input = json!({
388            "values": [3, 1, 2]
389        });
390
391        let result = normalize(&input, &[]);
392        let values = result.get("values").unwrap().as_array().unwrap();
393
394        assert_eq!(values[0], json!(3));
395        assert_eq!(values[1], json!(1));
396        assert_eq!(values[2], json!(2));
397    }
398
399    #[test]
400    fn test_empty_object_preserved() {
401        let input = json!({});
402        let result = normalize(&input, &[]);
403        assert_eq!(result, json!({}));
404    }
405
406    #[test]
407    fn test_empty_array_preserved() {
408        let input = json!({
409            "items": []
410        });
411
412        let result = normalize(&input, &[]);
413        let items = result.get("items").unwrap().as_array().unwrap();
414        assert!(items.is_empty());
415    }
416
417    #[test]
418    fn test_redact_nested_credentials() {
419        let mut input = json!({
420            "name": "test",
421            "outer": {
422                "credentials": {
423                    "connectionString": "nested-secret"
424                }
425            }
426        });
427
428        redact_credentials(&mut input);
429
430        assert_eq!(
431            input["outer"]["credentials"]["connectionString"],
432            "<REDACTED>"
433        );
434    }
435
436    #[test]
437    fn test_redact_storage_connection_string() {
438        let mut input = json!({
439            "name": "test",
440            "storageConnectionStringSecret": "my-storage-secret"
441        });
442
443        redact_credentials(&mut input);
444
445        assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
446    }
447
448    #[test]
449    fn test_redact_multiple_targets() {
450        let mut input = json!({
451            "name": "test",
452            "credentials": {
453                "connectionString": "secret-conn"
454            },
455            "storageConnectionStringSecret": "secret-storage"
456        });
457
458        redact_credentials(&mut input);
459
460        assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
461        assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
462    }
463
464    #[test]
465    fn test_redact_credentials_in_array() {
466        let mut input = json!({
467            "dataSources": [
468                {
469                    "name": "ds1",
470                    "credentials": {
471                        "connectionString": "secret1"
472                    }
473                },
474                {
475                    "name": "ds2",
476                    "credentials": {
477                        "connectionString": "secret2"
478                    }
479                }
480            ]
481        });
482
483        redact_credentials(&mut input);
484
485        assert_eq!(
486            input["dataSources"][0]["credentials"]["connectionString"],
487            "<REDACTED>"
488        );
489        assert_eq!(
490            input["dataSources"][1]["credentials"]["connectionString"],
491            "<REDACTED>"
492        );
493    }
494
495    #[test]
496    fn test_format_json_trailing_newline() {
497        let input = json!({"key": "value"});
498        let output = format_json(&input);
499        assert!(output.ends_with('\n'));
500    }
501
502    #[test]
503    fn test_format_json_empty_object() {
504        let input = json!({});
505        let output = format_json(&input);
506        assert_eq!(output, "{}\n");
507    }
508}