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 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
71pub 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
78fn 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; }
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
137pub 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
155pub 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
164pub fn redact_credentials(value: &mut Value) {
166 if let Some(obj) = value.as_object_mut() {
167 if let Some(creds) = obj.get_mut("credentials") {
169 if let Some(creds_obj) = creds.as_object_mut() {
170 if creds_obj.contains_key("connectionString") {
171 creds_obj.insert(
172 "connectionString".to_string(),
173 Value::String("<REDACTED>".to_string()),
174 );
175 }
176 }
177 }
178
179 if obj.contains_key("storageConnectionStringSecret") {
181 obj.insert(
182 "storageConnectionStringSecret".to_string(),
183 Value::String("<REDACTED>".to_string()),
184 );
185 }
186
187 for (_, v) in obj.iter_mut() {
189 redact_credentials(v);
190 }
191 } else if let Some(arr) = value.as_array_mut() {
192 for item in arr {
193 redact_credentials(item);
194 }
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use serde_json::json;
202
203 #[test]
204 fn disk_normalization_strips_volatile_and_read_only() {
205 let indexer = json!({
206 "@odata.etag": "0x123",
207 "name": "idxr",
208 "dataSourceName": "ds",
209 "status": "running",
210 "lastResult": {"status": "success"},
211 "nested": {"@odata.etag": "0x456", "keep": true}
212 });
213 let out = normalize_for_disk(ResourceKind::Indexer, &indexer);
214 assert!(out.get("@odata.etag").is_none());
215 assert!(out.get("status").is_none(), "read-only stripped");
216 assert!(out.get("lastResult").is_none());
217 assert!(
218 out["nested"].get("@odata.etag").is_none(),
219 "etag stripped at depth"
220 );
221 assert_eq!(out["nested"]["keep"], json!(true));
222 assert_eq!(out["dataSourceName"], json!("ds"));
223 }
224
225 #[test]
226 fn dotted_path_stripping_for_arm_kinds() {
227 let dep = json!({
228 "name": "gpt-5-mini",
229 "properties": {
230 "model": {"name": "gpt-5-mini", "callRateLimit": {"count": 1}},
231 "provisioningState": "Succeeded",
232 "raiPolicyName": "default"
233 },
234 "systemData": {"createdAt": "2026-01-01"}
235 });
236 let out = normalize_for_disk(ResourceKind::Deployment, &dep);
237 assert!(out.get("systemData").is_none());
238 assert!(out["properties"].get("provisioningState").is_none());
239 assert!(out["properties"]["model"].get("callRateLimit").is_none());
240 assert_eq!(out["properties"]["raiPolicyName"], json!("default"));
241 }
242
243 #[test]
244 fn push_normalization_strips_x_rigg_but_disk_keeps() {
245 let agent = json!({
246 "name": "a",
247 "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb", "server_url": ""}]
248 });
249 let disk = normalize_for_disk(ResourceKind::Agent, &agent);
250 assert_eq!(disk["tools"][0]["x-rigg-ref"], json!("knowledge-bases/kb"));
251 let push = normalize_for_push(ResourceKind::Agent, &agent);
252 assert!(push["tools"][0].get("x-rigg-ref").is_none());
253 assert_eq!(push["tools"][0]["type"], json!("mcp"));
254 }
255
256 #[test]
257 fn push_normalization_strips_x_rigg_pin_annotation() {
258 let doc = json!({
262 "name": "a",
263 "properties": {"target": "https://prod.example"},
264 "x-rigg-pin": ["properties.target"]
265 });
266 let disk = normalize_for_disk(ResourceKind::Connection, &doc);
267 assert_eq!(disk["x-rigg-pin"], json!(["properties.target"]));
268 let push = normalize_for_push(ResourceKind::Connection, &doc);
269 assert!(push.get("x-rigg-pin").is_none());
270 }
271
272 #[test]
273 fn semantic_eq_ignores_volatile_and_order() {
274 let a = json!({"name": "i", "@odata.etag": "1", "fields": [{"name": "f1"}]});
275 let b = json!({"@odata.etag": "2", "fields": [{"name": "f1"}], "name": "i"});
276 assert!(semantic_eq(ResourceKind::Index, &a, &b));
277 let c = json!({"name": "i", "fields": [{"name": "f2"}]});
278 assert!(!semantic_eq(ResourceKind::Index, &a, &c));
279 }
280
281 #[test]
282 fn test_strips_volatile_fields() {
283 let input = json!({
284 "@odata.etag": "abc123",
285 "@odata.context": "https://...",
286 "name": "test",
287 "fields": []
288 });
289
290 let result = normalize(&input, &["@odata.etag", "@odata.context"]);
291
292 assert!(result.get("@odata.etag").is_none());
293 assert!(result.get("@odata.context").is_none());
294 assert_eq!(result.get("name"), Some(&json!("test")));
295 }
296
297 #[test]
298 fn test_preserves_key_order() {
299 let mut map = serde_json::Map::new();
301 map.insert("zebra".to_string(), json!(1));
302 map.insert("apple".to_string(), json!(2));
303 map.insert("mango".to_string(), json!(3));
304 let input = Value::Object(map);
305
306 let result = normalize(&input, &[]);
307 let formatted = serde_json::to_string(&result).unwrap();
308
309 let zebra_pos = formatted.find("zebra").unwrap();
311 let apple_pos = formatted.find("apple").unwrap();
312 let mango_pos = formatted.find("mango").unwrap();
313
314 assert!(zebra_pos < apple_pos);
315 assert!(apple_pos < mango_pos);
316 }
317
318 #[test]
319 fn test_preserves_array_order() {
320 let input = json!({
321 "items": [
322 {"name": "charlie", "value": 3},
323 {"name": "alice", "value": 1},
324 {"name": "bob", "value": 2}
325 ]
326 });
327
328 let result = normalize(&input, &[]);
329 let items = result.get("items").unwrap().as_array().unwrap();
330
331 assert_eq!(items[0].get("name").unwrap(), "charlie");
333 assert_eq!(items[1].get("name").unwrap(), "alice");
334 assert_eq!(items[2].get("name").unwrap(), "bob");
335 }
336
337 #[test]
338 fn test_redact_credentials() {
339 let mut input = json!({
340 "name": "test",
341 "credentials": {
342 "connectionString": "secret-connection-string"
343 }
344 });
345
346 redact_credentials(&mut input);
347
348 assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
349 }
350
351 #[test]
352 fn test_deeply_nested_volatile_fields() {
353 let input = json!({
354 "name": "top",
355 "@odata.etag": "top-etag",
356 "nested": {
357 "@odata.etag": "nested-etag",
358 "value": 1,
359 "deeper": {
360 "@odata.context": "ctx",
361 "keep": true
362 }
363 }
364 });
365
366 let result = normalize(&input, &["@odata.etag", "@odata.context"]);
367
368 assert!(result.get("@odata.etag").is_none());
369 let nested = result.get("nested").unwrap();
370 assert!(nested.get("@odata.etag").is_none());
371 assert_eq!(nested.get("value"), Some(&json!(1)));
372 let deeper = nested.get("deeper").unwrap();
373 assert!(deeper.get("@odata.context").is_none());
374 assert_eq!(deeper.get("keep"), Some(&json!(true)));
375 }
376
377 #[test]
378 fn test_primitive_array_order_preserved() {
379 let input = json!({
380 "values": [3, 1, 2]
381 });
382
383 let result = normalize(&input, &[]);
384 let values = result.get("values").unwrap().as_array().unwrap();
385
386 assert_eq!(values[0], json!(3));
387 assert_eq!(values[1], json!(1));
388 assert_eq!(values[2], json!(2));
389 }
390
391 #[test]
392 fn test_empty_object_preserved() {
393 let input = json!({});
394 let result = normalize(&input, &[]);
395 assert_eq!(result, json!({}));
396 }
397
398 #[test]
399 fn test_empty_array_preserved() {
400 let input = json!({
401 "items": []
402 });
403
404 let result = normalize(&input, &[]);
405 let items = result.get("items").unwrap().as_array().unwrap();
406 assert!(items.is_empty());
407 }
408
409 #[test]
410 fn test_redact_nested_credentials() {
411 let mut input = json!({
412 "name": "test",
413 "outer": {
414 "credentials": {
415 "connectionString": "nested-secret"
416 }
417 }
418 });
419
420 redact_credentials(&mut input);
421
422 assert_eq!(
423 input["outer"]["credentials"]["connectionString"],
424 "<REDACTED>"
425 );
426 }
427
428 #[test]
429 fn test_redact_storage_connection_string() {
430 let mut input = json!({
431 "name": "test",
432 "storageConnectionStringSecret": "my-storage-secret"
433 });
434
435 redact_credentials(&mut input);
436
437 assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
438 }
439
440 #[test]
441 fn test_redact_multiple_targets() {
442 let mut input = json!({
443 "name": "test",
444 "credentials": {
445 "connectionString": "secret-conn"
446 },
447 "storageConnectionStringSecret": "secret-storage"
448 });
449
450 redact_credentials(&mut input);
451
452 assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
453 assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
454 }
455
456 #[test]
457 fn test_redact_credentials_in_array() {
458 let mut input = json!({
459 "dataSources": [
460 {
461 "name": "ds1",
462 "credentials": {
463 "connectionString": "secret1"
464 }
465 },
466 {
467 "name": "ds2",
468 "credentials": {
469 "connectionString": "secret2"
470 }
471 }
472 ]
473 });
474
475 redact_credentials(&mut input);
476
477 assert_eq!(
478 input["dataSources"][0]["credentials"]["connectionString"],
479 "<REDACTED>"
480 );
481 assert_eq!(
482 input["dataSources"][1]["credentials"]["connectionString"],
483 "<REDACTED>"
484 );
485 }
486
487 #[test]
488 fn test_format_json_trailing_newline() {
489 let input = json!({"key": "value"});
490 let output = format_json(&input);
491 assert!(output.ends_with('\n'));
492 }
493
494 #[test]
495 fn test_format_json_empty_object() {
496 let input = json!({});
497 let output = format_json(&input);
498 assert_eq!(output, "{}\n");
499 }
500}