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::Preview,
244 volatile_fields: COMMON_VOLATILE,
245 read_only_fields: &[],
246 secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
247 write_only_fields: &[],
248 sidecar_fields: &[],
249 reference_fields: &[RefField {
250 path: "knowledgeSources[].name",
251 to: ResourceKind::KnowledgeSource,
252 }],
253 immutable_fields: &[],
254 },
255 KindMeta {
256 kind: ResourceKind::Agent,
257 domain: Domain::FoundryData,
258 collection_path: "agents",
259 dir_name: "agents",
260 channel: Channel::Stable,
261 volatile_fields: &[
262 "@odata.etag",
263 "@odata.context",
264 "id",
265 "object",
266 "created_at",
267 "updated_at",
268 "version",
269 "metadata.modified_at",
270 ],
271 read_only_fields: &[],
272 secret_fields: &[],
273 write_only_fields: &[],
274 sidecar_fields: &["instructions"],
275 reference_fields: &[
276 RefField {
277 path: "model",
278 to: ResourceKind::Deployment,
279 },
280 RefField {
281 path: "tools[].project_connection_id",
282 to: ResourceKind::Connection,
283 },
284 ],
285 immutable_fields: &[],
286 },
287 KindMeta {
288 kind: ResourceKind::Deployment,
289 domain: Domain::FoundryArm,
290 collection_path: "deployments",
291 dir_name: "deployments",
292 channel: Channel::Stable,
293 volatile_fields: &[
294 "id",
295 "type",
296 "systemData",
297 "etag",
298 "properties.provisioningState",
299 "properties.capabilities",
300 "properties.rateLimits",
301 "properties.model.callRateLimit",
302 "properties.currentCapacity",
303 "properties.deploymentState",
304 ],
305 read_only_fields: &[],
306 secret_fields: &[],
307 write_only_fields: &[],
308 sidecar_fields: &[],
309 reference_fields: &[RefField {
310 path: "properties.raiPolicyName",
311 to: ResourceKind::Guardrail,
312 }],
313 immutable_fields: &[],
314 },
315 KindMeta {
316 kind: ResourceKind::Connection,
317 domain: Domain::FoundryArm,
318 collection_path: "connections",
319 dir_name: "connections",
320 channel: Channel::Stable,
321 volatile_fields: &[
322 "id",
323 "type",
324 "systemData",
325 "etag",
326 "properties.provisioningState",
327 ],
328 read_only_fields: &[],
329 secret_fields: &[
331 "properties.credentials.key",
332 "properties.credentials.keys",
333 "properties.credentials.secret",
334 "properties.credentials.clientSecret",
335 "properties.credentials.pat",
336 "properties.credentials.sas",
337 ],
338 write_only_fields: &[],
339 sidecar_fields: &[],
340 reference_fields: &[],
341 immutable_fields: &[],
342 },
343 KindMeta {
344 kind: ResourceKind::Guardrail,
345 domain: Domain::FoundryArm,
346 collection_path: "raiPolicies",
347 dir_name: "guardrails",
348 channel: Channel::Stable,
349 volatile_fields: &["id", "type", "systemData", "etag"],
350 read_only_fields: &[],
351 secret_fields: &[],
352 write_only_fields: &[],
353 sidecar_fields: &[],
354 reference_fields: &[],
355 immutable_fields: &[],
356 },
357];
358
359pub fn all_kinds() -> &'static [ResourceKind] {
361 static ORDER: &[ResourceKind] = &[
362 ResourceKind::DataSource,
363 ResourceKind::Index,
364 ResourceKind::Skillset,
365 ResourceKind::Indexer,
366 ResourceKind::SynonymMap,
367 ResourceKind::Alias,
368 ResourceKind::KnowledgeSource,
369 ResourceKind::KnowledgeBase,
370 ResourceKind::Agent,
371 ResourceKind::Deployment,
372 ResourceKind::Connection,
373 ResourceKind::Guardrail,
374 ];
375 ORDER
376}
377
378pub fn meta(kind: ResourceKind) -> &'static KindMeta {
380 KINDS
381 .iter()
382 .find(|m| m.kind == kind)
383 .expect("registry entry exists for every ResourceKind")
384}
385
386pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
392 const GA: &[&str] = &[
393 "azureblob",
394 "adlsgen2",
395 "azuretable",
396 "azuresql",
397 "cosmosdb",
398 "onelake",
399 ];
400 const PREVIEW: &[&str] = &[
401 "azureblob",
402 "adlsgen2",
403 "azuretable",
404 "azuresql",
405 "cosmosdb",
406 "onelake",
407 "mysql",
408 "sharepoint",
409 "azurefile",
410 "azurefiles",
411 ];
412 match channel {
413 Channel::Stable => GA,
414 Channel::Preview => PREVIEW,
415 }
416}
417
418pub fn preview_only_datasource_types() -> &'static [&'static str] {
420 &["mysql", "sharepoint", "azurefile", "azurefiles"]
421}
422
423pub const X_RIGG_REF: &str = "x-rigg-ref";
426pub const X_RIGG_API: &str = "x-rigg-api";
428pub const X_RIGG_PIN: &str = "x-rigg-pin";
433
434fn env_pinned_extra(kind: ResourceKind) -> &'static [&'static str] {
439 match kind {
440 ResourceKind::Agent => &["tools[].server_url", "tools[].project_connection_id"],
441 ResourceKind::Connection => &["properties.target"],
442 ResourceKind::Skillset => &[
446 "skills[].uri",
447 "skills[].authResourceId",
448 "skills[].httpHeaders.x-functions-key",
449 "skills[].x-rigg-auth",
450 ],
451 _ => &[],
452 }
453}
454
455pub fn env_pinned(kind: ResourceKind) -> Vec<&'static str> {
460 let m = meta(kind);
461 let mut out: Vec<&'static str> = Vec::new();
462 for field in m
463 .secret_fields
464 .iter()
465 .chain(m.write_only_fields)
466 .chain(env_pinned_extra(kind))
467 {
468 if !out.contains(field) {
469 out.push(field);
470 }
471 }
472 out
473}
474
475fn collect_path_mut(v: &mut Value, path: &str, f: &mut dyn FnMut(&mut Value)) {
477 fn walk(v: &mut Value, segments: &[&str], f: &mut dyn FnMut(&mut Value)) {
478 let Some((head, rest)) = segments.split_first() else {
479 f(v);
480 return;
481 };
482 if let Some(key) = head.strip_suffix("[]") {
483 let target = if key.is_empty() {
484 Some(v)
485 } else {
486 v.get_mut(key)
487 };
488 if let Some(Value::Array(arr)) = target {
489 for item in arr {
490 walk(item, rest, f);
491 }
492 }
493 } else if let Some(next) = v.get_mut(*head) {
494 walk(next, rest, f);
495 }
496 }
497 let segments: Vec<&str> = path.split('.').collect();
498 walk(v, &segments, f);
499}
500
501pub fn rename_reference(
507 kind: ResourceKind,
508 body: &mut Value,
509 to: ResourceKind,
510 old: &str,
511 new: &str,
512) {
513 for rf in meta(kind).reference_fields {
514 if rf.to != to {
515 continue;
516 }
517 collect_path_mut(body, rf.path, &mut |v| {
518 if v.as_str() == Some(old) {
519 *v = Value::String(new.to_string());
520 }
521 });
522 }
523}
524
525pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
528 let mut out = Vec::new();
529 for rf in meta(kind).reference_fields {
530 collect_path(body, rf.path, &mut |v| {
531 if let Some(s) = v.as_str() {
532 if !s.is_empty() {
533 out.push((rf.to, s.to_string()));
534 }
535 }
536 });
537 }
538 collect_x_rigg_refs(body, &mut out);
539 if kind == ResourceKind::Agent {
540 collect_portal_agent_refs(body, &mut out);
541 }
542 out.sort();
543 out.dedup();
544 out
545}
546
547fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
552 match v {
553 Value::Object(map) => {
554 if let Some(url) = map.get("server_url").and_then(Value::as_str) {
555 if let Some(kb) = parse_kb_mcp_url(url) {
556 out.push((ResourceKind::KnowledgeBase, kb));
557 }
558 }
559 for val in map.values() {
560 collect_portal_agent_refs(val, out);
561 }
562 }
563 Value::Array(arr) => {
564 for item in arr {
565 collect_portal_agent_refs(item, out);
566 }
567 }
568 _ => {}
569 }
570}
571
572fn parse_kb_mcp_url(url: &str) -> Option<String> {
574 let rest = url.strip_prefix("https://")?;
575 let (host, path) = rest.split_once('/')?;
576 if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
577 return None;
578 }
579 let path = path.split('?').next().unwrap_or(path);
580 let mut segs = path.split('/').filter(|s| !s.is_empty());
581 let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
582 (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
583 .then(|| name.to_string())
584}
585
586pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
592 match kind {
593 ResourceKind::Guardrail => {
594 let system = body
595 .pointer("/properties/type")
596 .and_then(Value::as_str)
597 .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
598 .unwrap_or(false);
599 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
601 system || name.starts_with("Microsoft.")
602 }
603 _ => false,
604 }
605}
606
607pub fn auto_created_by(
613 snapshot: &[(ResourceRef, Value)],
614) -> std::collections::BTreeMap<String, String> {
615 let mut out = std::collections::BTreeMap::new();
616 for (r, doc) in snapshot {
617 if r.kind != ResourceKind::KnowledgeSource {
618 continue;
619 }
620 collect_created_resources(doc, &r.name, &mut out);
621 }
622 out
623}
624
625fn collect_created_resources(
626 v: &Value,
627 ks_name: &str,
628 out: &mut std::collections::BTreeMap<String, String>,
629) {
630 if let Value::Object(map) = v {
631 if let Some(Value::Object(created)) = map.get("createdResources") {
632 for (member, name) in created {
633 let kind = match member.as_str() {
634 "datasource" => Some(ResourceKind::DataSource),
635 "indexer" => Some(ResourceKind::Indexer),
636 "skillset" => Some(ResourceKind::Skillset),
637 "index" => Some(ResourceKind::Index),
638 _ => None, };
640 if let (Some(kind), Some(name)) = (kind, name.as_str()) {
641 out.insert(
642 ResourceRef::new(kind, name.to_string()).key(),
643 ks_name.to_string(),
644 );
645 }
646 }
647 }
648 for val in map.values() {
649 collect_created_resources(val, ks_name, out);
650 }
651 } else if let Value::Array(arr) = v {
652 for item in arr {
653 collect_created_resources(item, ks_name, out);
654 }
655 }
656}
657
658pub fn immutable_diff(
664 kind: ResourceKind,
665 local: &Value,
666 remote: &Value,
667) -> Vec<(&'static str, String, String)> {
668 fn values_at(doc: &Value, path: &str) -> Vec<Value> {
669 let mut vals = Vec::new();
670 collect_path(doc, path, &mut |v| vals.push(v.clone()));
671 vals
672 }
673 fn show(vals: &[Value]) -> String {
674 vals.iter()
675 .map(|v| {
676 v.as_str()
677 .map(str::to_string)
678 .unwrap_or_else(|| v.to_string())
679 })
680 .collect::<Vec<_>>()
681 .join(",")
682 }
683 let mut out = Vec::new();
684 for path in meta(kind).immutable_fields {
685 let l = values_at(local, path);
686 let r = values_at(remote, path);
687 if l != r {
688 out.push((*path, show(&r), show(&l)));
689 }
690 }
691 out
692}
693
694fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
695 match v {
696 Value::Object(map) => {
697 for (k, val) in map {
698 if k == X_RIGG_REF {
699 if let Some(s) = val.as_str() {
700 if let Some((dir, name)) = s.split_once('/') {
701 if let Some(kind) = ResourceKind::from_directory_name(dir) {
702 out.push((kind, name.to_string()));
703 }
704 }
705 }
706 } else {
707 collect_x_rigg_refs(val, out);
708 }
709 }
710 }
711 Value::Array(arr) => {
712 for item in arr {
713 collect_x_rigg_refs(item, out);
714 }
715 }
716 _ => {}
717 }
718}
719
720pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
723 fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
724 let Some((head, rest)) = segments.split_first() else {
725 f(v);
726 return;
727 };
728 if let Some(key) = head.strip_suffix("[]") {
729 let target = if key.is_empty() { Some(v) } else { v.get(key) };
730 if let Some(Value::Array(arr)) = target {
731 for item in arr {
732 walk(item, rest, f);
733 }
734 }
735 } else if let Some(next) = v.get(*head) {
736 walk(next, rest, f);
737 }
738 }
739 let segments: Vec<&str> = path.split('.').collect();
740 walk(v, &segments, f);
741}
742
743pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
766 let segments: Vec<&str> = path.split('.').collect();
767 restore_path_walk(dst, src, &segments);
768}
769
770fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
771 let Some((head, rest)) = segments.split_first() else {
772 *dst = src.clone();
773 return;
774 };
775 if let Some(key) = head.strip_suffix("[]") {
776 if key.is_empty() {
777 pair_arrays(dst, src, rest);
778 } else {
779 let Value::Object(src_map) = src else { return };
780 let Some(src_val) = src_map.get(key) else {
781 return;
782 };
783 let Value::Object(dst_map) = dst else { return };
784 let entry = dst_map
785 .entry(key.to_string())
786 .or_insert_with(|| Value::Array(Vec::new()));
787 pair_arrays(entry, src_val, rest);
788 }
789 } else {
790 let Value::Object(src_map) = src else { return };
791 let Some(src_val) = src_map.get(*head) else {
792 return;
793 };
794 let Value::Object(dst_map) = dst else { return };
795 if rest.is_empty() {
796 dst_map.insert((*head).to_string(), src_val.clone());
800 } else {
801 let entry = dst_map
802 .entry((*head).to_string())
803 .or_insert_with(|| Value::Object(serde_json::Map::new()));
804 restore_path_walk(entry, src_val, rest);
805 }
806 }
807}
808
809fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
816 let (Value::Array(d), Value::Array(s)) = (dst, src) else {
817 return;
818 };
819 let n = d.len().min(s.len());
820 for i in 0..n {
821 restore_path_walk(&mut d[i], &s[i], rest);
822 }
823 if s.len() > d.len() {
824 d.extend(s[n..].iter().cloned());
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831 use serde_json::json;
832
833 #[test]
834 fn meta_is_total_and_consistent() {
835 for kind in all_kinds() {
836 let m = meta(*kind);
837 assert_eq!(m.kind, *kind);
838 assert!(!m.collection_path.is_empty());
839 assert!(!m.dir_name.is_empty());
840 }
841 assert_eq!(all_kinds().len(), 12);
842 }
843
844 #[test]
845 fn dir_names_unique() {
846 let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
847 dirs.sort();
848 dirs.dedup();
849 assert_eq!(dirs.len(), 12);
850 }
851
852 #[test]
853 fn indexer_references() {
854 let indexer = json!({
855 "name": "idxr",
856 "dataSourceName": "my-ds",
857 "targetIndexName": "my-index",
858 "skillsetName": "my-skills"
859 });
860 let refs = extract_references(ResourceKind::Indexer, &indexer);
861 assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
862 assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
863 assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
864 }
865
866 #[test]
867 fn knowledge_base_and_alias_references() {
868 let kb = json!({
869 "name": "kb",
870 "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
871 });
872 let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
873 assert_eq!(
874 refs,
875 vec![
876 (ResourceKind::KnowledgeSource, "ks-a".to_string()),
877 (ResourceKind::KnowledgeSource, "ks-b".to_string()),
878 ]
879 );
880
881 let alias = json!({"name": "a", "indexes": ["i1"]});
882 let refs = extract_references(ResourceKind::Alias, &alias);
883 assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
884 }
885
886 #[test]
887 fn x_rigg_ref_extracted_at_depth() {
888 let agent = json!({
889 "name": "agent",
890 "model": "gpt-5-mini",
891 "tools": [
892 {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
893 ]
894 });
895 let refs = extract_references(ResourceKind::Agent, &agent);
896 assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
897 assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
898 }
899
900 #[test]
901 fn agent_extracts_portal_kb_url_and_connection_id() {
902 let agent = serde_json::json!({
903 "name": "Regulus",
904 "model": "gpt-5.2-chat",
905 "tools": [{
906 "type": "mcp",
907 "server_label": "kb_regulatory_kb",
908 "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
909 "project_connection_id": "kb-regulatory-kb-9kdyn"
910 }]
911 });
912 let refs = extract_references(ResourceKind::Agent, &agent);
913 assert!(
914 refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
915 "{refs:?}"
916 );
917 assert!(
918 refs.contains(&(
919 ResourceKind::Connection,
920 "kb-regulatory-kb-9kdyn".to_string()
921 )),
922 "{refs:?}"
923 );
924 assert!(
925 refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
926 "{refs:?}"
927 );
928 }
929
930 #[test]
931 fn agent_ignores_non_search_mcp_urls() {
932 let agent = serde_json::json!({
933 "name": "a",
934 "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
935 });
936 let refs = extract_references(ResourceKind::Agent, &agent);
937 assert!(
938 !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
939 "{refs:?}"
940 );
941 }
942
943 #[test]
944 fn deployment_runtime_state_is_volatile() {
945 let vf = meta(ResourceKind::Deployment).volatile_fields;
946 assert!(vf.contains(&"properties.currentCapacity"));
947 assert!(vf.contains(&"properties.deploymentState"));
948 }
949
950 #[test]
951 fn agent_portal_timestamp_is_volatile() {
952 assert!(
953 meta(ResourceKind::Agent)
954 .volatile_fields
955 .contains(&"metadata.modified_at")
956 );
957 }
958
959 #[test]
960 fn is_platform_managed_true_for_system_managed_guardrail() {
961 let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
962 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
963 }
964
965 #[test]
966 fn is_platform_managed_false_for_user_managed_guardrail() {
967 let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
968 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
969 }
970
971 #[test]
972 fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
973 let doc = json!({"name": "Microsoft.Default"});
974 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
975 }
976
977 #[test]
978 fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
979 let doc = json!({"name": "my-policy"});
980 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
981 }
982
983 #[test]
984 fn is_platform_managed_only_applies_to_guardrail_kind() {
985 let doc = json!({"name": "Microsoft.whatever"});
986 assert!(!is_platform_managed(ResourceKind::Index, &doc));
987 }
988
989 #[test]
990 fn auto_created_by_finds_nested_created_resources() {
991 let ks = serde_json::json!({
993 "name": "regulatory",
994 "kind": "azureBlob",
995 "azureBlobParameters": {
996 "containerName": "regulatory",
997 "createdResources": {
998 "datasource": "regulatory-datasource",
999 "indexer": "regulatory-indexer",
1000 "skillset": "regulatory-skillset",
1001 "index": "regulatory-index",
1002 "somethingFuture": "ignored-name"
1003 }
1004 }
1005 });
1006 let index_doc = serde_json::json!({"name": "regulatory-index"});
1007 let snapshot = vec![
1008 (
1009 ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
1010 ks,
1011 ),
1012 (
1013 ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
1014 index_doc,
1015 ),
1016 ];
1017 let map = auto_created_by(&snapshot);
1018 assert_eq!(
1019 map.get("indexes/regulatory-index").map(String::as_str),
1020 Some("regulatory")
1021 );
1022 assert_eq!(
1023 map.get("indexers/regulatory-indexer").map(String::as_str),
1024 Some("regulatory")
1025 );
1026 assert_eq!(
1027 map.get("data-sources/regulatory-datasource")
1028 .map(String::as_str),
1029 Some("regulatory")
1030 );
1031 assert_eq!(
1032 map.get("skillsets/regulatory-skillset").map(String::as_str),
1033 Some("regulatory")
1034 );
1035 assert!(
1036 !map.values().any(|v| v == "ignored-name"),
1037 "unknown member names ignored: {map:?}"
1038 );
1039 assert_eq!(map.len(), 4);
1040 }
1041
1042 #[test]
1043 fn auto_created_by_ignores_non_knowledge_source_docs() {
1044 let idx = serde_json::json!({
1045 "name": "i",
1046 "createdResources": {"index": "x"}
1047 });
1048 let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
1049 assert!(auto_created_by(&snapshot).is_empty());
1050 }
1051
1052 #[test]
1053 fn datasource_types_per_channel() {
1054 assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
1055 assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
1056 assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
1057 assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
1058 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
1060 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
1061 }
1062
1063 #[test]
1064 fn ks_points_at_index() {
1065 let ks = json!({
1066 "name": "ks",
1067 "kind": "searchIndex",
1068 "searchIndexParameters": {"searchIndexName": "docs"}
1069 });
1070 let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
1071 assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
1072 }
1073
1074 #[test]
1075 fn immutable_diff_detects_kind_change() {
1076 let local = json!({"name": "ks", "kind": "searchIndex",
1077 "searchIndexParameters": {"searchIndexName": "docs"}});
1078 let remote = json!({"name": "ks", "kind": "azureBlob",
1079 "azureBlobParameters": {"containerName": "c"}});
1080 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1081 assert_eq!(
1082 diff,
1083 vec![("kind", "azureBlob".to_string(), "searchIndex".to_string())]
1084 );
1085 }
1086
1087 #[test]
1088 fn immutable_diff_empty_when_kind_unchanged() {
1089 let local = json!({"name": "ks", "kind": "azureBlob", "description": "new"});
1090 let remote = json!({"name": "ks", "kind": "azureBlob"});
1091 assert!(immutable_diff(ResourceKind::KnowledgeSource, &local, &remote).is_empty());
1092 }
1093
1094 #[test]
1095 fn immutable_diff_empty_for_kinds_without_immutable_fields() {
1096 let local = json!({"name": "i", "kind": "a"});
1097 let remote = json!({"name": "i", "kind": "b"});
1098 assert!(immutable_diff(ResourceKind::Index, &local, &remote).is_empty());
1099 }
1100
1101 #[test]
1102 fn immutable_diff_counts_missing_side_as_difference() {
1103 let local = json!({"name": "ks", "kind": "searchIndex"});
1104 let remote = json!({"name": "ks"});
1105 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1106 assert_eq!(
1107 diff,
1108 vec![("kind", String::new(), "searchIndex".to_string())]
1109 );
1110 }
1111
1112 #[test]
1113 fn env_pinned_agent_covers_tool_server_fields() {
1114 let pinned = env_pinned(ResourceKind::Agent);
1115 assert!(pinned.contains(&"tools[].server_url"));
1116 assert!(pinned.contains(&"tools[].project_connection_id"));
1117 }
1118
1119 #[test]
1120 fn env_pinned_connection_covers_target_endpoint() {
1121 let pinned = env_pinned(ResourceKind::Connection);
1122 assert!(pinned.contains(&"properties.target"));
1123 assert_eq!(
1125 pinned.iter().filter(|f| **f == "properties.target").count(),
1126 1
1127 );
1128 }
1129
1130 #[test]
1131 fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
1132 let pinned = env_pinned(ResourceKind::DataSource);
1135 assert_eq!(
1136 pinned
1137 .iter()
1138 .filter(|f| **f == "credentials.connectionString")
1139 .count(),
1140 1
1141 );
1142 }
1143
1144 #[test]
1145 fn env_pinned_empty_for_kinds_with_no_defaults() {
1146 assert!(env_pinned(ResourceKind::Guardrail).is_empty());
1147 }
1148
1149 #[test]
1150 fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
1151 assert!(
1155 meta(ResourceKind::KnowledgeSource)
1156 .secret_fields
1157 .contains(&"azureBlobParameters.connectionString")
1158 );
1159 assert!(
1160 env_pinned(ResourceKind::KnowledgeSource)
1161 .contains(&"azureBlobParameters.connectionString"),
1162 "env_pinned includes it via the secret_fields union"
1163 );
1164 }
1165
1166 #[test]
1167 fn restore_path_plain_field() {
1168 let mut dst = json!({"name": "b-name", "model": "m1"});
1169 let src = json!({"name": "a-name", "model": "m2"});
1170 restore_path(&mut dst, &src, "name");
1171 assert_eq!(dst["name"], json!("a-name"));
1172 assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1173 }
1174
1175 #[test]
1176 fn restore_path_creates_missing_intermediate_objects() {
1177 let mut dst = json!({"name": "x"});
1178 let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1179 restore_path(&mut dst, &src, "credentials.connectionString");
1180 assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1181 }
1182
1183 #[test]
1184 fn restore_path_array_paired_by_index_not_identity() {
1185 let mut dst = json!({
1186 "tools": [
1187 {"type": "mcp", "server_url": "https://dst-a"},
1188 {"type": "mcp", "server_url": "https://dst-b"}
1189 ]
1190 });
1191 let src = json!({
1192 "tools": [
1193 {"type": "mcp", "server_url": "https://src-a"},
1194 {"type": "mcp", "server_url": "https://src-b"}
1195 ]
1196 });
1197 restore_path(&mut dst, &src, "tools[].server_url");
1198 assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1199 assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1200 assert_eq!(
1201 dst["tools"][0]["type"],
1202 json!("mcp"),
1203 "unrelated sibling kept"
1204 );
1205 }
1206
1207 #[test]
1208 fn restore_path_array_min_prefix_when_lengths_differ() {
1209 let mut dst = json!({
1212 "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1213 });
1214 let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1215 restore_path(&mut dst, &src, "tools[].server_url");
1216 assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1217 assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1218 assert_eq!(
1219 dst["tools"][2]["server_url"],
1220 json!("d3"),
1221 "no src counterpart — left untouched"
1222 );
1223 }
1224
1225 #[test]
1226 fn restore_path_appends_src_only_array_elements_wholesale() {
1227 let mut dst = json!({
1232 "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1233 });
1234 let src = json!({
1235 "tools": [
1236 {"type": "mcp", "server_url": "https://tgt-a"},
1237 {"type": "file_search", "vector_store_ids": ["vs1"]},
1238 {"type": "mcp", "server_url": "https://tgt-c"}
1239 ]
1240 });
1241 restore_path(&mut dst, &src, "tools[].server_url");
1242 let tools = dst["tools"].as_array().unwrap();
1243 assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1244 assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1245 assert_eq!(
1246 tools[1],
1247 json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1248 "extra element appended wholesale, not just the leaf field"
1249 );
1250 assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1251 }
1252
1253 #[test]
1254 fn restore_path_missing_in_src_leaves_dst_untouched() {
1255 let mut dst = json!({"name": "b", "model": "kept"});
1256 let src = json!({"name": "a"});
1257 restore_path(&mut dst, &src, "model");
1258 assert_eq!(dst["model"], json!("kept"));
1259 }
1260
1261 #[test]
1262 fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1263 let mut dst = json!({"tools": [{"server_url": "kept"}]});
1264 let src = json!({"name": "a"});
1265 restore_path(&mut dst, &src, "tools[].server_url");
1266 assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1267 }
1268}
1269
1270#[cfg(test)]
1271mod index_projection_ref_tests {
1272 use super::*;
1273 use serde_json::json;
1274
1275 #[test]
1276 fn skillset_index_projections_reference_the_index() {
1277 let ss = json!({
1278 "name": "ss",
1279 "skills": [],
1280 "indexProjections": {
1281 "selectors": [
1282 {"targetIndexName": "proj-index-a"},
1283 {"targetIndexName": "proj-index-b"}
1284 ]
1285 }
1286 });
1287 let refs = extract_references(ResourceKind::Skillset, &ss);
1288 assert!(refs.contains(&(ResourceKind::Index, "proj-index-a".into())));
1289 assert!(refs.contains(&(ResourceKind::Index, "proj-index-b".into())));
1290 }
1291
1292 #[test]
1293 fn rename_reference_rewrites_only_matching_values() {
1294 let mut ss = json!({
1295 "name": "ss",
1296 "indexProjections": {
1297 "selectors": [
1298 {"targetIndexName": "old-index"},
1299 {"targetIndexName": "other-index"}
1300 ]
1301 }
1302 });
1303 rename_reference(
1304 ResourceKind::Skillset,
1305 &mut ss,
1306 ResourceKind::Index,
1307 "old-index",
1308 "new-index",
1309 );
1310 assert_eq!(
1311 ss["indexProjections"]["selectors"][0]["targetIndexName"],
1312 "new-index"
1313 );
1314 assert_eq!(
1315 ss["indexProjections"]["selectors"][1]["targetIndexName"],
1316 "other-index"
1317 );
1318 }
1319
1320 #[test]
1321 fn rename_reference_rewrites_indexer_fields() {
1322 let mut idxr = json!({
1323 "name": "i",
1324 "dataSourceName": "old-ds",
1325 "targetIndexName": "old-index",
1326 "skillsetName": "old-ss"
1327 });
1328 rename_reference(
1329 ResourceKind::Indexer,
1330 &mut idxr,
1331 ResourceKind::DataSource,
1332 "old-ds",
1333 "new-ds",
1334 );
1335 assert_eq!(idxr["dataSourceName"], "new-ds");
1336 assert_eq!(
1337 idxr["targetIndexName"], "old-index",
1338 "other kinds untouched"
1339 );
1340 }
1341}
1342
1343#[cfg(test)]
1344mod skillset_env_pinned_tests {
1345 use super::*;
1346
1347 #[test]
1348 fn skillset_webapi_auth_carriers_are_env_pinned() {
1349 let pinned = env_pinned(ResourceKind::Skillset);
1350 for path in [
1351 "skills[].uri",
1352 "skills[].authResourceId",
1353 "skills[].httpHeaders.x-functions-key",
1354 "skills[].x-rigg-auth",
1355 ] {
1356 assert!(pinned.contains(&path), "missing env-pinned path: {path}");
1357 }
1358 }
1359}