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 && 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 if obj.contains_key("storageConnectionStringSecret") {
180 obj.insert(
181 "storageConnectionStringSecret".to_string(),
182 Value::String("<REDACTED>".to_string()),
183 );
184 }
185
186 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 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 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 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 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 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}