1use serde_json::{Map, Value};
4
5use crate::resources::traits::ResourceKind;
6
7pub 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 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
41pub 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
53pub 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
61pub 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
68fn 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; }
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
127pub 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
145pub 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
154pub fn redact_credentials(value: &mut Value) {
156 if let Some(obj) = value.as_object_mut() {
157 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 if obj.contains_key("storageConnectionStringSecret") {
171 obj.insert(
172 "storageConnectionStringSecret".to_string(),
173 Value::String("<REDACTED>".to_string()),
174 );
175 }
176
177 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 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 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 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}