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 ],
141 immutable_fields: &[],
142 },
143 KindMeta {
144 kind: ResourceKind::Indexer,
145 domain: Domain::Search,
146 collection_path: "indexers",
147 dir_name: "indexers",
148 channel: Channel::Stable,
149 volatile_fields: COMMON_VOLATILE,
150 read_only_fields: &["status", "lastResult", "executionHistory", "limits"],
151 secret_fields: &[],
152 write_only_fields: &[],
153 sidecar_fields: &[],
154 reference_fields: &[
155 RefField {
156 path: "dataSourceName",
157 to: ResourceKind::DataSource,
158 },
159 RefField {
160 path: "targetIndexName",
161 to: ResourceKind::Index,
162 },
163 RefField {
164 path: "skillsetName",
165 to: ResourceKind::Skillset,
166 },
167 ],
168 immutable_fields: &[],
169 },
170 KindMeta {
171 kind: ResourceKind::SynonymMap,
172 domain: Domain::Search,
173 collection_path: "synonymmaps",
174 dir_name: "synonym-maps",
175 channel: Channel::Stable,
176 volatile_fields: COMMON_VOLATILE,
177 read_only_fields: &[],
178 secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
179 write_only_fields: &[],
180 sidecar_fields: &[],
181 reference_fields: &[],
182 immutable_fields: &[],
183 },
184 KindMeta {
185 kind: ResourceKind::Alias,
186 domain: Domain::Search,
187 collection_path: "aliases",
188 dir_name: "aliases",
189 channel: Channel::Stable,
190 volatile_fields: COMMON_VOLATILE,
191 read_only_fields: &[],
192 secret_fields: &[],
193 write_only_fields: &[],
194 sidecar_fields: &[],
195 reference_fields: &[RefField {
196 path: "indexes[]",
197 to: ResourceKind::Index,
198 }],
199 immutable_fields: &[],
200 },
201 KindMeta {
202 kind: ResourceKind::KnowledgeSource,
203 domain: Domain::Search,
204 collection_path: "knowledgeSources",
205 dir_name: "knowledge-sources",
206 channel: Channel::Stable,
207 volatile_fields: COMMON_VOLATILE,
208 read_only_fields: &["createdResources", "ingestionPermissionOptions"],
210 secret_fields: &[
215 "searchIndexParameters.apiKey",
216 "azureBlobParameters.connectionString",
217 ],
218 write_only_fields: &[],
219 sidecar_fields: &[],
220 reference_fields: &[RefField {
221 path: "searchIndexParameters.searchIndexName",
222 to: ResourceKind::Index,
223 }],
224 immutable_fields: &["kind"],
227 },
228 KindMeta {
229 kind: ResourceKind::KnowledgeBase,
230 domain: Domain::Search,
231 collection_path: "knowledgeBases",
232 dir_name: "knowledge-bases",
233 channel: Channel::Stable,
234 volatile_fields: COMMON_VOLATILE,
235 read_only_fields: &[],
236 secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
237 write_only_fields: &[],
238 sidecar_fields: &[],
239 reference_fields: &[RefField {
240 path: "knowledgeSources[].name",
241 to: ResourceKind::KnowledgeSource,
242 }],
243 immutable_fields: &[],
244 },
245 KindMeta {
246 kind: ResourceKind::Agent,
247 domain: Domain::FoundryData,
248 collection_path: "agents",
249 dir_name: "agents",
250 channel: Channel::Stable,
251 volatile_fields: &[
252 "@odata.etag",
253 "@odata.context",
254 "id",
255 "object",
256 "created_at",
257 "updated_at",
258 "version",
259 "metadata.modified_at",
260 ],
261 read_only_fields: &[],
262 secret_fields: &[],
263 write_only_fields: &[],
264 sidecar_fields: &["instructions"],
265 reference_fields: &[
266 RefField {
267 path: "model",
268 to: ResourceKind::Deployment,
269 },
270 RefField {
271 path: "tools[].project_connection_id",
272 to: ResourceKind::Connection,
273 },
274 ],
275 immutable_fields: &[],
276 },
277 KindMeta {
278 kind: ResourceKind::Deployment,
279 domain: Domain::FoundryArm,
280 collection_path: "deployments",
281 dir_name: "deployments",
282 channel: Channel::Stable,
283 volatile_fields: &[
284 "id",
285 "type",
286 "systemData",
287 "etag",
288 "properties.provisioningState",
289 "properties.capabilities",
290 "properties.rateLimits",
291 "properties.model.callRateLimit",
292 "properties.currentCapacity",
293 "properties.deploymentState",
294 ],
295 read_only_fields: &[],
296 secret_fields: &[],
297 write_only_fields: &[],
298 sidecar_fields: &[],
299 reference_fields: &[RefField {
300 path: "properties.raiPolicyName",
301 to: ResourceKind::Guardrail,
302 }],
303 immutable_fields: &[],
304 },
305 KindMeta {
306 kind: ResourceKind::Connection,
307 domain: Domain::FoundryArm,
308 collection_path: "connections",
309 dir_name: "connections",
310 channel: Channel::Stable,
311 volatile_fields: &[
312 "id",
313 "type",
314 "systemData",
315 "etag",
316 "properties.provisioningState",
317 ],
318 read_only_fields: &[],
319 secret_fields: &[
321 "properties.credentials.key",
322 "properties.credentials.keys",
323 "properties.credentials.secret",
324 "properties.credentials.clientSecret",
325 "properties.credentials.pat",
326 "properties.credentials.sas",
327 ],
328 write_only_fields: &[],
329 sidecar_fields: &[],
330 reference_fields: &[],
331 immutable_fields: &[],
332 },
333 KindMeta {
334 kind: ResourceKind::Guardrail,
335 domain: Domain::FoundryArm,
336 collection_path: "raiPolicies",
337 dir_name: "guardrails",
338 channel: Channel::Stable,
339 volatile_fields: &["id", "type", "systemData", "etag"],
340 read_only_fields: &[],
341 secret_fields: &[],
342 write_only_fields: &[],
343 sidecar_fields: &[],
344 reference_fields: &[],
345 immutable_fields: &[],
346 },
347];
348
349pub fn all_kinds() -> &'static [ResourceKind] {
351 static ORDER: &[ResourceKind] = &[
352 ResourceKind::DataSource,
353 ResourceKind::Index,
354 ResourceKind::Skillset,
355 ResourceKind::Indexer,
356 ResourceKind::SynonymMap,
357 ResourceKind::Alias,
358 ResourceKind::KnowledgeSource,
359 ResourceKind::KnowledgeBase,
360 ResourceKind::Agent,
361 ResourceKind::Deployment,
362 ResourceKind::Connection,
363 ResourceKind::Guardrail,
364 ];
365 ORDER
366}
367
368pub fn meta(kind: ResourceKind) -> &'static KindMeta {
370 KINDS
371 .iter()
372 .find(|m| m.kind == kind)
373 .expect("registry entry exists for every ResourceKind")
374}
375
376pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
382 const GA: &[&str] = &[
383 "azureblob",
384 "adlsgen2",
385 "azuretable",
386 "azuresql",
387 "cosmosdb",
388 "onelake",
389 ];
390 const PREVIEW: &[&str] = &[
391 "azureblob",
392 "adlsgen2",
393 "azuretable",
394 "azuresql",
395 "cosmosdb",
396 "onelake",
397 "mysql",
398 "sharepoint",
399 "azurefile",
400 "azurefiles",
401 ];
402 match channel {
403 Channel::Stable => GA,
404 Channel::Preview => PREVIEW,
405 }
406}
407
408pub fn preview_only_datasource_types() -> &'static [&'static str] {
410 &["mysql", "sharepoint", "azurefile", "azurefiles"]
411}
412
413pub const X_RIGG_REF: &str = "x-rigg-ref";
416pub const X_RIGG_API: &str = "x-rigg-api";
418pub const X_RIGG_PIN: &str = "x-rigg-pin";
423
424fn env_pinned_extra(kind: ResourceKind) -> &'static [&'static str] {
429 match kind {
430 ResourceKind::Agent => &["tools[].server_url", "tools[].project_connection_id"],
431 ResourceKind::Connection => &["properties.target"],
432 _ => &[],
433 }
434}
435
436pub fn env_pinned(kind: ResourceKind) -> Vec<&'static str> {
441 let m = meta(kind);
442 let mut out: Vec<&'static str> = Vec::new();
443 for field in m
444 .secret_fields
445 .iter()
446 .chain(m.write_only_fields)
447 .chain(env_pinned_extra(kind))
448 {
449 if !out.contains(field) {
450 out.push(field);
451 }
452 }
453 out
454}
455
456pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
459 let mut out = Vec::new();
460 for rf in meta(kind).reference_fields {
461 collect_path(body, rf.path, &mut |v| {
462 if let Some(s) = v.as_str() {
463 if !s.is_empty() {
464 out.push((rf.to, s.to_string()));
465 }
466 }
467 });
468 }
469 collect_x_rigg_refs(body, &mut out);
470 if kind == ResourceKind::Agent {
471 collect_portal_agent_refs(body, &mut out);
472 }
473 out.sort();
474 out.dedup();
475 out
476}
477
478fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
483 match v {
484 Value::Object(map) => {
485 if let Some(url) = map.get("server_url").and_then(Value::as_str) {
486 if let Some(kb) = parse_kb_mcp_url(url) {
487 out.push((ResourceKind::KnowledgeBase, kb));
488 }
489 }
490 for val in map.values() {
491 collect_portal_agent_refs(val, out);
492 }
493 }
494 Value::Array(arr) => {
495 for item in arr {
496 collect_portal_agent_refs(item, out);
497 }
498 }
499 _ => {}
500 }
501}
502
503fn parse_kb_mcp_url(url: &str) -> Option<String> {
505 let rest = url.strip_prefix("https://")?;
506 let (host, path) = rest.split_once('/')?;
507 if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
508 return None;
509 }
510 let path = path.split('?').next().unwrap_or(path);
511 let mut segs = path.split('/').filter(|s| !s.is_empty());
512 let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
513 (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
514 .then(|| name.to_string())
515}
516
517pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
523 match kind {
524 ResourceKind::Guardrail => {
525 let system = body
526 .pointer("/properties/type")
527 .and_then(Value::as_str)
528 .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
529 .unwrap_or(false);
530 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
532 system || name.starts_with("Microsoft.")
533 }
534 _ => false,
535 }
536}
537
538pub fn auto_created_by(
544 snapshot: &[(ResourceRef, Value)],
545) -> std::collections::BTreeMap<String, String> {
546 let mut out = std::collections::BTreeMap::new();
547 for (r, doc) in snapshot {
548 if r.kind != ResourceKind::KnowledgeSource {
549 continue;
550 }
551 collect_created_resources(doc, &r.name, &mut out);
552 }
553 out
554}
555
556fn collect_created_resources(
557 v: &Value,
558 ks_name: &str,
559 out: &mut std::collections::BTreeMap<String, String>,
560) {
561 if let Value::Object(map) = v {
562 if let Some(Value::Object(created)) = map.get("createdResources") {
563 for (member, name) in created {
564 let kind = match member.as_str() {
565 "datasource" => Some(ResourceKind::DataSource),
566 "indexer" => Some(ResourceKind::Indexer),
567 "skillset" => Some(ResourceKind::Skillset),
568 "index" => Some(ResourceKind::Index),
569 _ => None, };
571 if let (Some(kind), Some(name)) = (kind, name.as_str()) {
572 out.insert(
573 ResourceRef::new(kind, name.to_string()).key(),
574 ks_name.to_string(),
575 );
576 }
577 }
578 }
579 for val in map.values() {
580 collect_created_resources(val, ks_name, out);
581 }
582 } else if let Value::Array(arr) = v {
583 for item in arr {
584 collect_created_resources(item, ks_name, out);
585 }
586 }
587}
588
589pub fn immutable_diff(
595 kind: ResourceKind,
596 local: &Value,
597 remote: &Value,
598) -> Vec<(&'static str, String, String)> {
599 fn values_at(doc: &Value, path: &str) -> Vec<Value> {
600 let mut vals = Vec::new();
601 collect_path(doc, path, &mut |v| vals.push(v.clone()));
602 vals
603 }
604 fn show(vals: &[Value]) -> String {
605 vals.iter()
606 .map(|v| {
607 v.as_str()
608 .map(str::to_string)
609 .unwrap_or_else(|| v.to_string())
610 })
611 .collect::<Vec<_>>()
612 .join(",")
613 }
614 let mut out = Vec::new();
615 for path in meta(kind).immutable_fields {
616 let l = values_at(local, path);
617 let r = values_at(remote, path);
618 if l != r {
619 out.push((*path, show(&r), show(&l)));
620 }
621 }
622 out
623}
624
625fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
626 match v {
627 Value::Object(map) => {
628 for (k, val) in map {
629 if k == X_RIGG_REF {
630 if let Some(s) = val.as_str() {
631 if let Some((dir, name)) = s.split_once('/') {
632 if let Some(kind) = ResourceKind::from_directory_name(dir) {
633 out.push((kind, name.to_string()));
634 }
635 }
636 }
637 } else {
638 collect_x_rigg_refs(val, out);
639 }
640 }
641 }
642 Value::Array(arr) => {
643 for item in arr {
644 collect_x_rigg_refs(item, out);
645 }
646 }
647 _ => {}
648 }
649}
650
651pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
654 fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
655 let Some((head, rest)) = segments.split_first() else {
656 f(v);
657 return;
658 };
659 if let Some(key) = head.strip_suffix("[]") {
660 let target = if key.is_empty() { Some(v) } else { v.get(key) };
661 if let Some(Value::Array(arr)) = target {
662 for item in arr {
663 walk(item, rest, f);
664 }
665 }
666 } else if let Some(next) = v.get(*head) {
667 walk(next, rest, f);
668 }
669 }
670 let segments: Vec<&str> = path.split('.').collect();
671 walk(v, &segments, f);
672}
673
674pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
697 let segments: Vec<&str> = path.split('.').collect();
698 restore_path_walk(dst, src, &segments);
699}
700
701fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
702 let Some((head, rest)) = segments.split_first() else {
703 *dst = src.clone();
704 return;
705 };
706 if let Some(key) = head.strip_suffix("[]") {
707 if key.is_empty() {
708 pair_arrays(dst, src, rest);
709 } else {
710 let Value::Object(src_map) = src else { return };
711 let Some(src_val) = src_map.get(key) else {
712 return;
713 };
714 let Value::Object(dst_map) = dst else { return };
715 let entry = dst_map
716 .entry(key.to_string())
717 .or_insert_with(|| Value::Array(Vec::new()));
718 pair_arrays(entry, src_val, rest);
719 }
720 } else {
721 let Value::Object(src_map) = src else { return };
722 let Some(src_val) = src_map.get(*head) else {
723 return;
724 };
725 let Value::Object(dst_map) = dst else { return };
726 if rest.is_empty() {
727 dst_map.insert((*head).to_string(), src_val.clone());
731 } else {
732 let entry = dst_map
733 .entry((*head).to_string())
734 .or_insert_with(|| Value::Object(serde_json::Map::new()));
735 restore_path_walk(entry, src_val, rest);
736 }
737 }
738}
739
740fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
747 let (Value::Array(d), Value::Array(s)) = (dst, src) else {
748 return;
749 };
750 let n = d.len().min(s.len());
751 for i in 0..n {
752 restore_path_walk(&mut d[i], &s[i], rest);
753 }
754 if s.len() > d.len() {
755 d.extend(s[n..].iter().cloned());
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use serde_json::json;
763
764 #[test]
765 fn meta_is_total_and_consistent() {
766 for kind in all_kinds() {
767 let m = meta(*kind);
768 assert_eq!(m.kind, *kind);
769 assert!(!m.collection_path.is_empty());
770 assert!(!m.dir_name.is_empty());
771 }
772 assert_eq!(all_kinds().len(), 12);
773 }
774
775 #[test]
776 fn dir_names_unique() {
777 let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
778 dirs.sort();
779 dirs.dedup();
780 assert_eq!(dirs.len(), 12);
781 }
782
783 #[test]
784 fn indexer_references() {
785 let indexer = json!({
786 "name": "idxr",
787 "dataSourceName": "my-ds",
788 "targetIndexName": "my-index",
789 "skillsetName": "my-skills"
790 });
791 let refs = extract_references(ResourceKind::Indexer, &indexer);
792 assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
793 assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
794 assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
795 }
796
797 #[test]
798 fn knowledge_base_and_alias_references() {
799 let kb = json!({
800 "name": "kb",
801 "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
802 });
803 let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
804 assert_eq!(
805 refs,
806 vec![
807 (ResourceKind::KnowledgeSource, "ks-a".to_string()),
808 (ResourceKind::KnowledgeSource, "ks-b".to_string()),
809 ]
810 );
811
812 let alias = json!({"name": "a", "indexes": ["i1"]});
813 let refs = extract_references(ResourceKind::Alias, &alias);
814 assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
815 }
816
817 #[test]
818 fn x_rigg_ref_extracted_at_depth() {
819 let agent = json!({
820 "name": "agent",
821 "model": "gpt-5-mini",
822 "tools": [
823 {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
824 ]
825 });
826 let refs = extract_references(ResourceKind::Agent, &agent);
827 assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
828 assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
829 }
830
831 #[test]
832 fn agent_extracts_portal_kb_url_and_connection_id() {
833 let agent = serde_json::json!({
834 "name": "Regulus",
835 "model": "gpt-5.2-chat",
836 "tools": [{
837 "type": "mcp",
838 "server_label": "kb_regulatory_kb",
839 "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
840 "project_connection_id": "kb-regulatory-kb-9kdyn"
841 }]
842 });
843 let refs = extract_references(ResourceKind::Agent, &agent);
844 assert!(
845 refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
846 "{refs:?}"
847 );
848 assert!(
849 refs.contains(&(
850 ResourceKind::Connection,
851 "kb-regulatory-kb-9kdyn".to_string()
852 )),
853 "{refs:?}"
854 );
855 assert!(
856 refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
857 "{refs:?}"
858 );
859 }
860
861 #[test]
862 fn agent_ignores_non_search_mcp_urls() {
863 let agent = serde_json::json!({
864 "name": "a",
865 "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
866 });
867 let refs = extract_references(ResourceKind::Agent, &agent);
868 assert!(
869 !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
870 "{refs:?}"
871 );
872 }
873
874 #[test]
875 fn deployment_runtime_state_is_volatile() {
876 let vf = meta(ResourceKind::Deployment).volatile_fields;
877 assert!(vf.contains(&"properties.currentCapacity"));
878 assert!(vf.contains(&"properties.deploymentState"));
879 }
880
881 #[test]
882 fn agent_portal_timestamp_is_volatile() {
883 assert!(
884 meta(ResourceKind::Agent)
885 .volatile_fields
886 .contains(&"metadata.modified_at")
887 );
888 }
889
890 #[test]
891 fn is_platform_managed_true_for_system_managed_guardrail() {
892 let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
893 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
894 }
895
896 #[test]
897 fn is_platform_managed_false_for_user_managed_guardrail() {
898 let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
899 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
900 }
901
902 #[test]
903 fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
904 let doc = json!({"name": "Microsoft.Default"});
905 assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
906 }
907
908 #[test]
909 fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
910 let doc = json!({"name": "my-policy"});
911 assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
912 }
913
914 #[test]
915 fn is_platform_managed_only_applies_to_guardrail_kind() {
916 let doc = json!({"name": "Microsoft.whatever"});
917 assert!(!is_platform_managed(ResourceKind::Index, &doc));
918 }
919
920 #[test]
921 fn auto_created_by_finds_nested_created_resources() {
922 let ks = serde_json::json!({
924 "name": "regulatory",
925 "kind": "azureBlob",
926 "azureBlobParameters": {
927 "containerName": "regulatory",
928 "createdResources": {
929 "datasource": "regulatory-datasource",
930 "indexer": "regulatory-indexer",
931 "skillset": "regulatory-skillset",
932 "index": "regulatory-index",
933 "somethingFuture": "ignored-name"
934 }
935 }
936 });
937 let index_doc = serde_json::json!({"name": "regulatory-index"});
938 let snapshot = vec![
939 (
940 ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
941 ks,
942 ),
943 (
944 ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
945 index_doc,
946 ),
947 ];
948 let map = auto_created_by(&snapshot);
949 assert_eq!(
950 map.get("indexes/regulatory-index").map(String::as_str),
951 Some("regulatory")
952 );
953 assert_eq!(
954 map.get("indexers/regulatory-indexer").map(String::as_str),
955 Some("regulatory")
956 );
957 assert_eq!(
958 map.get("data-sources/regulatory-datasource")
959 .map(String::as_str),
960 Some("regulatory")
961 );
962 assert_eq!(
963 map.get("skillsets/regulatory-skillset").map(String::as_str),
964 Some("regulatory")
965 );
966 assert!(
967 !map.values().any(|v| v == "ignored-name"),
968 "unknown member names ignored: {map:?}"
969 );
970 assert_eq!(map.len(), 4);
971 }
972
973 #[test]
974 fn auto_created_by_ignores_non_knowledge_source_docs() {
975 let idx = serde_json::json!({
976 "name": "i",
977 "createdResources": {"index": "x"}
978 });
979 let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
980 assert!(auto_created_by(&snapshot).is_empty());
981 }
982
983 #[test]
984 fn datasource_types_per_channel() {
985 assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
986 assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
987 assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
988 assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
989 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
991 assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
992 }
993
994 #[test]
995 fn ks_points_at_index() {
996 let ks = json!({
997 "name": "ks",
998 "kind": "searchIndex",
999 "searchIndexParameters": {"searchIndexName": "docs"}
1000 });
1001 let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
1002 assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
1003 }
1004
1005 #[test]
1006 fn immutable_diff_detects_kind_change() {
1007 let local = json!({"name": "ks", "kind": "searchIndex",
1008 "searchIndexParameters": {"searchIndexName": "docs"}});
1009 let remote = json!({"name": "ks", "kind": "azureBlob",
1010 "azureBlobParameters": {"containerName": "c"}});
1011 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1012 assert_eq!(
1013 diff,
1014 vec![("kind", "azureBlob".to_string(), "searchIndex".to_string())]
1015 );
1016 }
1017
1018 #[test]
1019 fn immutable_diff_empty_when_kind_unchanged() {
1020 let local = json!({"name": "ks", "kind": "azureBlob", "description": "new"});
1021 let remote = json!({"name": "ks", "kind": "azureBlob"});
1022 assert!(immutable_diff(ResourceKind::KnowledgeSource, &local, &remote).is_empty());
1023 }
1024
1025 #[test]
1026 fn immutable_diff_empty_for_kinds_without_immutable_fields() {
1027 let local = json!({"name": "i", "kind": "a"});
1028 let remote = json!({"name": "i", "kind": "b"});
1029 assert!(immutable_diff(ResourceKind::Index, &local, &remote).is_empty());
1030 }
1031
1032 #[test]
1033 fn immutable_diff_counts_missing_side_as_difference() {
1034 let local = json!({"name": "ks", "kind": "searchIndex"});
1035 let remote = json!({"name": "ks"});
1036 let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1037 assert_eq!(
1038 diff,
1039 vec![("kind", String::new(), "searchIndex".to_string())]
1040 );
1041 }
1042
1043 #[test]
1044 fn env_pinned_agent_covers_tool_server_fields() {
1045 let pinned = env_pinned(ResourceKind::Agent);
1046 assert!(pinned.contains(&"tools[].server_url"));
1047 assert!(pinned.contains(&"tools[].project_connection_id"));
1048 }
1049
1050 #[test]
1051 fn env_pinned_connection_covers_target_endpoint() {
1052 let pinned = env_pinned(ResourceKind::Connection);
1053 assert!(pinned.contains(&"properties.target"));
1054 assert_eq!(
1056 pinned.iter().filter(|f| **f == "properties.target").count(),
1057 1
1058 );
1059 }
1060
1061 #[test]
1062 fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
1063 let pinned = env_pinned(ResourceKind::DataSource);
1066 assert_eq!(
1067 pinned
1068 .iter()
1069 .filter(|f| **f == "credentials.connectionString")
1070 .count(),
1071 1
1072 );
1073 }
1074
1075 #[test]
1076 fn env_pinned_empty_for_kinds_with_no_defaults() {
1077 assert!(env_pinned(ResourceKind::Guardrail).is_empty());
1078 }
1079
1080 #[test]
1081 fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
1082 assert!(
1086 meta(ResourceKind::KnowledgeSource)
1087 .secret_fields
1088 .contains(&"azureBlobParameters.connectionString")
1089 );
1090 assert!(
1091 env_pinned(ResourceKind::KnowledgeSource)
1092 .contains(&"azureBlobParameters.connectionString"),
1093 "env_pinned includes it via the secret_fields union"
1094 );
1095 }
1096
1097 #[test]
1098 fn restore_path_plain_field() {
1099 let mut dst = json!({"name": "b-name", "model": "m1"});
1100 let src = json!({"name": "a-name", "model": "m2"});
1101 restore_path(&mut dst, &src, "name");
1102 assert_eq!(dst["name"], json!("a-name"));
1103 assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1104 }
1105
1106 #[test]
1107 fn restore_path_creates_missing_intermediate_objects() {
1108 let mut dst = json!({"name": "x"});
1109 let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1110 restore_path(&mut dst, &src, "credentials.connectionString");
1111 assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1112 }
1113
1114 #[test]
1115 fn restore_path_array_paired_by_index_not_identity() {
1116 let mut dst = json!({
1117 "tools": [
1118 {"type": "mcp", "server_url": "https://dst-a"},
1119 {"type": "mcp", "server_url": "https://dst-b"}
1120 ]
1121 });
1122 let src = json!({
1123 "tools": [
1124 {"type": "mcp", "server_url": "https://src-a"},
1125 {"type": "mcp", "server_url": "https://src-b"}
1126 ]
1127 });
1128 restore_path(&mut dst, &src, "tools[].server_url");
1129 assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1130 assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1131 assert_eq!(
1132 dst["tools"][0]["type"],
1133 json!("mcp"),
1134 "unrelated sibling kept"
1135 );
1136 }
1137
1138 #[test]
1139 fn restore_path_array_min_prefix_when_lengths_differ() {
1140 let mut dst = json!({
1143 "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1144 });
1145 let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1146 restore_path(&mut dst, &src, "tools[].server_url");
1147 assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1148 assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1149 assert_eq!(
1150 dst["tools"][2]["server_url"],
1151 json!("d3"),
1152 "no src counterpart — left untouched"
1153 );
1154 }
1155
1156 #[test]
1157 fn restore_path_appends_src_only_array_elements_wholesale() {
1158 let mut dst = json!({
1163 "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1164 });
1165 let src = json!({
1166 "tools": [
1167 {"type": "mcp", "server_url": "https://tgt-a"},
1168 {"type": "file_search", "vector_store_ids": ["vs1"]},
1169 {"type": "mcp", "server_url": "https://tgt-c"}
1170 ]
1171 });
1172 restore_path(&mut dst, &src, "tools[].server_url");
1173 let tools = dst["tools"].as_array().unwrap();
1174 assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1175 assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1176 assert_eq!(
1177 tools[1],
1178 json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1179 "extra element appended wholesale, not just the leaf field"
1180 );
1181 assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1182 }
1183
1184 #[test]
1185 fn restore_path_missing_in_src_leaves_dst_untouched() {
1186 let mut dst = json!({"name": "b", "model": "kept"});
1187 let src = json!({"name": "a"});
1188 restore_path(&mut dst, &src, "model");
1189 assert_eq!(dst["model"], json!("kept"));
1190 }
1191
1192 #[test]
1193 fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1194 let mut dst = json!({"tools": [{"server_url": "kept"}]});
1195 let src = json!({"name": "a"});
1196 restore_path(&mut dst, &src, "tools[].server_url");
1197 assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1198 }
1199}