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}
80
81const COMMON_VOLATILE: &[&str] = &["@odata.etag", "@odata.context", "e_tag", "etag"];
82
83static KINDS: &[KindMeta] = &[
84 KindMeta {
85 kind: ResourceKind::DataSource,
86 domain: Domain::Search,
87 collection_path: "datasources",
88 dir_name: "data-sources",
89 channel: Channel::Stable,
90 volatile_fields: COMMON_VOLATILE,
91 read_only_fields: &[],
92 secret_fields: &["credentials.connectionString"],
93 write_only_fields: &["credentials.connectionString"],
94 sidecar_fields: &[],
95 reference_fields: &[],
96 },
97 KindMeta {
98 kind: ResourceKind::Index,
99 domain: Domain::Search,
100 collection_path: "indexes",
101 dir_name: "indexes",
102 channel: Channel::Stable,
103 volatile_fields: COMMON_VOLATILE,
104 read_only_fields: &[],
105 secret_fields: &[
106 "encryptionKey.accessCredentials.applicationSecret",
107 "vectorSearch.vectorizers[].azureOpenAIParameters.apiKey",
108 ],
109 write_only_fields: &[],
110 sidecar_fields: &[],
111 reference_fields: &[],
112 },
113 KindMeta {
114 kind: ResourceKind::Skillset,
115 domain: Domain::Search,
116 collection_path: "skillsets",
117 dir_name: "skillsets",
118 channel: Channel::Stable,
119 volatile_fields: COMMON_VOLATILE,
120 read_only_fields: &[],
121 secret_fields: &[
122 "cognitiveServices.key",
123 "skills[].apiKey",
124 "encryptionKey.accessCredentials.applicationSecret",
125 ],
126 write_only_fields: &[],
127 sidecar_fields: &[],
128 reference_fields: &[
129 RefField {
131 path: "knowledgeStore.projections[].objects[].storageContainer",
132 to: ResourceKind::Index,
133 },
134 ],
135 },
136 KindMeta {
137 kind: ResourceKind::Indexer,
138 domain: Domain::Search,
139 collection_path: "indexers",
140 dir_name: "indexers",
141 channel: Channel::Stable,
142 volatile_fields: COMMON_VOLATILE,
143 read_only_fields: &["status", "lastResult", "executionHistory", "limits"],
144 secret_fields: &[],
145 write_only_fields: &[],
146 sidecar_fields: &[],
147 reference_fields: &[
148 RefField {
149 path: "dataSourceName",
150 to: ResourceKind::DataSource,
151 },
152 RefField {
153 path: "targetIndexName",
154 to: ResourceKind::Index,
155 },
156 RefField {
157 path: "skillsetName",
158 to: ResourceKind::Skillset,
159 },
160 ],
161 },
162 KindMeta {
163 kind: ResourceKind::SynonymMap,
164 domain: Domain::Search,
165 collection_path: "synonymmaps",
166 dir_name: "synonym-maps",
167 channel: Channel::Stable,
168 volatile_fields: COMMON_VOLATILE,
169 read_only_fields: &[],
170 secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
171 write_only_fields: &[],
172 sidecar_fields: &[],
173 reference_fields: &[],
174 },
175 KindMeta {
176 kind: ResourceKind::Alias,
177 domain: Domain::Search,
178 collection_path: "aliases",
179 dir_name: "aliases",
180 channel: Channel::Stable,
181 volatile_fields: COMMON_VOLATILE,
182 read_only_fields: &[],
183 secret_fields: &[],
184 write_only_fields: &[],
185 sidecar_fields: &[],
186 reference_fields: &[RefField {
187 path: "indexes[]",
188 to: ResourceKind::Index,
189 }],
190 },
191 KindMeta {
192 kind: ResourceKind::KnowledgeSource,
193 domain: Domain::Search,
194 collection_path: "knowledgeSources",
195 dir_name: "knowledge-sources",
196 channel: Channel::Stable,
197 volatile_fields: COMMON_VOLATILE,
198 read_only_fields: &["createdResources", "ingestionPermissionOptions"],
200 secret_fields: &[
205 "searchIndexParameters.apiKey",
206 "azureBlobParameters.connectionString",
207 ],
208 write_only_fields: &[],
209 sidecar_fields: &[],
210 reference_fields: &[RefField {
211 path: "searchIndexParameters.searchIndexName",
212 to: ResourceKind::Index,
213 }],
214 },
215 KindMeta {
216 kind: ResourceKind::KnowledgeBase,
217 domain: Domain::Search,
218 collection_path: "knowledgeBases",
219 dir_name: "knowledge-bases",
220 channel: Channel::Stable,
221 volatile_fields: COMMON_VOLATILE,
222 read_only_fields: &[],
223 secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
224 write_only_fields: &[],
225 sidecar_fields: &[],
226 reference_fields: &[RefField {
227 path: "knowledgeSources[].name",
228 to: ResourceKind::KnowledgeSource,
229 }],
230 },
231 KindMeta {
232 kind: ResourceKind::Agent,
233 domain: Domain::FoundryData,
234 collection_path: "agents",
235 dir_name: "agents",
236 channel: Channel::Stable,
237 volatile_fields: &[
238 "@odata.etag",
239 "@odata.context",
240 "id",
241 "object",
242 "created_at",
243 "updated_at",
244 "version",
245 "metadata.modified_at",
246 ],
247 read_only_fields: &[],
248 secret_fields: &[],
249 write_only_fields: &[],
250 sidecar_fields: &["instructions"],
251 reference_fields: &[
252 RefField {
253 path: "model",
254 to: ResourceKind::Deployment,
255 },
256 RefField {
257 path: "tools[].project_connection_id",
258 to: ResourceKind::Connection,
259 },
260 ],
261 },
262 KindMeta {
263 kind: ResourceKind::Deployment,
264 domain: Domain::FoundryArm,
265 collection_path: "deployments",
266 dir_name: "deployments",
267 channel: Channel::Stable,
268 volatile_fields: &[
269 "id",
270 "type",
271 "systemData",
272 "etag",
273 "properties.provisioningState",
274 "properties.capabilities",
275 "properties.rateLimits",
276 "properties.model.callRateLimit",
277 "properties.currentCapacity",
278 "properties.deploymentState",
279 ],
280 read_only_fields: &[],
281 secret_fields: &[],
282 write_only_fields: &[],
283 sidecar_fields: &[],
284 reference_fields: &[RefField {
285 path: "properties.raiPolicyName",
286 to: ResourceKind::Guardrail,
287 }],
288 },
289 KindMeta {
290 kind: ResourceKind::Connection,
291 domain: Domain::FoundryArm,
292 collection_path: "connections",
293 dir_name: "connections",
294 channel: Channel::Stable,
295 volatile_fields: &[
296 "id",
297 "type",
298 "systemData",
299 "etag",
300 "properties.provisioningState",
301 ],
302 read_only_fields: &[],
303 secret_fields: &[
305 "properties.credentials.key",
306 "properties.credentials.keys",
307 "properties.credentials.secret",
308 "properties.credentials.clientSecret",
309 "properties.credentials.pat",
310 "properties.credentials.sas",
311 ],
312 write_only_fields: &[],
313 sidecar_fields: &[],
314 reference_fields: &[],
315 },
316 KindMeta {
317 kind: ResourceKind::Guardrail,
318 domain: Domain::FoundryArm,
319 collection_path: "raiPolicies",
320 dir_name: "guardrails",
321 channel: Channel::Stable,
322 volatile_fields: &["id", "type", "systemData", "etag"],
323 read_only_fields: &[],
324 secret_fields: &[],
325 write_only_fields: &[],
326 sidecar_fields: &[],
327 reference_fields: &[],
328 },
329];
330
331pub fn all_kinds() -> &'static [ResourceKind] {
333 static ORDER: &[ResourceKind] = &[
334 ResourceKind::DataSource,
335 ResourceKind::Index,
336 ResourceKind::Skillset,
337 ResourceKind::Indexer,
338 ResourceKind::SynonymMap,
339 ResourceKind::Alias,
340 ResourceKind::KnowledgeSource,
341 ResourceKind::KnowledgeBase,
342 ResourceKind::Agent,
343 ResourceKind::Deployment,
344 ResourceKind::Connection,
345 ResourceKind::Guardrail,
346 ];
347 ORDER
348}
349
350pub fn meta(kind: ResourceKind) -> &'static KindMeta {
352 KINDS
353 .iter()
354 .find(|m| m.kind == kind)
355 .expect("registry entry exists for every ResourceKind")
356}
357
358pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
364 const GA: &[&str] = &[
365 "azureblob",
366 "adlsgen2",
367 "azuretable",
368 "azuresql",
369 "cosmosdb",
370 "onelake",
371 ];
372 const PREVIEW: &[&str] = &[
373 "azureblob",
374 "adlsgen2",
375 "azuretable",
376 "azuresql",
377 "cosmosdb",
378 "onelake",
379 "mysql",
380 "sharepoint",
381 "azurefile",
382 "azurefiles",
383 ];
384 match channel {
385 Channel::Stable => GA,
386 Channel::Preview => PREVIEW,
387 }
388}
389
390pub fn preview_only_datasource_types() -> &'static [&'static str] {
392 &["mysql", "sharepoint", "azurefile", "azurefiles"]
393}
394
395pub const X_RIGG_REF: &str = "x-rigg-ref";
398pub const X_RIGG_API: &str = "x-rigg-api";
400pub const X_RIGG_PIN: &str = "x-rigg-pin";
405
406fn env_pinned_extra(kind: ResourceKind) -> &'static [&'static str] {
411 match kind {
412 ResourceKind::Agent => &["tools[].server_url", "tools[].project_connection_id"],
413 ResourceKind::Connection => &["properties.target"],
414 _ => &[],
415 }
416}
417
418pub fn env_pinned(kind: ResourceKind) -> Vec<&'static str> {
423 let m = meta(kind);
424 let mut out: Vec<&'static str> = Vec::new();
425 for field in m
426 .secret_fields
427 .iter()
428 .chain(m.write_only_fields)
429 .chain(env_pinned_extra(kind))
430 {
431 if !out.contains(field) {
432 out.push(field);
433 }
434 }
435 out
436}
437
438pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
441 let mut out = Vec::new();
442 for rf in meta(kind).reference_fields {
443 collect_path(body, rf.path, &mut |v| {
444 if let Some(s) = v.as_str() {
445 if !s.is_empty() {
446 out.push((rf.to, s.to_string()));
447 }
448 }
449 });
450 }
451 collect_x_rigg_refs(body, &mut out);
452 if kind == ResourceKind::Agent {
453 collect_portal_agent_refs(body, &mut out);
454 }
455 out.sort();
456 out.dedup();
457 out
458}
459
460fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
465 match v {
466 Value::Object(map) => {
467 if let Some(url) = map.get("server_url").and_then(Value::as_str) {
468 if let Some(kb) = parse_kb_mcp_url(url) {
469 out.push((ResourceKind::KnowledgeBase, kb));
470 }
471 }
472 for val in map.values() {
473 collect_portal_agent_refs(val, out);
474 }
475 }
476 Value::Array(arr) => {
477 for item in arr {
478 collect_portal_agent_refs(item, out);
479 }
480 }
481 _ => {}
482 }
483}
484
485fn parse_kb_mcp_url(url: &str) -> Option<String> {
487 let rest = url.strip_prefix("https://")?;
488 let (host, path) = rest.split_once('/')?;
489 if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
490 return None;
491 }
492 let path = path.split('?').next().unwrap_or(path);
493 let mut segs = path.split('/').filter(|s| !s.is_empty());
494 let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
495 (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
496 .then(|| name.to_string())
497}
498
499pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
505 match kind {
506 ResourceKind::Guardrail => {
507 let system = body
508 .pointer("/properties/type")
509 .and_then(Value::as_str)
510 .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
511 .unwrap_or(false);
512 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
514 system || name.starts_with("Microsoft.")
515 }
516 _ => false,
517 }
518}
519
520pub fn auto_created_by(
526 snapshot: &[(ResourceRef, Value)],
527) -> std::collections::BTreeMap<String, String> {
528 let mut out = std::collections::BTreeMap::new();
529 for (r, doc) in snapshot {
530 if r.kind != ResourceKind::KnowledgeSource {
531 continue;
532 }
533 collect_created_resources(doc, &r.name, &mut out);
534 }
535 out
536}
537
538fn collect_created_resources(
539 v: &Value,
540 ks_name: &str,
541 out: &mut std::collections::BTreeMap<String, String>,
542) {
543 if let Value::Object(map) = v {
544 if let Some(Value::Object(created)) = map.get("createdResources") {
545 for (member, name) in created {
546 let kind = match member.as_str() {
547 "datasource" => Some(ResourceKind::DataSource),
548 "indexer" => Some(ResourceKind::Indexer),
549 "skillset" => Some(ResourceKind::Skillset),
550 "index" => Some(ResourceKind::Index),
551 _ => None, };
553 if let (Some(kind), Some(name)) = (kind, name.as_str()) {
554 out.insert(
555 ResourceRef::new(kind, name.to_string()).key(),
556 ks_name.to_string(),
557 );
558 }
559 }
560 }
561 for val in map.values() {
562 collect_created_resources(val, ks_name, out);
563 }
564 } else if let Value::Array(arr) = v {
565 for item in arr {
566 collect_created_resources(item, ks_name, out);
567 }
568 }
569}
570
571fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
572 match v {
573 Value::Object(map) => {
574 for (k, val) in map {
575 if k == X_RIGG_REF {
576 if let Some(s) = val.as_str() {
577 if let Some((dir, name)) = s.split_once('/') {
578 if let Some(kind) = ResourceKind::from_directory_name(dir) {
579 out.push((kind, name.to_string()));
580 }
581 }
582 }
583 } else {
584 collect_x_rigg_refs(val, out);
585 }
586 }
587 }
588 Value::Array(arr) => {
589 for item in arr {
590 collect_x_rigg_refs(item, out);
591 }
592 }
593 _ => {}
594 }
595}
596
597pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
600 fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
601 let Some((head, rest)) = segments.split_first() else {
602 f(v);
603 return;
604 };
605 if let Some(key) = head.strip_suffix("[]") {
606 let target = if key.is_empty() { Some(v) } else { v.get(key) };
607 if let Some(Value::Array(arr)) = target {
608 for item in arr {
609 walk(item, rest, f);
610 }
611 }
612 } else if let Some(next) = v.get(*head) {
613 walk(next, rest, f);
614 }
615 }
616 let segments: Vec<&str> = path.split('.').collect();
617 walk(v, &segments, f);
618}
619
620pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
643 let segments: Vec<&str> = path.split('.').collect();
644 restore_path_walk(dst, src, &segments);
645}
646
647fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
648 let Some((head, rest)) = segments.split_first() else {
649 *dst = src.clone();
650 return;
651 };
652 if let Some(key) = head.strip_suffix("[]") {
653 if key.is_empty() {
654 pair_arrays(dst, src, rest);
655 } else {
656 let Value::Object(src_map) = src else { return };
657 let Some(src_val) = src_map.get(key) else {
658 return;
659 };
660 let Value::Object(dst_map) = dst else { return };
661 let entry = dst_map
662 .entry(key.to_string())
663 .or_insert_with(|| Value::Array(Vec::new()));
664 pair_arrays(entry, src_val, rest);
665 }
666 } else {
667 let Value::Object(src_map) = src else { return };
668 let Some(src_val) = src_map.get(*head) else {
669 return;
670 };
671 let Value::Object(dst_map) = dst else { return };
672 if rest.is_empty() {
673 dst_map.insert((*head).to_string(), src_val.clone());
677 } else {
678 let entry = dst_map
679 .entry((*head).to_string())
680 .or_insert_with(|| Value::Object(serde_json::Map::new()));
681 restore_path_walk(entry, src_val, rest);
682 }
683 }
684}
685
686fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
693 let (Value::Array(d), Value::Array(s)) = (dst, src) else {
694 return;
695 };
696 let n = d.len().min(s.len());
697 for i in 0..n {
698 restore_path_walk(&mut d[i], &s[i], rest);
699 }
700 if s.len() > d.len() {
701 d.extend(s[n..].iter().cloned());
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use serde_json::json;
709
710 #[test]
711 fn meta_is_total_and_consistent() {
712 for kind in all_kinds() {
713 let m = meta(*kind);
714 assert_eq!(m.kind, *kind);
715 assert!(!m.collection_path.is_empty());
716 assert!(!m.dir_name.is_empty());
717 }
718 assert_eq!(all_kinds().len(), 12);
719 }
720
721 #[test]
722 fn dir_names_unique() {
723 let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
724 dirs.sort();
725 dirs.dedup();
726 assert_eq!(dirs.len(), 12);
727 }
728
729 #[test]
730 fn indexer_references() {
731 let indexer = json!({
732 "name": "idxr",
733 "dataSourceName": "my-ds",
734 "targetIndexName": "my-index",
735 "skillsetName": "my-skills"
736 });
737 let refs = extract_references(ResourceKind::Indexer, &indexer);
738 assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
739 assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
740 assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
741 }
742
743 #[test]
744 fn knowledge_base_and_alias_references() {
745 let kb = json!({
746 "name": "kb",
747 "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
748 });
749 let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
750 assert_eq!(
751 refs,
752 vec![
753 (ResourceKind::KnowledgeSource, "ks-a".to_string()),
754 (ResourceKind::KnowledgeSource, "ks-b".to_string()),
755 ]
756 );
757
758 let alias = json!({"name": "a", "indexes": ["i1"]});
759 let refs = extract_references(ResourceKind::Alias, &alias);
760 assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
761 }
762
763 #[test]
764 fn x_rigg_ref_extracted_at_depth() {
765 let agent = json!({
766 "name": "agent",
767 "model": "gpt-5-mini",
768 "tools": [
769 {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
770 ]
771 });
772 let refs = extract_references(ResourceKind::Agent, &agent);
773 assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
774 assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
775 }
776
777 #[test]
778 fn agent_extracts_portal_kb_url_and_connection_id() {
779 let agent = serde_json::json!({
780 "name": "Regulus",
781 "model": "gpt-5.2-chat",
782 "tools": [{
783 "type": "mcp",
784 "server_label": "kb_regulatory_kb",
785 "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
786 "project_connection_id": "kb-regulatory-kb-9kdyn"
787 }]
788 });
789 let refs = extract_references(ResourceKind::Agent, &agent);
790 assert!(
791 refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
792 "{refs:?}"
793 );
794 assert!(
795 refs.contains(&(
796 ResourceKind::Connection,
797 "kb-regulatory-kb-9kdyn".to_string()
798 )),
799 "{refs:?}"
800 );
801 assert!(
802 refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
803 "{refs:?}"
804 );
805 }
806
807 #[test]
808 fn agent_ignores_non_search_mcp_urls() {
809 let agent = serde_json::json!({
810 "name": "a",
811 "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
812 });
813 let refs = extract_references(ResourceKind::Agent, &agent);
814 assert!(
815 !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
816 "{refs:?}"
817 );
818 }
819
820 #[test]
821 fn deployment_runtime_state_is_volatile() {
822 let vf = meta(ResourceKind::Deployment).volatile_fields;
823 assert!(vf.contains(&"properties.currentCapacity"));
824 assert!(vf.contains(&"properties.deploymentState"));
825 }
826
827 #[test]
828 fn agent_portal_timestamp_is_volatile() {
829 assert!(
830 meta(ResourceKind::Agent)
831 .volatile_fields
832 .contains(&"metadata.modified_at")
833 );
834 }
835
836 #[test]
837 fn is_platform_managed_true_for_system_managed_guardrail() {
838 let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
839 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
840 }
841
842 #[test]
843 fn is_platform_managed_false_for_user_managed_guardrail() {
844 let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
845 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
846 }
847
848 #[test]
849 fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
850 let doc = json!({"name": "Microsoft.Default"});
851 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
852 }
853
854 #[test]
855 fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
856 let doc = json!({"name": "my-policy"});
857 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
858 }
859
860 #[test]
861 fn is_platform_managed_only_applies_to_guardrail_kind() {
862 let doc = json!({"name": "Microsoft.whatever"});
863 assert!(!is_platform_managed(ResourceKind::Index, &doc));
864 }
865
866 #[test]
867 fn auto_created_by_finds_nested_created_resources() {
868 let ks = serde_json::json!({
870 "name": "regulatory",
871 "kind": "azureBlob",
872 "azureBlobParameters": {
873 "containerName": "regulatory",
874 "createdResources": {
875 "datasource": "regulatory-datasource",
876 "indexer": "regulatory-indexer",
877 "skillset": "regulatory-skillset",
878 "index": "regulatory-index",
879 "somethingFuture": "ignored-name"
880 }
881 }
882 });
883 let index_doc = serde_json::json!({"name": "regulatory-index"});
884 let snapshot = vec![
885 (
886 ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
887 ks,
888 ),
889 (
890 ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
891 index_doc,
892 ),
893 ];
894 let map = auto_created_by(&snapshot);
895 assert_eq!(
896 map.get("indexes/regulatory-index").map(String::as_str),
897 Some("regulatory")
898 );
899 assert_eq!(
900 map.get("indexers/regulatory-indexer").map(String::as_str),
901 Some("regulatory")
902 );
903 assert_eq!(
904 map.get("data-sources/regulatory-datasource")
905 .map(String::as_str),
906 Some("regulatory")
907 );
908 assert_eq!(
909 map.get("skillsets/regulatory-skillset").map(String::as_str),
910 Some("regulatory")
911 );
912 assert!(
913 !map.values().any(|v| v == "ignored-name"),
914 "unknown member names ignored: {map:?}"
915 );
916 assert_eq!(map.len(), 4);
917 }
918
919 #[test]
920 fn auto_created_by_ignores_non_knowledge_source_docs() {
921 let idx = serde_json::json!({
922 "name": "i",
923 "createdResources": {"index": "x"}
924 });
925 let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
926 assert!(auto_created_by(&snapshot).is_empty());
927 }
928
929 #[test]
930 fn datasource_types_per_channel() {
931 assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
932 assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
933 assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
934 assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
935 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
937 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
938 }
939
940 #[test]
941 fn ks_points_at_index() {
942 let ks = json!({
943 "name": "ks",
944 "kind": "searchIndex",
945 "searchIndexParameters": {"searchIndexName": "docs"}
946 });
947 let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
948 assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
949 }
950
951 #[test]
952 fn env_pinned_agent_covers_tool_server_fields() {
953 let pinned = env_pinned(ResourceKind::Agent);
954 assert!(pinned.contains(&"tools[].server_url"));
955 assert!(pinned.contains(&"tools[].project_connection_id"));
956 }
957
958 #[test]
959 fn env_pinned_connection_covers_target_endpoint() {
960 let pinned = env_pinned(ResourceKind::Connection);
961 assert!(pinned.contains(&"properties.target"));
962 assert_eq!(
964 pinned.iter().filter(|f| **f == "properties.target").count(),
965 1
966 );
967 }
968
969 #[test]
970 fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
971 let pinned = env_pinned(ResourceKind::DataSource);
974 assert_eq!(
975 pinned
976 .iter()
977 .filter(|f| **f == "credentials.connectionString")
978 .count(),
979 1
980 );
981 }
982
983 #[test]
984 fn env_pinned_empty_for_kinds_with_no_defaults() {
985 assert!(env_pinned(ResourceKind::Guardrail).is_empty());
986 }
987
988 #[test]
989 fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
990 assert!(
994 meta(ResourceKind::KnowledgeSource)
995 .secret_fields
996 .contains(&"azureBlobParameters.connectionString")
997 );
998 assert!(
999 env_pinned(ResourceKind::KnowledgeSource)
1000 .contains(&"azureBlobParameters.connectionString"),
1001 "env_pinned includes it via the secret_fields union"
1002 );
1003 }
1004
1005 #[test]
1006 fn restore_path_plain_field() {
1007 let mut dst = json!({"name": "b-name", "model": "m1"});
1008 let src = json!({"name": "a-name", "model": "m2"});
1009 restore_path(&mut dst, &src, "name");
1010 assert_eq!(dst["name"], json!("a-name"));
1011 assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1012 }
1013
1014 #[test]
1015 fn restore_path_creates_missing_intermediate_objects() {
1016 let mut dst = json!({"name": "x"});
1017 let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1018 restore_path(&mut dst, &src, "credentials.connectionString");
1019 assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1020 }
1021
1022 #[test]
1023 fn restore_path_array_paired_by_index_not_identity() {
1024 let mut dst = json!({
1025 "tools": [
1026 {"type": "mcp", "server_url": "https://dst-a"},
1027 {"type": "mcp", "server_url": "https://dst-b"}
1028 ]
1029 });
1030 let src = json!({
1031 "tools": [
1032 {"type": "mcp", "server_url": "https://src-a"},
1033 {"type": "mcp", "server_url": "https://src-b"}
1034 ]
1035 });
1036 restore_path(&mut dst, &src, "tools[].server_url");
1037 assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1038 assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1039 assert_eq!(
1040 dst["tools"][0]["type"],
1041 json!("mcp"),
1042 "unrelated sibling kept"
1043 );
1044 }
1045
1046 #[test]
1047 fn restore_path_array_min_prefix_when_lengths_differ() {
1048 let mut dst = json!({
1051 "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1052 });
1053 let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1054 restore_path(&mut dst, &src, "tools[].server_url");
1055 assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1056 assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1057 assert_eq!(
1058 dst["tools"][2]["server_url"],
1059 json!("d3"),
1060 "no src counterpart — left untouched"
1061 );
1062 }
1063
1064 #[test]
1065 fn restore_path_appends_src_only_array_elements_wholesale() {
1066 let mut dst = json!({
1071 "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1072 });
1073 let src = json!({
1074 "tools": [
1075 {"type": "mcp", "server_url": "https://tgt-a"},
1076 {"type": "file_search", "vector_store_ids": ["vs1"]},
1077 {"type": "mcp", "server_url": "https://tgt-c"}
1078 ]
1079 });
1080 restore_path(&mut dst, &src, "tools[].server_url");
1081 let tools = dst["tools"].as_array().unwrap();
1082 assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1083 assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1084 assert_eq!(
1085 tools[1],
1086 json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1087 "extra element appended wholesale, not just the leaf field"
1088 );
1089 assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1090 }
1091
1092 #[test]
1093 fn restore_path_missing_in_src_leaves_dst_untouched() {
1094 let mut dst = json!({"name": "b", "model": "kept"});
1095 let src = json!({"name": "a"});
1096 restore_path(&mut dst, &src, "model");
1097 assert_eq!(dst["model"], json!("kept"));
1098 }
1099
1100 #[test]
1101 fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1102 let mut dst = json!({"tools": [{"server_url": "kept"}]});
1103 let src = json!({"name": "a"});
1104 restore_path(&mut dst, &src, "tools[].server_url");
1105 assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1106 }
1107}