1use serde_json::Value;
13
14use crate::resources::traits::{ResourceKind, ResourceRef};
15
16pub const SEARCH_STABLE_API_VERSION: &str = "2026-04-01";
18pub const SEARCH_PREVIEW_API_VERSION: &str = "2026-05-01-preview";
19pub const FOUNDRY_API_VERSION: &str = "v1";
20pub const ARM_COGNITIVE_API_VERSION: &str = "2026-05-01";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Domain {
26 Search,
28 FoundryData,
30 FoundryArm,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Channel {
37 Stable,
38 Preview,
39}
40
41#[derive(Debug, Clone, Copy)]
48pub struct RefField {
49 pub path: &'static str,
50 pub to: ResourceKind,
51}
52
53#[derive(Debug, Clone, Copy)]
55pub struct KindMeta {
56 pub kind: ResourceKind,
57 pub domain: Domain,
58 pub collection_path: &'static str,
60 pub dir_name: &'static str,
62 pub channel: Channel,
64 pub volatile_fields: &'static [&'static str],
67 pub read_only_fields: &'static [&'static str],
69 pub secret_fields: &'static [&'static str],
72 pub write_only_fields: &'static [&'static str],
75 pub sidecar_fields: &'static [&'static str],
77 pub reference_fields: &'static [RefField],
79 pub immutable_fields: &'static [&'static str],
83}
84
85const COMMON_VOLATILE: &[&str] = &["@odata.etag", "@odata.context", "e_tag", "etag"];
86
87static KINDS: &[KindMeta] = &[
88 KindMeta {
89 kind: ResourceKind::DataSource,
90 domain: Domain::Search,
91 collection_path: "datasources",
92 dir_name: "data-sources",
93 channel: Channel::Stable,
94 volatile_fields: COMMON_VOLATILE,
95 read_only_fields: &[],
96 secret_fields: &["credentials.connectionString"],
97 write_only_fields: &["credentials.connectionString"],
98 sidecar_fields: &[],
99 reference_fields: &[],
100 immutable_fields: &[],
101 },
102 KindMeta {
103 kind: ResourceKind::Index,
104 domain: Domain::Search,
105 collection_path: "indexes",
106 dir_name: "indexes",
107 channel: Channel::Stable,
108 volatile_fields: COMMON_VOLATILE,
109 read_only_fields: &[],
110 secret_fields: &[
111 "encryptionKey.accessCredentials.applicationSecret",
112 "vectorSearch.vectorizers[].azureOpenAIParameters.apiKey",
113 ],
114 write_only_fields: &[],
115 sidecar_fields: &[],
116 reference_fields: &[],
117 immutable_fields: &[],
118 },
119 KindMeta {
120 kind: ResourceKind::Skillset,
121 domain: Domain::Search,
122 collection_path: "skillsets",
123 dir_name: "skillsets",
124 channel: Channel::Stable,
125 volatile_fields: COMMON_VOLATILE,
126 read_only_fields: &[],
127 secret_fields: &[
128 "cognitiveServices.key",
129 "skills[].apiKey",
130 "encryptionKey.accessCredentials.applicationSecret",
131 ],
132 write_only_fields: &[],
133 sidecar_fields: &[],
134 reference_fields: &[
135 RefField {
137 path: "knowledgeStore.projections[].objects[].storageContainer",
138 to: ResourceKind::Index,
139 },
140 RefField {
142 path: "indexProjections.selectors[].targetIndexName",
143 to: ResourceKind::Index,
144 },
145 ],
146 immutable_fields: &[],
147 },
148 KindMeta {
149 kind: ResourceKind::Indexer,
150 domain: Domain::Search,
151 collection_path: "indexers",
152 dir_name: "indexers",
153 channel: Channel::Stable,
154 volatile_fields: COMMON_VOLATILE,
155 read_only_fields: &["status", "lastResult", "executionHistory", "limits"],
156 secret_fields: &[],
157 write_only_fields: &[],
158 sidecar_fields: &[],
159 reference_fields: &[
160 RefField {
161 path: "dataSourceName",
162 to: ResourceKind::DataSource,
163 },
164 RefField {
165 path: "targetIndexName",
166 to: ResourceKind::Index,
167 },
168 RefField {
169 path: "skillsetName",
170 to: ResourceKind::Skillset,
171 },
172 ],
173 immutable_fields: &[],
174 },
175 KindMeta {
176 kind: ResourceKind::SynonymMap,
177 domain: Domain::Search,
178 collection_path: "synonymmaps",
179 dir_name: "synonym-maps",
180 channel: Channel::Stable,
181 volatile_fields: COMMON_VOLATILE,
182 read_only_fields: &[],
183 secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
184 write_only_fields: &[],
185 sidecar_fields: &[],
186 reference_fields: &[],
187 immutable_fields: &[],
188 },
189 KindMeta {
190 kind: ResourceKind::Alias,
191 domain: Domain::Search,
192 collection_path: "aliases",
193 dir_name: "aliases",
194 channel: Channel::Stable,
195 volatile_fields: COMMON_VOLATILE,
196 read_only_fields: &[],
197 secret_fields: &[],
198 write_only_fields: &[],
199 sidecar_fields: &[],
200 reference_fields: &[RefField {
201 path: "indexes[]",
202 to: ResourceKind::Index,
203 }],
204 immutable_fields: &[],
205 },
206 KindMeta {
207 kind: ResourceKind::KnowledgeSource,
208 domain: Domain::Search,
209 collection_path: "knowledgeSources",
210 dir_name: "knowledge-sources",
211 channel: Channel::Stable,
212 volatile_fields: COMMON_VOLATILE,
213 read_only_fields: &["createdResources", "ingestionPermissionOptions"],
215 secret_fields: &[
220 "searchIndexParameters.apiKey",
221 "azureBlobParameters.connectionString",
222 ],
223 write_only_fields: &[],
224 sidecar_fields: &[],
225 reference_fields: &[RefField {
226 path: "searchIndexParameters.searchIndexName",
227 to: ResourceKind::Index,
228 }],
229 immutable_fields: &["kind"],
232 },
233 KindMeta {
234 kind: ResourceKind::KnowledgeBase,
235 domain: Domain::Search,
236 collection_path: "knowledgeBases",
237 dir_name: "knowledge-bases",
238 channel: Channel::Stable,
239 volatile_fields: COMMON_VOLATILE,
240 read_only_fields: &[],
241 secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
242 write_only_fields: &[],
243 sidecar_fields: &[],
244 reference_fields: &[RefField {
245 path: "knowledgeSources[].name",
246 to: ResourceKind::KnowledgeSource,
247 }],
248 immutable_fields: &[],
249 },
250 KindMeta {
251 kind: ResourceKind::Agent,
252 domain: Domain::FoundryData,
253 collection_path: "agents",
254 dir_name: "agents",
255 channel: Channel::Stable,
256 volatile_fields: &[
257 "@odata.etag",
258 "@odata.context",
259 "id",
260 "object",
261 "created_at",
262 "updated_at",
263 "version",
264 "metadata.modified_at",
265 ],
266 read_only_fields: &[],
267 secret_fields: &[],
268 write_only_fields: &[],
269 sidecar_fields: &["instructions"],
270 reference_fields: &[
271 RefField {
272 path: "model",
273 to: ResourceKind::Deployment,
274 },
275 RefField {
276 path: "tools[].project_connection_id",
277 to: ResourceKind::Connection,
278 },
279 ],
280 immutable_fields: &[],
281 },
282 KindMeta {
283 kind: ResourceKind::Deployment,
284 domain: Domain::FoundryArm,
285 collection_path: "deployments",
286 dir_name: "deployments",
287 channel: Channel::Stable,
288 volatile_fields: &[
289 "id",
290 "type",
291 "systemData",
292 "etag",
293 "properties.provisioningState",
294 "properties.capabilities",
295 "properties.rateLimits",
296 "properties.model.callRateLimit",
297 "properties.currentCapacity",
298 "properties.deploymentState",
299 ],
300 read_only_fields: &[],
301 secret_fields: &[],
302 write_only_fields: &[],
303 sidecar_fields: &[],
304 reference_fields: &[RefField {
305 path: "properties.raiPolicyName",
306 to: ResourceKind::Guardrail,
307 }],
308 immutable_fields: &[],
309 },
310 KindMeta {
311 kind: ResourceKind::Connection,
312 domain: Domain::FoundryArm,
313 collection_path: "connections",
314 dir_name: "connections",
315 channel: Channel::Stable,
316 volatile_fields: &[
317 "id",
318 "type",
319 "systemData",
320 "etag",
321 "properties.provisioningState",
322 ],
323 read_only_fields: &[],
324 secret_fields: &[
326 "properties.credentials.key",
327 "properties.credentials.keys",
328 "properties.credentials.secret",
329 "properties.credentials.clientSecret",
330 "properties.credentials.pat",
331 "properties.credentials.sas",
332 ],
333 write_only_fields: &[],
334 sidecar_fields: &[],
335 reference_fields: &[],
336 immutable_fields: &[],
337 },
338 KindMeta {
339 kind: ResourceKind::Guardrail,
340 domain: Domain::FoundryArm,
341 collection_path: "raiPolicies",
342 dir_name: "guardrails",
343 channel: Channel::Stable,
344 volatile_fields: &["id", "type", "systemData", "etag"],
345 read_only_fields: &[],
346 secret_fields: &[],
347 write_only_fields: &[],
348 sidecar_fields: &[],
349 reference_fields: &[],
350 immutable_fields: &[],
351 },
352];
353
354pub fn all_kinds() -> &'static [ResourceKind] {
356 static ORDER: &[ResourceKind] = &[
357 ResourceKind::DataSource,
358 ResourceKind::Index,
359 ResourceKind::Skillset,
360 ResourceKind::Indexer,
361 ResourceKind::SynonymMap,
362 ResourceKind::Alias,
363 ResourceKind::KnowledgeSource,
364 ResourceKind::KnowledgeBase,
365 ResourceKind::Agent,
366 ResourceKind::Deployment,
367 ResourceKind::Connection,
368 ResourceKind::Guardrail,
369 ];
370 ORDER
371}
372
373pub fn meta(kind: ResourceKind) -> &'static KindMeta {
375 KINDS
376 .iter()
377 .find(|m| m.kind == kind)
378 .expect("registry entry exists for every ResourceKind")
379}
380
381pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
387 const GA: &[&str] = &[
388 "azureblob",
389 "adlsgen2",
390 "azuretable",
391 "azuresql",
392 "cosmosdb",
393 "onelake",
394 ];
395 const PREVIEW: &[&str] = &[
396 "azureblob",
397 "adlsgen2",
398 "azuretable",
399 "azuresql",
400 "cosmosdb",
401 "onelake",
402 "mysql",
403 "sharepoint",
404 "azurefile",
405 "azurefiles",
406 ];
407 match channel {
408 Channel::Stable => GA,
409 Channel::Preview => PREVIEW,
410 }
411}
412
413pub fn preview_only_datasource_types() -> &'static [&'static str] {
415 &["mysql", "sharepoint", "azurefile", "azurefiles"]
416}
417
418pub const X_RIGG_REF: &str = "x-rigg-ref";
421pub const X_RIGG_API: &str = "x-rigg-api";
423pub const X_RIGG_PIN: &str = "x-rigg-pin";
428
429fn env_pinned_extra(kind: ResourceKind) -> &'static [&'static str] {
434 match kind {
435 ResourceKind::Agent => &["tools[].server_url", "tools[].project_connection_id"],
436 ResourceKind::Connection => &["properties.target"],
437 _ => &[],
438 }
439}
440
441pub fn env_pinned(kind: ResourceKind) -> Vec<&'static str> {
446 let m = meta(kind);
447 let mut out: Vec<&'static str> = Vec::new();
448 for field in m
449 .secret_fields
450 .iter()
451 .chain(m.write_only_fields)
452 .chain(env_pinned_extra(kind))
453 {
454 if !out.contains(field) {
455 out.push(field);
456 }
457 }
458 out
459}
460
461fn collect_path_mut(v: &mut Value, path: &str, f: &mut dyn FnMut(&mut Value)) {
463 fn walk(v: &mut Value, segments: &[&str], f: &mut dyn FnMut(&mut Value)) {
464 let Some((head, rest)) = segments.split_first() else {
465 f(v);
466 return;
467 };
468 if let Some(key) = head.strip_suffix("[]") {
469 let target = if key.is_empty() {
470 Some(v)
471 } else {
472 v.get_mut(key)
473 };
474 if let Some(Value::Array(arr)) = target {
475 for item in arr {
476 walk(item, rest, f);
477 }
478 }
479 } else if let Some(next) = v.get_mut(*head) {
480 walk(next, rest, f);
481 }
482 }
483 let segments: Vec<&str> = path.split('.').collect();
484 walk(v, &segments, f);
485}
486
487pub fn rename_reference(
493 kind: ResourceKind,
494 body: &mut Value,
495 to: ResourceKind,
496 old: &str,
497 new: &str,
498) {
499 for rf in meta(kind).reference_fields {
500 if rf.to != to {
501 continue;
502 }
503 collect_path_mut(body, rf.path, &mut |v| {
504 if v.as_str() == Some(old) {
505 *v = Value::String(new.to_string());
506 }
507 });
508 }
509}
510
511pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
514 let mut out = Vec::new();
515 for rf in meta(kind).reference_fields {
516 collect_path(body, rf.path, &mut |v| {
517 if let Some(s) = v.as_str() {
518 if !s.is_empty() {
519 out.push((rf.to, s.to_string()));
520 }
521 }
522 });
523 }
524 collect_x_rigg_refs(body, &mut out);
525 if kind == ResourceKind::Agent {
526 collect_portal_agent_refs(body, &mut out);
527 }
528 out.sort();
529 out.dedup();
530 out
531}
532
533fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
538 match v {
539 Value::Object(map) => {
540 if let Some(url) = map.get("server_url").and_then(Value::as_str) {
541 if let Some(kb) = parse_kb_mcp_url(url) {
542 out.push((ResourceKind::KnowledgeBase, kb));
543 }
544 }
545 for val in map.values() {
546 collect_portal_agent_refs(val, out);
547 }
548 }
549 Value::Array(arr) => {
550 for item in arr {
551 collect_portal_agent_refs(item, out);
552 }
553 }
554 _ => {}
555 }
556}
557
558fn parse_kb_mcp_url(url: &str) -> Option<String> {
560 let rest = url.strip_prefix("https://")?;
561 let (host, path) = rest.split_once('/')?;
562 if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
563 return None;
564 }
565 let path = path.split('?').next().unwrap_or(path);
566 let mut segs = path.split('/').filter(|s| !s.is_empty());
567 let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
568 (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
569 .then(|| name.to_string())
570}
571
572pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
578 match kind {
579 ResourceKind::Guardrail => {
580 let system = body
581 .pointer("/properties/type")
582 .and_then(Value::as_str)
583 .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
584 .unwrap_or(false);
585 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
587 system || name.starts_with("Microsoft.")
588 }
589 _ => false,
590 }
591}
592
593pub fn auto_created_by(
599 snapshot: &[(ResourceRef, Value)],
600) -> std::collections::BTreeMap<String, String> {
601 let mut out = std::collections::BTreeMap::new();
602 for (r, doc) in snapshot {
603 if r.kind != ResourceKind::KnowledgeSource {
604 continue;
605 }
606 collect_created_resources(doc, &r.name, &mut out);
607 }
608 out
609}
610
611fn collect_created_resources(
612 v: &Value,
613 ks_name: &str,
614 out: &mut std::collections::BTreeMap<String, String>,
615) {
616 if let Value::Object(map) = v {
617 if let Some(Value::Object(created)) = map.get("createdResources") {
618 for (member, name) in created {
619 let kind = match member.as_str() {
620 "datasource" => Some(ResourceKind::DataSource),
621 "indexer" => Some(ResourceKind::Indexer),
622 "skillset" => Some(ResourceKind::Skillset),
623 "index" => Some(ResourceKind::Index),
624 _ => None, };
626 if let (Some(kind), Some(name)) = (kind, name.as_str()) {
627 out.insert(
628 ResourceRef::new(kind, name.to_string()).key(),
629 ks_name.to_string(),
630 );
631 }
632 }
633 }
634 for val in map.values() {
635 collect_created_resources(val, ks_name, out);
636 }
637 } else if let Value::Array(arr) = v {
638 for item in arr {
639 collect_created_resources(item, ks_name, out);
640 }
641 }
642}
643
644pub fn immutable_diff(
650 kind: ResourceKind,
651 local: &Value,
652 remote: &Value,
653) -> Vec<(&'static str, String, String)> {
654 fn values_at(doc: &Value, path: &str) -> Vec<Value> {
655 let mut vals = Vec::new();
656 collect_path(doc, path, &mut |v| vals.push(v.clone()));
657 vals
658 }
659 fn show(vals: &[Value]) -> String {
660 vals.iter()
661 .map(|v| {
662 v.as_str()
663 .map(str::to_string)
664 .unwrap_or_else(|| v.to_string())
665 })
666 .collect::<Vec<_>>()
667 .join(",")
668 }
669 let mut out = Vec::new();
670 for path in meta(kind).immutable_fields {
671 let l = values_at(local, path);
672 let r = values_at(remote, path);
673 if l != r {
674 out.push((*path, show(&r), show(&l)));
675 }
676 }
677 out
678}
679
680fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
681 match v {
682 Value::Object(map) => {
683 for (k, val) in map {
684 if k == X_RIGG_REF {
685 if let Some(s) = val.as_str() {
686 if let Some((dir, name)) = s.split_once('/') {
687 if let Some(kind) = ResourceKind::from_directory_name(dir) {
688 out.push((kind, name.to_string()));
689 }
690 }
691 }
692 } else {
693 collect_x_rigg_refs(val, out);
694 }
695 }
696 }
697 Value::Array(arr) => {
698 for item in arr {
699 collect_x_rigg_refs(item, out);
700 }
701 }
702 _ => {}
703 }
704}
705
706pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
709 fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
710 let Some((head, rest)) = segments.split_first() else {
711 f(v);
712 return;
713 };
714 if let Some(key) = head.strip_suffix("[]") {
715 let target = if key.is_empty() { Some(v) } else { v.get(key) };
716 if let Some(Value::Array(arr)) = target {
717 for item in arr {
718 walk(item, rest, f);
719 }
720 }
721 } else if let Some(next) = v.get(*head) {
722 walk(next, rest, f);
723 }
724 }
725 let segments: Vec<&str> = path.split('.').collect();
726 walk(v, &segments, f);
727}
728
729pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
752 let segments: Vec<&str> = path.split('.').collect();
753 restore_path_walk(dst, src, &segments);
754}
755
756fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
757 let Some((head, rest)) = segments.split_first() else {
758 *dst = src.clone();
759 return;
760 };
761 if let Some(key) = head.strip_suffix("[]") {
762 if key.is_empty() {
763 pair_arrays(dst, src, rest);
764 } else {
765 let Value::Object(src_map) = src else { return };
766 let Some(src_val) = src_map.get(key) else {
767 return;
768 };
769 let Value::Object(dst_map) = dst else { return };
770 let entry = dst_map
771 .entry(key.to_string())
772 .or_insert_with(|| Value::Array(Vec::new()));
773 pair_arrays(entry, src_val, rest);
774 }
775 } else {
776 let Value::Object(src_map) = src else { return };
777 let Some(src_val) = src_map.get(*head) else {
778 return;
779 };
780 let Value::Object(dst_map) = dst else { return };
781 if rest.is_empty() {
782 dst_map.insert((*head).to_string(), src_val.clone());
786 } else {
787 let entry = dst_map
788 .entry((*head).to_string())
789 .or_insert_with(|| Value::Object(serde_json::Map::new()));
790 restore_path_walk(entry, src_val, rest);
791 }
792 }
793}
794
795fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
802 let (Value::Array(d), Value::Array(s)) = (dst, src) else {
803 return;
804 };
805 let n = d.len().min(s.len());
806 for i in 0..n {
807 restore_path_walk(&mut d[i], &s[i], rest);
808 }
809 if s.len() > d.len() {
810 d.extend(s[n..].iter().cloned());
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use serde_json::json;
818
819 #[test]
820 fn meta_is_total_and_consistent() {
821 for kind in all_kinds() {
822 let m = meta(*kind);
823 assert_eq!(m.kind, *kind);
824 assert!(!m.collection_path.is_empty());
825 assert!(!m.dir_name.is_empty());
826 }
827 assert_eq!(all_kinds().len(), 12);
828 }
829
830 #[test]
831 fn dir_names_unique() {
832 let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
833 dirs.sort();
834 dirs.dedup();
835 assert_eq!(dirs.len(), 12);
836 }
837
838 #[test]
839 fn indexer_references() {
840 let indexer = json!({
841 "name": "idxr",
842 "dataSourceName": "my-ds",
843 "targetIndexName": "my-index",
844 "skillsetName": "my-skills"
845 });
846 let refs = extract_references(ResourceKind::Indexer, &indexer);
847 assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
848 assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
849 assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
850 }
851
852 #[test]
853 fn knowledge_base_and_alias_references() {
854 let kb = json!({
855 "name": "kb",
856 "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
857 });
858 let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
859 assert_eq!(
860 refs,
861 vec![
862 (ResourceKind::KnowledgeSource, "ks-a".to_string()),
863 (ResourceKind::KnowledgeSource, "ks-b".to_string()),
864 ]
865 );
866
867 let alias = json!({"name": "a", "indexes": ["i1"]});
868 let refs = extract_references(ResourceKind::Alias, &alias);
869 assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
870 }
871
872 #[test]
873 fn x_rigg_ref_extracted_at_depth() {
874 let agent = json!({
875 "name": "agent",
876 "model": "gpt-5-mini",
877 "tools": [
878 {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
879 ]
880 });
881 let refs = extract_references(ResourceKind::Agent, &agent);
882 assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
883 assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
884 }
885
886 #[test]
887 fn agent_extracts_portal_kb_url_and_connection_id() {
888 let agent = serde_json::json!({
889 "name": "Regulus",
890 "model": "gpt-5.2-chat",
891 "tools": [{
892 "type": "mcp",
893 "server_label": "kb_regulatory_kb",
894 "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
895 "project_connection_id": "kb-regulatory-kb-9kdyn"
896 }]
897 });
898 let refs = extract_references(ResourceKind::Agent, &agent);
899 assert!(
900 refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
901 "{refs:?}"
902 );
903 assert!(
904 refs.contains(&(
905 ResourceKind::Connection,
906 "kb-regulatory-kb-9kdyn".to_string()
907 )),
908 "{refs:?}"
909 );
910 assert!(
911 refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
912 "{refs:?}"
913 );
914 }
915
916 #[test]
917 fn agent_ignores_non_search_mcp_urls() {
918 let agent = serde_json::json!({
919 "name": "a",
920 "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
921 });
922 let refs = extract_references(ResourceKind::Agent, &agent);
923 assert!(
924 !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
925 "{refs:?}"
926 );
927 }
928
929 #[test]
930 fn deployment_runtime_state_is_volatile() {
931 let vf = meta(ResourceKind::Deployment).volatile_fields;
932 assert!(vf.contains(&"properties.currentCapacity"));
933 assert!(vf.contains(&"properties.deploymentState"));
934 }
935
936 #[test]
937 fn agent_portal_timestamp_is_volatile() {
938 assert!(
939 meta(ResourceKind::Agent)
940 .volatile_fields
941 .contains(&"metadata.modified_at")
942 );
943 }
944
945 #[test]
946 fn is_platform_managed_true_for_system_managed_guardrail() {
947 let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
948 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
949 }
950
951 #[test]
952 fn is_platform_managed_false_for_user_managed_guardrail() {
953 let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
954 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
955 }
956
957 #[test]
958 fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
959 let doc = json!({"name": "Microsoft.Default"});
960 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
961 }
962
963 #[test]
964 fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
965 let doc = json!({"name": "my-policy"});
966 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
967 }
968
969 #[test]
970 fn is_platform_managed_only_applies_to_guardrail_kind() {
971 let doc = json!({"name": "Microsoft.whatever"});
972 assert!(!is_platform_managed(ResourceKind::Index, &doc));
973 }
974
975 #[test]
976 fn auto_created_by_finds_nested_created_resources() {
977 let ks = serde_json::json!({
979 "name": "regulatory",
980 "kind": "azureBlob",
981 "azureBlobParameters": {
982 "containerName": "regulatory",
983 "createdResources": {
984 "datasource": "regulatory-datasource",
985 "indexer": "regulatory-indexer",
986 "skillset": "regulatory-skillset",
987 "index": "regulatory-index",
988 "somethingFuture": "ignored-name"
989 }
990 }
991 });
992 let index_doc = serde_json::json!({"name": "regulatory-index"});
993 let snapshot = vec![
994 (
995 ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
996 ks,
997 ),
998 (
999 ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
1000 index_doc,
1001 ),
1002 ];
1003 let map = auto_created_by(&snapshot);
1004 assert_eq!(
1005 map.get("indexes/regulatory-index").map(String::as_str),
1006 Some("regulatory")
1007 );
1008 assert_eq!(
1009 map.get("indexers/regulatory-indexer").map(String::as_str),
1010 Some("regulatory")
1011 );
1012 assert_eq!(
1013 map.get("data-sources/regulatory-datasource")
1014 .map(String::as_str),
1015 Some("regulatory")
1016 );
1017 assert_eq!(
1018 map.get("skillsets/regulatory-skillset").map(String::as_str),
1019 Some("regulatory")
1020 );
1021 assert!(
1022 !map.values().any(|v| v == "ignored-name"),
1023 "unknown member names ignored: {map:?}"
1024 );
1025 assert_eq!(map.len(), 4);
1026 }
1027
1028 #[test]
1029 fn auto_created_by_ignores_non_knowledge_source_docs() {
1030 let idx = serde_json::json!({
1031 "name": "i",
1032 "createdResources": {"index": "x"}
1033 });
1034 let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
1035 assert!(auto_created_by(&snapshot).is_empty());
1036 }
1037
1038 #[test]
1039 fn datasource_types_per_channel() {
1040 assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
1041 assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
1042 assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
1043 assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
1044 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
1046 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
1047 }
1048
1049 #[test]
1050 fn ks_points_at_index() {
1051 let ks = json!({
1052 "name": "ks",
1053 "kind": "searchIndex",
1054 "searchIndexParameters": {"searchIndexName": "docs"}
1055 });
1056 let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
1057 assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
1058 }
1059
1060 #[test]
1061 fn immutable_diff_detects_kind_change() {
1062 let local = json!({"name": "ks", "kind": "searchIndex",
1063 "searchIndexParameters": {"searchIndexName": "docs"}});
1064 let remote = json!({"name": "ks", "kind": "azureBlob",
1065 "azureBlobParameters": {"containerName": "c"}});
1066 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1067 assert_eq!(
1068 diff,
1069 vec![("kind", "azureBlob".to_string(), "searchIndex".to_string())]
1070 );
1071 }
1072
1073 #[test]
1074 fn immutable_diff_empty_when_kind_unchanged() {
1075 let local = json!({"name": "ks", "kind": "azureBlob", "description": "new"});
1076 let remote = json!({"name": "ks", "kind": "azureBlob"});
1077 assert!(immutable_diff(ResourceKind::KnowledgeSource, &local, &remote).is_empty());
1078 }
1079
1080 #[test]
1081 fn immutable_diff_empty_for_kinds_without_immutable_fields() {
1082 let local = json!({"name": "i", "kind": "a"});
1083 let remote = json!({"name": "i", "kind": "b"});
1084 assert!(immutable_diff(ResourceKind::Index, &local, &remote).is_empty());
1085 }
1086
1087 #[test]
1088 fn immutable_diff_counts_missing_side_as_difference() {
1089 let local = json!({"name": "ks", "kind": "searchIndex"});
1090 let remote = json!({"name": "ks"});
1091 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1092 assert_eq!(
1093 diff,
1094 vec![("kind", String::new(), "searchIndex".to_string())]
1095 );
1096 }
1097
1098 #[test]
1099 fn env_pinned_agent_covers_tool_server_fields() {
1100 let pinned = env_pinned(ResourceKind::Agent);
1101 assert!(pinned.contains(&"tools[].server_url"));
1102 assert!(pinned.contains(&"tools[].project_connection_id"));
1103 }
1104
1105 #[test]
1106 fn env_pinned_connection_covers_target_endpoint() {
1107 let pinned = env_pinned(ResourceKind::Connection);
1108 assert!(pinned.contains(&"properties.target"));
1109 assert_eq!(
1111 pinned.iter().filter(|f| **f == "properties.target").count(),
1112 1
1113 );
1114 }
1115
1116 #[test]
1117 fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
1118 let pinned = env_pinned(ResourceKind::DataSource);
1121 assert_eq!(
1122 pinned
1123 .iter()
1124 .filter(|f| **f == "credentials.connectionString")
1125 .count(),
1126 1
1127 );
1128 }
1129
1130 #[test]
1131 fn env_pinned_empty_for_kinds_with_no_defaults() {
1132 assert!(env_pinned(ResourceKind::Guardrail).is_empty());
1133 }
1134
1135 #[test]
1136 fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
1137 assert!(
1141 meta(ResourceKind::KnowledgeSource)
1142 .secret_fields
1143 .contains(&"azureBlobParameters.connectionString")
1144 );
1145 assert!(
1146 env_pinned(ResourceKind::KnowledgeSource)
1147 .contains(&"azureBlobParameters.connectionString"),
1148 "env_pinned includes it via the secret_fields union"
1149 );
1150 }
1151
1152 #[test]
1153 fn restore_path_plain_field() {
1154 let mut dst = json!({"name": "b-name", "model": "m1"});
1155 let src = json!({"name": "a-name", "model": "m2"});
1156 restore_path(&mut dst, &src, "name");
1157 assert_eq!(dst["name"], json!("a-name"));
1158 assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1159 }
1160
1161 #[test]
1162 fn restore_path_creates_missing_intermediate_objects() {
1163 let mut dst = json!({"name": "x"});
1164 let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1165 restore_path(&mut dst, &src, "credentials.connectionString");
1166 assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1167 }
1168
1169 #[test]
1170 fn restore_path_array_paired_by_index_not_identity() {
1171 let mut dst = json!({
1172 "tools": [
1173 {"type": "mcp", "server_url": "https://dst-a"},
1174 {"type": "mcp", "server_url": "https://dst-b"}
1175 ]
1176 });
1177 let src = json!({
1178 "tools": [
1179 {"type": "mcp", "server_url": "https://src-a"},
1180 {"type": "mcp", "server_url": "https://src-b"}
1181 ]
1182 });
1183 restore_path(&mut dst, &src, "tools[].server_url");
1184 assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1185 assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1186 assert_eq!(
1187 dst["tools"][0]["type"],
1188 json!("mcp"),
1189 "unrelated sibling kept"
1190 );
1191 }
1192
1193 #[test]
1194 fn restore_path_array_min_prefix_when_lengths_differ() {
1195 let mut dst = json!({
1198 "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1199 });
1200 let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1201 restore_path(&mut dst, &src, "tools[].server_url");
1202 assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1203 assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1204 assert_eq!(
1205 dst["tools"][2]["server_url"],
1206 json!("d3"),
1207 "no src counterpart — left untouched"
1208 );
1209 }
1210
1211 #[test]
1212 fn restore_path_appends_src_only_array_elements_wholesale() {
1213 let mut dst = json!({
1218 "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1219 });
1220 let src = json!({
1221 "tools": [
1222 {"type": "mcp", "server_url": "https://tgt-a"},
1223 {"type": "file_search", "vector_store_ids": ["vs1"]},
1224 {"type": "mcp", "server_url": "https://tgt-c"}
1225 ]
1226 });
1227 restore_path(&mut dst, &src, "tools[].server_url");
1228 let tools = dst["tools"].as_array().unwrap();
1229 assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1230 assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1231 assert_eq!(
1232 tools[1],
1233 json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1234 "extra element appended wholesale, not just the leaf field"
1235 );
1236 assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1237 }
1238
1239 #[test]
1240 fn restore_path_missing_in_src_leaves_dst_untouched() {
1241 let mut dst = json!({"name": "b", "model": "kept"});
1242 let src = json!({"name": "a"});
1243 restore_path(&mut dst, &src, "model");
1244 assert_eq!(dst["model"], json!("kept"));
1245 }
1246
1247 #[test]
1248 fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1249 let mut dst = json!({"tools": [{"server_url": "kept"}]});
1250 let src = json!({"name": "a"});
1251 restore_path(&mut dst, &src, "tools[].server_url");
1252 assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1253 }
1254}
1255
1256#[cfg(test)]
1257mod index_projection_ref_tests {
1258 use super::*;
1259 use serde_json::json;
1260
1261 #[test]
1262 fn skillset_index_projections_reference_the_index() {
1263 let ss = json!({
1264 "name": "ss",
1265 "skills": [],
1266 "indexProjections": {
1267 "selectors": [
1268 {"targetIndexName": "proj-index-a"},
1269 {"targetIndexName": "proj-index-b"}
1270 ]
1271 }
1272 });
1273 let refs = extract_references(ResourceKind::Skillset, &ss);
1274 assert!(refs.contains(&(ResourceKind::Index, "proj-index-a".into())));
1275 assert!(refs.contains(&(ResourceKind::Index, "proj-index-b".into())));
1276 }
1277
1278 #[test]
1279 fn rename_reference_rewrites_only_matching_values() {
1280 let mut ss = json!({
1281 "name": "ss",
1282 "indexProjections": {
1283 "selectors": [
1284 {"targetIndexName": "old-index"},
1285 {"targetIndexName": "other-index"}
1286 ]
1287 }
1288 });
1289 rename_reference(
1290 ResourceKind::Skillset,
1291 &mut ss,
1292 ResourceKind::Index,
1293 "old-index",
1294 "new-index",
1295 );
1296 assert_eq!(
1297 ss["indexProjections"]["selectors"][0]["targetIndexName"],
1298 "new-index"
1299 );
1300 assert_eq!(
1301 ss["indexProjections"]["selectors"][1]["targetIndexName"],
1302 "other-index"
1303 );
1304 }
1305
1306 #[test]
1307 fn rename_reference_rewrites_indexer_fields() {
1308 let mut idxr = json!({
1309 "name": "i",
1310 "dataSourceName": "old-ds",
1311 "targetIndexName": "old-index",
1312 "skillsetName": "old-ss"
1313 });
1314 rename_reference(
1315 ResourceKind::Indexer,
1316 &mut idxr,
1317 ResourceKind::DataSource,
1318 "old-ds",
1319 "new-ds",
1320 );
1321 assert_eq!(idxr["dataSourceName"], "new-ds");
1322 assert_eq!(
1323 idxr["targetIndexName"], "old-index",
1324 "other kinds untouched"
1325 );
1326 }
1327}