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 ResourceKind::Skillset => &[
441 "skills[].uri",
442 "skills[].authResourceId",
443 "skills[].httpHeaders.x-functions-key",
444 "skills[].x-rigg-auth",
445 ],
446 _ => &[],
447 }
448}
449
450pub fn env_pinned(kind: ResourceKind) -> Vec<&'static str> {
455 let m = meta(kind);
456 let mut out: Vec<&'static str> = Vec::new();
457 for field in m
458 .secret_fields
459 .iter()
460 .chain(m.write_only_fields)
461 .chain(env_pinned_extra(kind))
462 {
463 if !out.contains(field) {
464 out.push(field);
465 }
466 }
467 out
468}
469
470fn collect_path_mut(v: &mut Value, path: &str, f: &mut dyn FnMut(&mut Value)) {
472 fn walk(v: &mut Value, segments: &[&str], f: &mut dyn FnMut(&mut Value)) {
473 let Some((head, rest)) = segments.split_first() else {
474 f(v);
475 return;
476 };
477 if let Some(key) = head.strip_suffix("[]") {
478 let target = if key.is_empty() {
479 Some(v)
480 } else {
481 v.get_mut(key)
482 };
483 if let Some(Value::Array(arr)) = target {
484 for item in arr {
485 walk(item, rest, f);
486 }
487 }
488 } else if let Some(next) = v.get_mut(*head) {
489 walk(next, rest, f);
490 }
491 }
492 let segments: Vec<&str> = path.split('.').collect();
493 walk(v, &segments, f);
494}
495
496pub fn rename_reference(
502 kind: ResourceKind,
503 body: &mut Value,
504 to: ResourceKind,
505 old: &str,
506 new: &str,
507) {
508 for rf in meta(kind).reference_fields {
509 if rf.to != to {
510 continue;
511 }
512 collect_path_mut(body, rf.path, &mut |v| {
513 if v.as_str() == Some(old) {
514 *v = Value::String(new.to_string());
515 }
516 });
517 }
518}
519
520pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
523 let mut out = Vec::new();
524 for rf in meta(kind).reference_fields {
525 collect_path(body, rf.path, &mut |v| {
526 if let Some(s) = v.as_str() {
527 if !s.is_empty() {
528 out.push((rf.to, s.to_string()));
529 }
530 }
531 });
532 }
533 collect_x_rigg_refs(body, &mut out);
534 if kind == ResourceKind::Agent {
535 collect_portal_agent_refs(body, &mut out);
536 }
537 out.sort();
538 out.dedup();
539 out
540}
541
542fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
547 match v {
548 Value::Object(map) => {
549 if let Some(url) = map.get("server_url").and_then(Value::as_str) {
550 if let Some(kb) = parse_kb_mcp_url(url) {
551 out.push((ResourceKind::KnowledgeBase, kb));
552 }
553 }
554 for val in map.values() {
555 collect_portal_agent_refs(val, out);
556 }
557 }
558 Value::Array(arr) => {
559 for item in arr {
560 collect_portal_agent_refs(item, out);
561 }
562 }
563 _ => {}
564 }
565}
566
567fn parse_kb_mcp_url(url: &str) -> Option<String> {
569 let rest = url.strip_prefix("https://")?;
570 let (host, path) = rest.split_once('/')?;
571 if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
572 return None;
573 }
574 let path = path.split('?').next().unwrap_or(path);
575 let mut segs = path.split('/').filter(|s| !s.is_empty());
576 let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
577 (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
578 .then(|| name.to_string())
579}
580
581pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
587 match kind {
588 ResourceKind::Guardrail => {
589 let system = body
590 .pointer("/properties/type")
591 .and_then(Value::as_str)
592 .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
593 .unwrap_or(false);
594 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
596 system || name.starts_with("Microsoft.")
597 }
598 _ => false,
599 }
600}
601
602pub fn auto_created_by(
608 snapshot: &[(ResourceRef, Value)],
609) -> std::collections::BTreeMap<String, String> {
610 let mut out = std::collections::BTreeMap::new();
611 for (r, doc) in snapshot {
612 if r.kind != ResourceKind::KnowledgeSource {
613 continue;
614 }
615 collect_created_resources(doc, &r.name, &mut out);
616 }
617 out
618}
619
620fn collect_created_resources(
621 v: &Value,
622 ks_name: &str,
623 out: &mut std::collections::BTreeMap<String, String>,
624) {
625 if let Value::Object(map) = v {
626 if let Some(Value::Object(created)) = map.get("createdResources") {
627 for (member, name) in created {
628 let kind = match member.as_str() {
629 "datasource" => Some(ResourceKind::DataSource),
630 "indexer" => Some(ResourceKind::Indexer),
631 "skillset" => Some(ResourceKind::Skillset),
632 "index" => Some(ResourceKind::Index),
633 _ => None, };
635 if let (Some(kind), Some(name)) = (kind, name.as_str()) {
636 out.insert(
637 ResourceRef::new(kind, name.to_string()).key(),
638 ks_name.to_string(),
639 );
640 }
641 }
642 }
643 for val in map.values() {
644 collect_created_resources(val, ks_name, out);
645 }
646 } else if let Value::Array(arr) = v {
647 for item in arr {
648 collect_created_resources(item, ks_name, out);
649 }
650 }
651}
652
653pub fn immutable_diff(
659 kind: ResourceKind,
660 local: &Value,
661 remote: &Value,
662) -> Vec<(&'static str, String, String)> {
663 fn values_at(doc: &Value, path: &str) -> Vec<Value> {
664 let mut vals = Vec::new();
665 collect_path(doc, path, &mut |v| vals.push(v.clone()));
666 vals
667 }
668 fn show(vals: &[Value]) -> String {
669 vals.iter()
670 .map(|v| {
671 v.as_str()
672 .map(str::to_string)
673 .unwrap_or_else(|| v.to_string())
674 })
675 .collect::<Vec<_>>()
676 .join(",")
677 }
678 let mut out = Vec::new();
679 for path in meta(kind).immutable_fields {
680 let l = values_at(local, path);
681 let r = values_at(remote, path);
682 if l != r {
683 out.push((*path, show(&r), show(&l)));
684 }
685 }
686 out
687}
688
689fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
690 match v {
691 Value::Object(map) => {
692 for (k, val) in map {
693 if k == X_RIGG_REF {
694 if let Some(s) = val.as_str() {
695 if let Some((dir, name)) = s.split_once('/') {
696 if let Some(kind) = ResourceKind::from_directory_name(dir) {
697 out.push((kind, name.to_string()));
698 }
699 }
700 }
701 } else {
702 collect_x_rigg_refs(val, out);
703 }
704 }
705 }
706 Value::Array(arr) => {
707 for item in arr {
708 collect_x_rigg_refs(item, out);
709 }
710 }
711 _ => {}
712 }
713}
714
715pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
718 fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
719 let Some((head, rest)) = segments.split_first() else {
720 f(v);
721 return;
722 };
723 if let Some(key) = head.strip_suffix("[]") {
724 let target = if key.is_empty() { Some(v) } else { v.get(key) };
725 if let Some(Value::Array(arr)) = target {
726 for item in arr {
727 walk(item, rest, f);
728 }
729 }
730 } else if let Some(next) = v.get(*head) {
731 walk(next, rest, f);
732 }
733 }
734 let segments: Vec<&str> = path.split('.').collect();
735 walk(v, &segments, f);
736}
737
738pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
761 let segments: Vec<&str> = path.split('.').collect();
762 restore_path_walk(dst, src, &segments);
763}
764
765fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
766 let Some((head, rest)) = segments.split_first() else {
767 *dst = src.clone();
768 return;
769 };
770 if let Some(key) = head.strip_suffix("[]") {
771 if key.is_empty() {
772 pair_arrays(dst, src, rest);
773 } else {
774 let Value::Object(src_map) = src else { return };
775 let Some(src_val) = src_map.get(key) else {
776 return;
777 };
778 let Value::Object(dst_map) = dst else { return };
779 let entry = dst_map
780 .entry(key.to_string())
781 .or_insert_with(|| Value::Array(Vec::new()));
782 pair_arrays(entry, src_val, rest);
783 }
784 } else {
785 let Value::Object(src_map) = src else { return };
786 let Some(src_val) = src_map.get(*head) else {
787 return;
788 };
789 let Value::Object(dst_map) = dst else { return };
790 if rest.is_empty() {
791 dst_map.insert((*head).to_string(), src_val.clone());
795 } else {
796 let entry = dst_map
797 .entry((*head).to_string())
798 .or_insert_with(|| Value::Object(serde_json::Map::new()));
799 restore_path_walk(entry, src_val, rest);
800 }
801 }
802}
803
804fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
811 let (Value::Array(d), Value::Array(s)) = (dst, src) else {
812 return;
813 };
814 let n = d.len().min(s.len());
815 for i in 0..n {
816 restore_path_walk(&mut d[i], &s[i], rest);
817 }
818 if s.len() > d.len() {
819 d.extend(s[n..].iter().cloned());
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use serde_json::json;
827
828 #[test]
829 fn meta_is_total_and_consistent() {
830 for kind in all_kinds() {
831 let m = meta(*kind);
832 assert_eq!(m.kind, *kind);
833 assert!(!m.collection_path.is_empty());
834 assert!(!m.dir_name.is_empty());
835 }
836 assert_eq!(all_kinds().len(), 12);
837 }
838
839 #[test]
840 fn dir_names_unique() {
841 let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
842 dirs.sort();
843 dirs.dedup();
844 assert_eq!(dirs.len(), 12);
845 }
846
847 #[test]
848 fn indexer_references() {
849 let indexer = json!({
850 "name": "idxr",
851 "dataSourceName": "my-ds",
852 "targetIndexName": "my-index",
853 "skillsetName": "my-skills"
854 });
855 let refs = extract_references(ResourceKind::Indexer, &indexer);
856 assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
857 assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
858 assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
859 }
860
861 #[test]
862 fn knowledge_base_and_alias_references() {
863 let kb = json!({
864 "name": "kb",
865 "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
866 });
867 let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
868 assert_eq!(
869 refs,
870 vec![
871 (ResourceKind::KnowledgeSource, "ks-a".to_string()),
872 (ResourceKind::KnowledgeSource, "ks-b".to_string()),
873 ]
874 );
875
876 let alias = json!({"name": "a", "indexes": ["i1"]});
877 let refs = extract_references(ResourceKind::Alias, &alias);
878 assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
879 }
880
881 #[test]
882 fn x_rigg_ref_extracted_at_depth() {
883 let agent = json!({
884 "name": "agent",
885 "model": "gpt-5-mini",
886 "tools": [
887 {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
888 ]
889 });
890 let refs = extract_references(ResourceKind::Agent, &agent);
891 assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
892 assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
893 }
894
895 #[test]
896 fn agent_extracts_portal_kb_url_and_connection_id() {
897 let agent = serde_json::json!({
898 "name": "Regulus",
899 "model": "gpt-5.2-chat",
900 "tools": [{
901 "type": "mcp",
902 "server_label": "kb_regulatory_kb",
903 "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
904 "project_connection_id": "kb-regulatory-kb-9kdyn"
905 }]
906 });
907 let refs = extract_references(ResourceKind::Agent, &agent);
908 assert!(
909 refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
910 "{refs:?}"
911 );
912 assert!(
913 refs.contains(&(
914 ResourceKind::Connection,
915 "kb-regulatory-kb-9kdyn".to_string()
916 )),
917 "{refs:?}"
918 );
919 assert!(
920 refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
921 "{refs:?}"
922 );
923 }
924
925 #[test]
926 fn agent_ignores_non_search_mcp_urls() {
927 let agent = serde_json::json!({
928 "name": "a",
929 "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
930 });
931 let refs = extract_references(ResourceKind::Agent, &agent);
932 assert!(
933 !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
934 "{refs:?}"
935 );
936 }
937
938 #[test]
939 fn deployment_runtime_state_is_volatile() {
940 let vf = meta(ResourceKind::Deployment).volatile_fields;
941 assert!(vf.contains(&"properties.currentCapacity"));
942 assert!(vf.contains(&"properties.deploymentState"));
943 }
944
945 #[test]
946 fn agent_portal_timestamp_is_volatile() {
947 assert!(
948 meta(ResourceKind::Agent)
949 .volatile_fields
950 .contains(&"metadata.modified_at")
951 );
952 }
953
954 #[test]
955 fn is_platform_managed_true_for_system_managed_guardrail() {
956 let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
957 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
958 }
959
960 #[test]
961 fn is_platform_managed_false_for_user_managed_guardrail() {
962 let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
963 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
964 }
965
966 #[test]
967 fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
968 let doc = json!({"name": "Microsoft.Default"});
969 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
970 }
971
972 #[test]
973 fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
974 let doc = json!({"name": "my-policy"});
975 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
976 }
977
978 #[test]
979 fn is_platform_managed_only_applies_to_guardrail_kind() {
980 let doc = json!({"name": "Microsoft.whatever"});
981 assert!(!is_platform_managed(ResourceKind::Index, &doc));
982 }
983
984 #[test]
985 fn auto_created_by_finds_nested_created_resources() {
986 let ks = serde_json::json!({
988 "name": "regulatory",
989 "kind": "azureBlob",
990 "azureBlobParameters": {
991 "containerName": "regulatory",
992 "createdResources": {
993 "datasource": "regulatory-datasource",
994 "indexer": "regulatory-indexer",
995 "skillset": "regulatory-skillset",
996 "index": "regulatory-index",
997 "somethingFuture": "ignored-name"
998 }
999 }
1000 });
1001 let index_doc = serde_json::json!({"name": "regulatory-index"});
1002 let snapshot = vec![
1003 (
1004 ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
1005 ks,
1006 ),
1007 (
1008 ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
1009 index_doc,
1010 ),
1011 ];
1012 let map = auto_created_by(&snapshot);
1013 assert_eq!(
1014 map.get("indexes/regulatory-index").map(String::as_str),
1015 Some("regulatory")
1016 );
1017 assert_eq!(
1018 map.get("indexers/regulatory-indexer").map(String::as_str),
1019 Some("regulatory")
1020 );
1021 assert_eq!(
1022 map.get("data-sources/regulatory-datasource")
1023 .map(String::as_str),
1024 Some("regulatory")
1025 );
1026 assert_eq!(
1027 map.get("skillsets/regulatory-skillset").map(String::as_str),
1028 Some("regulatory")
1029 );
1030 assert!(
1031 !map.values().any(|v| v == "ignored-name"),
1032 "unknown member names ignored: {map:?}"
1033 );
1034 assert_eq!(map.len(), 4);
1035 }
1036
1037 #[test]
1038 fn auto_created_by_ignores_non_knowledge_source_docs() {
1039 let idx = serde_json::json!({
1040 "name": "i",
1041 "createdResources": {"index": "x"}
1042 });
1043 let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
1044 assert!(auto_created_by(&snapshot).is_empty());
1045 }
1046
1047 #[test]
1048 fn datasource_types_per_channel() {
1049 assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
1050 assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
1051 assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
1052 assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
1053 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
1055 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
1056 }
1057
1058 #[test]
1059 fn ks_points_at_index() {
1060 let ks = json!({
1061 "name": "ks",
1062 "kind": "searchIndex",
1063 "searchIndexParameters": {"searchIndexName": "docs"}
1064 });
1065 let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
1066 assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
1067 }
1068
1069 #[test]
1070 fn immutable_diff_detects_kind_change() {
1071 let local = json!({"name": "ks", "kind": "searchIndex",
1072 "searchIndexParameters": {"searchIndexName": "docs"}});
1073 let remote = json!({"name": "ks", "kind": "azureBlob",
1074 "azureBlobParameters": {"containerName": "c"}});
1075 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1076 assert_eq!(
1077 diff,
1078 vec![("kind", "azureBlob".to_string(), "searchIndex".to_string())]
1079 );
1080 }
1081
1082 #[test]
1083 fn immutable_diff_empty_when_kind_unchanged() {
1084 let local = json!({"name": "ks", "kind": "azureBlob", "description": "new"});
1085 let remote = json!({"name": "ks", "kind": "azureBlob"});
1086 assert!(immutable_diff(ResourceKind::KnowledgeSource, &local, &remote).is_empty());
1087 }
1088
1089 #[test]
1090 fn immutable_diff_empty_for_kinds_without_immutable_fields() {
1091 let local = json!({"name": "i", "kind": "a"});
1092 let remote = json!({"name": "i", "kind": "b"});
1093 assert!(immutable_diff(ResourceKind::Index, &local, &remote).is_empty());
1094 }
1095
1096 #[test]
1097 fn immutable_diff_counts_missing_side_as_difference() {
1098 let local = json!({"name": "ks", "kind": "searchIndex"});
1099 let remote = json!({"name": "ks"});
1100 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1101 assert_eq!(
1102 diff,
1103 vec![("kind", String::new(), "searchIndex".to_string())]
1104 );
1105 }
1106
1107 #[test]
1108 fn env_pinned_agent_covers_tool_server_fields() {
1109 let pinned = env_pinned(ResourceKind::Agent);
1110 assert!(pinned.contains(&"tools[].server_url"));
1111 assert!(pinned.contains(&"tools[].project_connection_id"));
1112 }
1113
1114 #[test]
1115 fn env_pinned_connection_covers_target_endpoint() {
1116 let pinned = env_pinned(ResourceKind::Connection);
1117 assert!(pinned.contains(&"properties.target"));
1118 assert_eq!(
1120 pinned.iter().filter(|f| **f == "properties.target").count(),
1121 1
1122 );
1123 }
1124
1125 #[test]
1126 fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
1127 let pinned = env_pinned(ResourceKind::DataSource);
1130 assert_eq!(
1131 pinned
1132 .iter()
1133 .filter(|f| **f == "credentials.connectionString")
1134 .count(),
1135 1
1136 );
1137 }
1138
1139 #[test]
1140 fn env_pinned_empty_for_kinds_with_no_defaults() {
1141 assert!(env_pinned(ResourceKind::Guardrail).is_empty());
1142 }
1143
1144 #[test]
1145 fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
1146 assert!(
1150 meta(ResourceKind::KnowledgeSource)
1151 .secret_fields
1152 .contains(&"azureBlobParameters.connectionString")
1153 );
1154 assert!(
1155 env_pinned(ResourceKind::KnowledgeSource)
1156 .contains(&"azureBlobParameters.connectionString"),
1157 "env_pinned includes it via the secret_fields union"
1158 );
1159 }
1160
1161 #[test]
1162 fn restore_path_plain_field() {
1163 let mut dst = json!({"name": "b-name", "model": "m1"});
1164 let src = json!({"name": "a-name", "model": "m2"});
1165 restore_path(&mut dst, &src, "name");
1166 assert_eq!(dst["name"], json!("a-name"));
1167 assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1168 }
1169
1170 #[test]
1171 fn restore_path_creates_missing_intermediate_objects() {
1172 let mut dst = json!({"name": "x"});
1173 let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1174 restore_path(&mut dst, &src, "credentials.connectionString");
1175 assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1176 }
1177
1178 #[test]
1179 fn restore_path_array_paired_by_index_not_identity() {
1180 let mut dst = json!({
1181 "tools": [
1182 {"type": "mcp", "server_url": "https://dst-a"},
1183 {"type": "mcp", "server_url": "https://dst-b"}
1184 ]
1185 });
1186 let src = json!({
1187 "tools": [
1188 {"type": "mcp", "server_url": "https://src-a"},
1189 {"type": "mcp", "server_url": "https://src-b"}
1190 ]
1191 });
1192 restore_path(&mut dst, &src, "tools[].server_url");
1193 assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1194 assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1195 assert_eq!(
1196 dst["tools"][0]["type"],
1197 json!("mcp"),
1198 "unrelated sibling kept"
1199 );
1200 }
1201
1202 #[test]
1203 fn restore_path_array_min_prefix_when_lengths_differ() {
1204 let mut dst = json!({
1207 "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1208 });
1209 let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1210 restore_path(&mut dst, &src, "tools[].server_url");
1211 assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1212 assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1213 assert_eq!(
1214 dst["tools"][2]["server_url"],
1215 json!("d3"),
1216 "no src counterpart — left untouched"
1217 );
1218 }
1219
1220 #[test]
1221 fn restore_path_appends_src_only_array_elements_wholesale() {
1222 let mut dst = json!({
1227 "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1228 });
1229 let src = json!({
1230 "tools": [
1231 {"type": "mcp", "server_url": "https://tgt-a"},
1232 {"type": "file_search", "vector_store_ids": ["vs1"]},
1233 {"type": "mcp", "server_url": "https://tgt-c"}
1234 ]
1235 });
1236 restore_path(&mut dst, &src, "tools[].server_url");
1237 let tools = dst["tools"].as_array().unwrap();
1238 assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1239 assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1240 assert_eq!(
1241 tools[1],
1242 json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1243 "extra element appended wholesale, not just the leaf field"
1244 );
1245 assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1246 }
1247
1248 #[test]
1249 fn restore_path_missing_in_src_leaves_dst_untouched() {
1250 let mut dst = json!({"name": "b", "model": "kept"});
1251 let src = json!({"name": "a"});
1252 restore_path(&mut dst, &src, "model");
1253 assert_eq!(dst["model"], json!("kept"));
1254 }
1255
1256 #[test]
1257 fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1258 let mut dst = json!({"tools": [{"server_url": "kept"}]});
1259 let src = json!({"name": "a"});
1260 restore_path(&mut dst, &src, "tools[].server_url");
1261 assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1262 }
1263}
1264
1265#[cfg(test)]
1266mod index_projection_ref_tests {
1267 use super::*;
1268 use serde_json::json;
1269
1270 #[test]
1271 fn skillset_index_projections_reference_the_index() {
1272 let ss = json!({
1273 "name": "ss",
1274 "skills": [],
1275 "indexProjections": {
1276 "selectors": [
1277 {"targetIndexName": "proj-index-a"},
1278 {"targetIndexName": "proj-index-b"}
1279 ]
1280 }
1281 });
1282 let refs = extract_references(ResourceKind::Skillset, &ss);
1283 assert!(refs.contains(&(ResourceKind::Index, "proj-index-a".into())));
1284 assert!(refs.contains(&(ResourceKind::Index, "proj-index-b".into())));
1285 }
1286
1287 #[test]
1288 fn rename_reference_rewrites_only_matching_values() {
1289 let mut ss = json!({
1290 "name": "ss",
1291 "indexProjections": {
1292 "selectors": [
1293 {"targetIndexName": "old-index"},
1294 {"targetIndexName": "other-index"}
1295 ]
1296 }
1297 });
1298 rename_reference(
1299 ResourceKind::Skillset,
1300 &mut ss,
1301 ResourceKind::Index,
1302 "old-index",
1303 "new-index",
1304 );
1305 assert_eq!(
1306 ss["indexProjections"]["selectors"][0]["targetIndexName"],
1307 "new-index"
1308 );
1309 assert_eq!(
1310 ss["indexProjections"]["selectors"][1]["targetIndexName"],
1311 "other-index"
1312 );
1313 }
1314
1315 #[test]
1316 fn rename_reference_rewrites_indexer_fields() {
1317 let mut idxr = json!({
1318 "name": "i",
1319 "dataSourceName": "old-ds",
1320 "targetIndexName": "old-index",
1321 "skillsetName": "old-ss"
1322 });
1323 rename_reference(
1324 ResourceKind::Indexer,
1325 &mut idxr,
1326 ResourceKind::DataSource,
1327 "old-ds",
1328 "new-ds",
1329 );
1330 assert_eq!(idxr["dataSourceName"], "new-ds");
1331 assert_eq!(
1332 idxr["targetIndexName"], "old-index",
1333 "other kinds untouched"
1334 );
1335 }
1336}
1337
1338#[cfg(test)]
1339mod skillset_env_pinned_tests {
1340 use super::*;
1341
1342 #[test]
1343 fn skillset_webapi_auth_carriers_are_env_pinned() {
1344 let pinned = env_pinned(ResourceKind::Skillset);
1345 for path in [
1346 "skills[].uri",
1347 "skills[].authResourceId",
1348 "skills[].httpHeaders.x-functions-key",
1349 "skills[].x-rigg-auth",
1350 ] {
1351 assert!(pinned.contains(&path), "missing env-pinned path: {path}");
1352 }
1353 }
1354}