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 && !s.is_empty()
533 {
534 out.push((rf.to, s.to_string()));
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 && let Some(kb) = parse_kb_mcp_url(url)
556 {
557 out.push((ResourceKind::KnowledgeBase, kb));
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 && let Some((dir, name)) = s.split_once('/')
701 && let Some(kind) = ResourceKind::from_directory_name(dir)
702 {
703 out.push((kind, name.to_string()));
704 }
705 } else {
706 collect_x_rigg_refs(val, out);
707 }
708 }
709 }
710 Value::Array(arr) => {
711 for item in arr {
712 collect_x_rigg_refs(item, out);
713 }
714 }
715 _ => {}
716 }
717}
718
719pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
722 fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
723 let Some((head, rest)) = segments.split_first() else {
724 f(v);
725 return;
726 };
727 if let Some(key) = head.strip_suffix("[]") {
728 let target = if key.is_empty() { Some(v) } else { v.get(key) };
729 if let Some(Value::Array(arr)) = target {
730 for item in arr {
731 walk(item, rest, f);
732 }
733 }
734 } else if let Some(next) = v.get(*head) {
735 walk(next, rest, f);
736 }
737 }
738 let segments: Vec<&str> = path.split('.').collect();
739 walk(v, &segments, f);
740}
741
742pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
765 let segments: Vec<&str> = path.split('.').collect();
766 restore_path_walk(dst, src, &segments);
767}
768
769fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
770 let Some((head, rest)) = segments.split_first() else {
771 *dst = src.clone();
772 return;
773 };
774 if let Some(key) = head.strip_suffix("[]") {
775 if key.is_empty() {
776 pair_arrays(dst, src, rest);
777 } else {
778 let Value::Object(src_map) = src else { return };
779 let Some(src_val) = src_map.get(key) else {
780 return;
781 };
782 let Value::Object(dst_map) = dst else { return };
783 let entry = dst_map
784 .entry(key.to_string())
785 .or_insert_with(|| Value::Array(Vec::new()));
786 pair_arrays(entry, src_val, rest);
787 }
788 } else {
789 let Value::Object(src_map) = src else { return };
790 let Some(src_val) = src_map.get(*head) else {
791 return;
792 };
793 let Value::Object(dst_map) = dst else { return };
794 if rest.is_empty() {
795 dst_map.insert((*head).to_string(), src_val.clone());
799 } else {
800 let entry = dst_map
801 .entry((*head).to_string())
802 .or_insert_with(|| Value::Object(serde_json::Map::new()));
803 restore_path_walk(entry, src_val, rest);
804 }
805 }
806}
807
808fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
815 let (Value::Array(d), Value::Array(s)) = (dst, src) else {
816 return;
817 };
818 let n = d.len().min(s.len());
819 for i in 0..n {
820 restore_path_walk(&mut d[i], &s[i], rest);
821 }
822 if s.len() > d.len() {
823 d.extend(s[n..].iter().cloned());
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use serde_json::json;
831
832 #[test]
833 fn meta_is_total_and_consistent() {
834 for kind in all_kinds() {
835 let m = meta(*kind);
836 assert_eq!(m.kind, *kind);
837 assert!(!m.collection_path.is_empty());
838 assert!(!m.dir_name.is_empty());
839 }
840 assert_eq!(all_kinds().len(), 12);
841 }
842
843 #[test]
844 fn dir_names_unique() {
845 let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
846 dirs.sort();
847 dirs.dedup();
848 assert_eq!(dirs.len(), 12);
849 }
850
851 #[test]
852 fn indexer_references() {
853 let indexer = json!({
854 "name": "idxr",
855 "dataSourceName": "my-ds",
856 "targetIndexName": "my-index",
857 "skillsetName": "my-skills"
858 });
859 let refs = extract_references(ResourceKind::Indexer, &indexer);
860 assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
861 assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
862 assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
863 }
864
865 #[test]
866 fn knowledge_base_and_alias_references() {
867 let kb = json!({
868 "name": "kb",
869 "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
870 });
871 let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
872 assert_eq!(
873 refs,
874 vec![
875 (ResourceKind::KnowledgeSource, "ks-a".to_string()),
876 (ResourceKind::KnowledgeSource, "ks-b".to_string()),
877 ]
878 );
879
880 let alias = json!({"name": "a", "indexes": ["i1"]});
881 let refs = extract_references(ResourceKind::Alias, &alias);
882 assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
883 }
884
885 #[test]
886 fn x_rigg_ref_extracted_at_depth() {
887 let agent = json!({
888 "name": "agent",
889 "model": "gpt-5-mini",
890 "tools": [
891 {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
892 ]
893 });
894 let refs = extract_references(ResourceKind::Agent, &agent);
895 assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
896 assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
897 }
898
899 #[test]
900 fn agent_extracts_portal_kb_url_and_connection_id() {
901 let agent = serde_json::json!({
902 "name": "Regulus",
903 "model": "gpt-5.2-chat",
904 "tools": [{
905 "type": "mcp",
906 "server_label": "kb_regulatory_kb",
907 "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
908 "project_connection_id": "kb-regulatory-kb-9kdyn"
909 }]
910 });
911 let refs = extract_references(ResourceKind::Agent, &agent);
912 assert!(
913 refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
914 "{refs:?}"
915 );
916 assert!(
917 refs.contains(&(
918 ResourceKind::Connection,
919 "kb-regulatory-kb-9kdyn".to_string()
920 )),
921 "{refs:?}"
922 );
923 assert!(
924 refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
925 "{refs:?}"
926 );
927 }
928
929 #[test]
930 fn agent_ignores_non_search_mcp_urls() {
931 let agent = serde_json::json!({
932 "name": "a",
933 "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
934 });
935 let refs = extract_references(ResourceKind::Agent, &agent);
936 assert!(
937 !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
938 "{refs:?}"
939 );
940 }
941
942 #[test]
943 fn deployment_runtime_state_is_volatile() {
944 let vf = meta(ResourceKind::Deployment).volatile_fields;
945 assert!(vf.contains(&"properties.currentCapacity"));
946 assert!(vf.contains(&"properties.deploymentState"));
947 }
948
949 #[test]
950 fn agent_portal_timestamp_is_volatile() {
951 assert!(
952 meta(ResourceKind::Agent)
953 .volatile_fields
954 .contains(&"metadata.modified_at")
955 );
956 }
957
958 #[test]
959 fn is_platform_managed_true_for_system_managed_guardrail() {
960 let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
961 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
962 }
963
964 #[test]
965 fn is_platform_managed_false_for_user_managed_guardrail() {
966 let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
967 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
968 }
969
970 #[test]
971 fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
972 let doc = json!({"name": "Microsoft.Default"});
973 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
974 }
975
976 #[test]
977 fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
978 let doc = json!({"name": "my-policy"});
979 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
980 }
981
982 #[test]
983 fn is_platform_managed_only_applies_to_guardrail_kind() {
984 let doc = json!({"name": "Microsoft.whatever"});
985 assert!(!is_platform_managed(ResourceKind::Index, &doc));
986 }
987
988 #[test]
989 fn auto_created_by_finds_nested_created_resources() {
990 let ks = serde_json::json!({
992 "name": "regulatory",
993 "kind": "azureBlob",
994 "azureBlobParameters": {
995 "containerName": "regulatory",
996 "createdResources": {
997 "datasource": "regulatory-datasource",
998 "indexer": "regulatory-indexer",
999 "skillset": "regulatory-skillset",
1000 "index": "regulatory-index",
1001 "somethingFuture": "ignored-name"
1002 }
1003 }
1004 });
1005 let index_doc = serde_json::json!({"name": "regulatory-index"});
1006 let snapshot = vec![
1007 (
1008 ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
1009 ks,
1010 ),
1011 (
1012 ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
1013 index_doc,
1014 ),
1015 ];
1016 let map = auto_created_by(&snapshot);
1017 assert_eq!(
1018 map.get("indexes/regulatory-index").map(String::as_str),
1019 Some("regulatory")
1020 );
1021 assert_eq!(
1022 map.get("indexers/regulatory-indexer").map(String::as_str),
1023 Some("regulatory")
1024 );
1025 assert_eq!(
1026 map.get("data-sources/regulatory-datasource")
1027 .map(String::as_str),
1028 Some("regulatory")
1029 );
1030 assert_eq!(
1031 map.get("skillsets/regulatory-skillset").map(String::as_str),
1032 Some("regulatory")
1033 );
1034 assert!(
1035 !map.values().any(|v| v == "ignored-name"),
1036 "unknown member names ignored: {map:?}"
1037 );
1038 assert_eq!(map.len(), 4);
1039 }
1040
1041 #[test]
1042 fn auto_created_by_ignores_non_knowledge_source_docs() {
1043 let idx = serde_json::json!({
1044 "name": "i",
1045 "createdResources": {"index": "x"}
1046 });
1047 let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
1048 assert!(auto_created_by(&snapshot).is_empty());
1049 }
1050
1051 #[test]
1052 fn datasource_types_per_channel() {
1053 assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
1054 assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
1055 assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
1056 assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
1057 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
1059 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
1060 }
1061
1062 #[test]
1063 fn ks_points_at_index() {
1064 let ks = json!({
1065 "name": "ks",
1066 "kind": "searchIndex",
1067 "searchIndexParameters": {"searchIndexName": "docs"}
1068 });
1069 let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
1070 assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
1071 }
1072
1073 #[test]
1074 fn immutable_diff_detects_kind_change() {
1075 let local = json!({"name": "ks", "kind": "searchIndex",
1076 "searchIndexParameters": {"searchIndexName": "docs"}});
1077 let remote = json!({"name": "ks", "kind": "azureBlob",
1078 "azureBlobParameters": {"containerName": "c"}});
1079 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1080 assert_eq!(
1081 diff,
1082 vec![("kind", "azureBlob".to_string(), "searchIndex".to_string())]
1083 );
1084 }
1085
1086 #[test]
1087 fn immutable_diff_empty_when_kind_unchanged() {
1088 let local = json!({"name": "ks", "kind": "azureBlob", "description": "new"});
1089 let remote = json!({"name": "ks", "kind": "azureBlob"});
1090 assert!(immutable_diff(ResourceKind::KnowledgeSource, &local, &remote).is_empty());
1091 }
1092
1093 #[test]
1094 fn immutable_diff_empty_for_kinds_without_immutable_fields() {
1095 let local = json!({"name": "i", "kind": "a"});
1096 let remote = json!({"name": "i", "kind": "b"});
1097 assert!(immutable_diff(ResourceKind::Index, &local, &remote).is_empty());
1098 }
1099
1100 #[test]
1101 fn immutable_diff_counts_missing_side_as_difference() {
1102 let local = json!({"name": "ks", "kind": "searchIndex"});
1103 let remote = json!({"name": "ks"});
1104 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1105 assert_eq!(
1106 diff,
1107 vec![("kind", String::new(), "searchIndex".to_string())]
1108 );
1109 }
1110
1111 #[test]
1112 fn env_pinned_agent_covers_tool_server_fields() {
1113 let pinned = env_pinned(ResourceKind::Agent);
1114 assert!(pinned.contains(&"tools[].server_url"));
1115 assert!(pinned.contains(&"tools[].project_connection_id"));
1116 }
1117
1118 #[test]
1119 fn env_pinned_connection_covers_target_endpoint() {
1120 let pinned = env_pinned(ResourceKind::Connection);
1121 assert!(pinned.contains(&"properties.target"));
1122 assert_eq!(
1124 pinned.iter().filter(|f| **f == "properties.target").count(),
1125 1
1126 );
1127 }
1128
1129 #[test]
1130 fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
1131 let pinned = env_pinned(ResourceKind::DataSource);
1134 assert_eq!(
1135 pinned
1136 .iter()
1137 .filter(|f| **f == "credentials.connectionString")
1138 .count(),
1139 1
1140 );
1141 }
1142
1143 #[test]
1144 fn env_pinned_empty_for_kinds_with_no_defaults() {
1145 assert!(env_pinned(ResourceKind::Guardrail).is_empty());
1146 }
1147
1148 #[test]
1149 fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
1150 assert!(
1154 meta(ResourceKind::KnowledgeSource)
1155 .secret_fields
1156 .contains(&"azureBlobParameters.connectionString")
1157 );
1158 assert!(
1159 env_pinned(ResourceKind::KnowledgeSource)
1160 .contains(&"azureBlobParameters.connectionString"),
1161 "env_pinned includes it via the secret_fields union"
1162 );
1163 }
1164
1165 #[test]
1166 fn restore_path_plain_field() {
1167 let mut dst = json!({"name": "b-name", "model": "m1"});
1168 let src = json!({"name": "a-name", "model": "m2"});
1169 restore_path(&mut dst, &src, "name");
1170 assert_eq!(dst["name"], json!("a-name"));
1171 assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1172 }
1173
1174 #[test]
1175 fn restore_path_creates_missing_intermediate_objects() {
1176 let mut dst = json!({"name": "x"});
1177 let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1178 restore_path(&mut dst, &src, "credentials.connectionString");
1179 assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1180 }
1181
1182 #[test]
1183 fn restore_path_array_paired_by_index_not_identity() {
1184 let mut dst = json!({
1185 "tools": [
1186 {"type": "mcp", "server_url": "https://dst-a"},
1187 {"type": "mcp", "server_url": "https://dst-b"}
1188 ]
1189 });
1190 let src = json!({
1191 "tools": [
1192 {"type": "mcp", "server_url": "https://src-a"},
1193 {"type": "mcp", "server_url": "https://src-b"}
1194 ]
1195 });
1196 restore_path(&mut dst, &src, "tools[].server_url");
1197 assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1198 assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1199 assert_eq!(
1200 dst["tools"][0]["type"],
1201 json!("mcp"),
1202 "unrelated sibling kept"
1203 );
1204 }
1205
1206 #[test]
1207 fn restore_path_array_min_prefix_when_lengths_differ() {
1208 let mut dst = json!({
1211 "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1212 });
1213 let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1214 restore_path(&mut dst, &src, "tools[].server_url");
1215 assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1216 assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1217 assert_eq!(
1218 dst["tools"][2]["server_url"],
1219 json!("d3"),
1220 "no src counterpart — left untouched"
1221 );
1222 }
1223
1224 #[test]
1225 fn restore_path_appends_src_only_array_elements_wholesale() {
1226 let mut dst = json!({
1231 "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1232 });
1233 let src = json!({
1234 "tools": [
1235 {"type": "mcp", "server_url": "https://tgt-a"},
1236 {"type": "file_search", "vector_store_ids": ["vs1"]},
1237 {"type": "mcp", "server_url": "https://tgt-c"}
1238 ]
1239 });
1240 restore_path(&mut dst, &src, "tools[].server_url");
1241 let tools = dst["tools"].as_array().unwrap();
1242 assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1243 assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1244 assert_eq!(
1245 tools[1],
1246 json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1247 "extra element appended wholesale, not just the leaf field"
1248 );
1249 assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1250 }
1251
1252 #[test]
1253 fn restore_path_missing_in_src_leaves_dst_untouched() {
1254 let mut dst = json!({"name": "b", "model": "kept"});
1255 let src = json!({"name": "a"});
1256 restore_path(&mut dst, &src, "model");
1257 assert_eq!(dst["model"], json!("kept"));
1258 }
1259
1260 #[test]
1261 fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1262 let mut dst = json!({"tools": [{"server_url": "kept"}]});
1263 let src = json!({"name": "a"});
1264 restore_path(&mut dst, &src, "tools[].server_url");
1265 assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1266 }
1267}
1268
1269#[cfg(test)]
1270mod index_projection_ref_tests {
1271 use super::*;
1272 use serde_json::json;
1273
1274 #[test]
1275 fn skillset_index_projections_reference_the_index() {
1276 let ss = json!({
1277 "name": "ss",
1278 "skills": [],
1279 "indexProjections": {
1280 "selectors": [
1281 {"targetIndexName": "proj-index-a"},
1282 {"targetIndexName": "proj-index-b"}
1283 ]
1284 }
1285 });
1286 let refs = extract_references(ResourceKind::Skillset, &ss);
1287 assert!(refs.contains(&(ResourceKind::Index, "proj-index-a".into())));
1288 assert!(refs.contains(&(ResourceKind::Index, "proj-index-b".into())));
1289 }
1290
1291 #[test]
1292 fn rename_reference_rewrites_only_matching_values() {
1293 let mut ss = json!({
1294 "name": "ss",
1295 "indexProjections": {
1296 "selectors": [
1297 {"targetIndexName": "old-index"},
1298 {"targetIndexName": "other-index"}
1299 ]
1300 }
1301 });
1302 rename_reference(
1303 ResourceKind::Skillset,
1304 &mut ss,
1305 ResourceKind::Index,
1306 "old-index",
1307 "new-index",
1308 );
1309 assert_eq!(
1310 ss["indexProjections"]["selectors"][0]["targetIndexName"],
1311 "new-index"
1312 );
1313 assert_eq!(
1314 ss["indexProjections"]["selectors"][1]["targetIndexName"],
1315 "other-index"
1316 );
1317 }
1318
1319 #[test]
1320 fn rename_reference_rewrites_indexer_fields() {
1321 let mut idxr = json!({
1322 "name": "i",
1323 "dataSourceName": "old-ds",
1324 "targetIndexName": "old-index",
1325 "skillsetName": "old-ss"
1326 });
1327 rename_reference(
1328 ResourceKind::Indexer,
1329 &mut idxr,
1330 ResourceKind::DataSource,
1331 "old-ds",
1332 "new-ds",
1333 );
1334 assert_eq!(idxr["dataSourceName"], "new-ds");
1335 assert_eq!(
1336 idxr["targetIndexName"], "old-index",
1337 "other kinds untouched"
1338 );
1339 }
1340}
1341
1342#[cfg(test)]
1343mod skillset_env_pinned_tests {
1344 use super::*;
1345
1346 #[test]
1347 fn skillset_webapi_auth_carriers_are_env_pinned() {
1348 let pinned = env_pinned(ResourceKind::Skillset);
1349 for path in [
1350 "skills[].uri",
1351 "skills[].authResourceId",
1352 "skills[].httpHeaders.x-functions-key",
1353 "skills[].x-rigg-auth",
1354 ] {
1355 assert!(pinned.contains(&path), "missing env-pinned path: {path}");
1356 }
1357 }
1358}