Skip to main content

rigg_core/
registry.rs

1//! Declarative per-kind metadata registry.
2//!
3//! This is the single place that encodes what Rigg knows *about* each resource
4//! kind: where it lives in the API, which api-version channel it needs, which
5//! fields are volatile / read-only / secret, how it references other
6//! resources, and which values are valid per channel. Resources themselves
7//! remain schema-light `serde_json::Value` passthrough documents.
8//!
9//! When Azure ships a new API version, updating Rigg should mostly mean
10//! editing this file (`rigg dev api-check` watches for that).
11
12use serde_json::Value;
13
14use crate::resources::traits::{ResourceKind, ResourceRef};
15
16/// Azure AI Search data plane. Overridable per environment in `rigg.yaml`.
17pub const SEARCH_STABLE_API_VERSION: &str = "2026-04-01";
18pub const SEARCH_PREVIEW_API_VERSION: &str = "2026-08-01-preview";
19/// Microsoft Foundry data plane (route-versioned).
20pub const FOUNDRY_API_VERSION: &str = "v1";
21/// ARM: Microsoft.CognitiveServices (accounts, projects, deployments, connections, RAI policies).
22/// Newest version ARM registers for every CognitiveServices resource type
23/// rigg uses; `accounts/projects/connections` caps it (2026-07-01 is
24/// registered for accounts and projects only, and changed nothing rigg
25/// reads).
26pub const ARM_COGNITIVE_API_VERSION: &str = "2026-05-01";
27/// ARM: Microsoft.Search (search services, identity, network, shared private links).
28pub const ARM_SEARCH_API_VERSION: &str = "2025-05-01";
29/// ARM: Microsoft.Storage (accounts, blob services, containers).
30pub const ARM_STORAGE_API_VERSION: &str = "2026-06-01";
31/// ARM: Microsoft.Web (sites, function keys, auth settings, site config).
32pub const ARM_WEB_API_VERSION: &str = "2026-07-15";
33/// ARM: Microsoft.Authorization (role assignments, permissions).
34pub const ARM_AUTHORIZATION_API_VERSION: &str = "2022-04-01";
35/// ARM: Microsoft.Resources (subscriptions, tenants).
36pub const ARM_RESOURCES_API_VERSION: &str = "2022-12-01";
37/// ARM: Microsoft.ManagedIdentity (user-assigned identities).
38pub const ARM_MANAGED_IDENTITY_API_VERSION: &str = "2024-11-30";
39/// ARM: Microsoft.KeyVault (vaults).
40pub const ARM_KEYVAULT_API_VERSION: &str = "2026-02-01";
41/// Key Vault data plane (secrets).
42pub const KEYVAULT_SECRETS_API_VERSION: &str = "2025-07-01";
43/// Microsoft Graph (route-versioned).
44pub const GRAPH_API_VERSION: &str = "v1.0";
45pub const ARM_BASE_URL: &str = "https://management.azure.com";
46pub const GRAPH_BASE_URL: &str = "https://graph.microsoft.com/v1.0";
47
48/// Every remote API rigg talks to.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum Provider {
51    SearchData,
52    FoundryData,
53    CognitiveServicesArm,
54    SearchArm,
55    StorageArm,
56    WebArm,
57    AuthorizationArm,
58    ResourcesArm,
59    ManagedIdentityArm,
60    KeyVaultArm,
61    KeyVaultData,
62    Graph,
63}
64
65/// What `az provider show -n <namespace>` (and [`crate::registry`]-driven
66/// callers of `ArmClient::provider_api_versions`) reports for the ARM
67/// resource types a provider's calls touch: the ground truth for which
68/// api-version a call may actually use, since the specs repository can be
69/// ahead of what ARM has registered.
70#[derive(Debug, Clone, Copy)]
71pub struct ArmRegistration {
72    pub namespace: &'static str,
73    pub resource_types: &'static [&'static str],
74}
75
76/// A stable-channel pin held below the newest version the specs repository
77/// publishes, and why — so `rigg dev api-check` can report `held` instead of
78/// `BEHIND` forever, and say when the hold can be lifted.
79#[derive(Debug, Clone, Copy)]
80pub struct Hold {
81    pub newer: &'static str,
82    pub reason: &'static str,
83}
84
85#[derive(Debug, Clone, Copy)]
86pub struct ProviderMeta {
87    pub provider: Provider,
88    /// Human label for `rigg dev api-check`.
89    pub label: &'static str,
90    pub stable: &'static str,
91    pub preview: Option<&'static str>,
92    /// Token audience (scope base, without `/.default`).
93    pub audience: &'static str,
94    /// Folder in Azure/azure-rest-api-specs whose entries are version folders
95    /// (`None` for route-versioned APIs and APIs not in that repository).
96    pub spec_path: Option<&'static str>,
97    /// Preview folder in the specs repository, when rigg uses a preview.
98    pub preview_spec_path: Option<&'static str>,
99    pub route_versioned: bool,
100    /// ARM registration coordinates for this provider's resource types
101    /// (`None` for data-plane/route-versioned APIs, and for the ARM
102    /// registration API itself).
103    pub arm: Option<ArmRegistration>,
104    /// Set when `stable` is deliberately held below the newest spec-repo
105    /// version.
106    pub hold: Option<Hold>,
107}
108
109static PROVIDERS: &[ProviderMeta] = &[
110    ProviderMeta {
111        provider: Provider::SearchData,
112        label: "Azure AI Search data plane",
113        stable: SEARCH_STABLE_API_VERSION,
114        preview: Some(SEARCH_PREVIEW_API_VERSION),
115        audience: "https://search.azure.com",
116        spec_path: Some("specification/search/data-plane/Search/stable"),
117        preview_spec_path: Some("specification/search/data-plane/Search/preview"),
118        route_versioned: false,
119        arm: None,
120        hold: None,
121    },
122    ProviderMeta {
123        provider: Provider::FoundryData,
124        label: "Microsoft Foundry data plane",
125        stable: FOUNDRY_API_VERSION,
126        preview: None,
127        audience: "https://ai.azure.com",
128        spec_path: None,
129        preview_spec_path: None,
130        route_versioned: true,
131        arm: None,
132        hold: None,
133    },
134    ProviderMeta {
135        provider: Provider::CognitiveServicesArm,
136        label: "Microsoft.CognitiveServices ARM",
137        stable: ARM_COGNITIVE_API_VERSION,
138        preview: None,
139        audience: "https://management.azure.com",
140        spec_path: Some(
141            "specification/cognitiveservices/resource-manager/Microsoft.CognitiveServices/stable",
142        ),
143        preview_spec_path: None,
144        route_versioned: false,
145        arm: Some(ArmRegistration {
146            namespace: "Microsoft.CognitiveServices",
147            resource_types: &[
148                "accounts",
149                "accounts/projects",
150                "accounts/projects/connections",
151            ],
152        }),
153        hold: Some(Hold {
154            newer: "2026-07-01",
155            reason: "not registered for accounts/projects/connections (max 2026-05-01 stable)",
156        }),
157    },
158    ProviderMeta {
159        provider: Provider::SearchArm,
160        label: "Microsoft.Search ARM",
161        stable: ARM_SEARCH_API_VERSION,
162        preview: None,
163        audience: "https://management.azure.com",
164        spec_path: Some("specification/search/resource-manager/Microsoft.Search/Search/stable"),
165        preview_spec_path: None,
166        route_versioned: false,
167        arm: Some(ArmRegistration {
168            namespace: "Microsoft.Search",
169            resource_types: &["searchServices"],
170        }),
171        hold: None,
172    },
173    ProviderMeta {
174        provider: Provider::StorageArm,
175        label: "Microsoft.Storage ARM",
176        stable: ARM_STORAGE_API_VERSION,
177        preview: None,
178        audience: "https://management.azure.com",
179        spec_path: Some("specification/storage/resource-manager/Microsoft.Storage/stable"),
180        preview_spec_path: None,
181        route_versioned: false,
182        arm: Some(ArmRegistration {
183            namespace: "Microsoft.Storage",
184            resource_types: &["storageAccounts"],
185        }),
186        hold: None,
187    },
188    ProviderMeta {
189        provider: Provider::WebArm,
190        label: "Microsoft.Web ARM",
191        stable: ARM_WEB_API_VERSION,
192        preview: None,
193        audience: "https://management.azure.com",
194        spec_path: Some("specification/web/resource-manager/Microsoft.Web/AppService/stable"),
195        preview_spec_path: None,
196        route_versioned: false,
197        arm: Some(ArmRegistration {
198            namespace: "Microsoft.Web",
199            resource_types: &["sites"],
200        }),
201        hold: None,
202    },
203    ProviderMeta {
204        provider: Provider::AuthorizationArm,
205        label: "Microsoft.Authorization ARM",
206        stable: ARM_AUTHORIZATION_API_VERSION,
207        preview: None,
208        audience: "https://management.azure.com",
209        spec_path: Some(
210            "specification/authorization/resource-manager/Microsoft.Authorization/Authorization/stable",
211        ),
212        preview_spec_path: None,
213        route_versioned: false,
214        arm: Some(ArmRegistration {
215            namespace: "Microsoft.Authorization",
216            resource_types: &["roleAssignments"],
217        }),
218        hold: None,
219    },
220    ProviderMeta {
221        provider: Provider::ResourcesArm,
222        label: "Microsoft.Resources ARM",
223        stable: ARM_RESOURCES_API_VERSION,
224        preview: None,
225        audience: "https://management.azure.com",
226        spec_path: Some(
227            "specification/resources/resource-manager/Microsoft.Resources/subscriptions/stable",
228        ),
229        preview_spec_path: None,
230        route_versioned: false,
231        arm: None,
232        hold: None,
233    },
234    // Used by the identity-and-auth workstream (spec 2026-09-09-identity-and-auth-design.md).
235    ProviderMeta {
236        provider: Provider::ManagedIdentityArm,
237        label: "Microsoft.ManagedIdentity ARM",
238        stable: ARM_MANAGED_IDENTITY_API_VERSION,
239        preview: None,
240        audience: "https://management.azure.com",
241        spec_path: Some(
242            "specification/msi/resource-manager/Microsoft.ManagedIdentity/ManagedIdentity/stable",
243        ),
244        preview_spec_path: None,
245        route_versioned: false,
246        arm: Some(ArmRegistration {
247            namespace: "Microsoft.ManagedIdentity",
248            resource_types: &["userAssignedIdentities"],
249        }),
250        hold: None,
251    },
252    // Used by the identity-and-auth workstream (spec 2026-09-09-identity-and-auth-design.md).
253    ProviderMeta {
254        provider: Provider::KeyVaultArm,
255        label: "Microsoft.KeyVault ARM",
256        stable: ARM_KEYVAULT_API_VERSION,
257        preview: None,
258        audience: "https://management.azure.com",
259        spec_path: Some(
260            "specification/keyvault/resource-manager/Microsoft.KeyVault/KeyVault/stable",
261        ),
262        preview_spec_path: None,
263        route_versioned: false,
264        arm: Some(ArmRegistration {
265            namespace: "Microsoft.KeyVault",
266            resource_types: &["vaults"],
267        }),
268        hold: None,
269    },
270    // Used by the identity-and-auth workstream (spec 2026-09-09-identity-and-auth-design.md).
271    ProviderMeta {
272        provider: Provider::KeyVaultData,
273        label: "Key Vault data plane (secrets)",
274        stable: KEYVAULT_SECRETS_API_VERSION,
275        preview: None,
276        audience: "https://vault.azure.net",
277        spec_path: Some("specification/keyvault/data-plane/Secrets/stable"),
278        preview_spec_path: None,
279        route_versioned: false,
280        arm: None,
281        hold: None,
282    },
283    // Used by the identity-and-auth workstream (spec 2026-09-09-identity-and-auth-design.md).
284    ProviderMeta {
285        provider: Provider::Graph,
286        label: "Microsoft Graph",
287        stable: GRAPH_API_VERSION,
288        preview: None,
289        audience: "https://graph.microsoft.com",
290        spec_path: None,
291        preview_spec_path: None,
292        route_versioned: true,
293        arm: None,
294        hold: None,
295    },
296];
297
298pub fn providers() -> &'static [ProviderMeta] {
299    PROVIDERS
300}
301
302pub fn provider(p: Provider) -> &'static ProviderMeta {
303    PROVIDERS
304        .iter()
305        .find(|m| m.provider == p)
306        .expect("every Provider has a table entry")
307}
308
309/// Which service/plane a kind is managed through.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum Domain {
312    /// Azure AI Search data plane.
313    Search,
314    /// Microsoft Foundry project data plane (`api-version=v1`).
315    FoundryData,
316    /// ARM control plane under Microsoft.CognitiveServices.
317    FoundryArm,
318}
319
320/// API version channel a kind (or capability) requires.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum Channel {
323    Stable,
324    Preview,
325}
326
327/// A reference-bearing field: `path` addresses a string (or array of strings)
328/// naming resources of kind `to`.
329///
330/// Path syntax: dot-separated keys; `[]` after a key descends into each array
331/// element. Examples: `"dataSourceName"`, `"knowledgeSources[].name"`,
332/// `"indexes[].name"`.
333#[derive(Debug, Clone, Copy)]
334pub struct RefField {
335    pub path: &'static str,
336    pub to: ResourceKind,
337}
338
339/// Declarative metadata for one resource kind.
340#[derive(Debug, Clone, Copy)]
341pub struct KindMeta {
342    pub kind: ResourceKind,
343    pub domain: Domain,
344    /// API collection path (exact casing as the REST API expects).
345    pub collection_path: &'static str,
346    /// Directory name on disk, relative to the project's `search/` or `foundry/` dir.
347    pub dir_name: &'static str,
348    /// Minimum channel required for the kind itself.
349    pub channel: Channel,
350    /// Stripped on pull and ignored in diff (dot paths, applied at any depth
351    /// for `@odata.*`; top-level otherwise).
352    pub volatile_fields: &'static [&'static str],
353    /// Returned by GET but rejected by PUT — never written to files.
354    pub read_only_fields: &'static [&'static str],
355    /// Paths that may carry key material — validation rejects files where
356    /// these contain anything but identity-based placeholders.
357    pub secret_fields: &'static [&'static str],
358    /// Fields the server accepts on PUT but never returns on GET (redacted).
359    /// Kept in local files, sent on push, excluded from comparisons.
360    pub write_only_fields: &'static [&'static str],
361    /// String fields extracted to Markdown sidecars on pull by default.
362    pub sidecar_fields: &'static [&'static str],
363    /// How this kind references other resources.
364    pub reference_fields: &'static [RefField],
365    /// Fields the service will not change in place — a differing local value
366    /// means an in-place PUT cannot reconcile the documents and the resource
367    /// must be deleted and re-created (`rigg push` shows `replace`).
368    pub immutable_fields: &'static [&'static str],
369    /// OpenAPI `definitions` entry (in this kind's pinned schema fixture)
370    /// whose property names describe this kind's top-level document shape.
371    /// Empty for kinds with no fixture (Foundry data-plane agents).
372    pub schema_definition: &'static str,
373}
374
375const COMMON_VOLATILE: &[&str] = &["@odata.etag", "@odata.context", "e_tag", "etag"];
376
377static KINDS: &[KindMeta] = &[
378    KindMeta {
379        kind: ResourceKind::DataSource,
380        domain: Domain::Search,
381        collection_path: "datasources",
382        dir_name: "data-sources",
383        channel: Channel::Stable,
384        volatile_fields: COMMON_VOLATILE,
385        read_only_fields: &[],
386        secret_fields: &["credentials.connectionString"],
387        write_only_fields: &["credentials.connectionString"],
388        sidecar_fields: &[],
389        reference_fields: &[],
390        immutable_fields: &[],
391        schema_definition: "SearchIndexerDataSource",
392    },
393    KindMeta {
394        kind: ResourceKind::Index,
395        domain: Domain::Search,
396        collection_path: "indexes",
397        dir_name: "indexes",
398        channel: Channel::Stable,
399        volatile_fields: COMMON_VOLATILE,
400        read_only_fields: &[],
401        secret_fields: &[
402            "encryptionKey.accessCredentials.applicationSecret",
403            "vectorSearch.vectorizers[].azureOpenAIParameters.apiKey",
404        ],
405        write_only_fields: &[],
406        sidecar_fields: &[],
407        reference_fields: &[],
408        immutable_fields: &[],
409        schema_definition: "SearchIndex",
410    },
411    KindMeta {
412        kind: ResourceKind::Skillset,
413        domain: Domain::Search,
414        collection_path: "skillsets",
415        dir_name: "skillsets",
416        channel: Channel::Stable,
417        volatile_fields: COMMON_VOLATILE,
418        read_only_fields: &[],
419        secret_fields: &[
420            "cognitiveServices.key",
421            "skills[].apiKey",
422            "encryptionKey.accessCredentials.applicationSecret",
423        ],
424        write_only_fields: &[],
425        sidecar_fields: &[],
426        reference_fields: &[
427            // SearchIndexKnowledgeStore / index projections target the index by name.
428            RefField {
429                path: "knowledgeStore.projections[].objects[].storageContainer",
430                to: ResourceKind::Index,
431            },
432            // Index projections write enriched documents into an index by name.
433            RefField {
434                path: "indexProjections.selectors[].targetIndexName",
435                to: ResourceKind::Index,
436            },
437        ],
438        immutable_fields: &[],
439        schema_definition: "SearchIndexerSkillset",
440    },
441    KindMeta {
442        kind: ResourceKind::Indexer,
443        domain: Domain::Search,
444        collection_path: "indexers",
445        dir_name: "indexers",
446        channel: Channel::Stable,
447        volatile_fields: COMMON_VOLATILE,
448        // GET /indexers('name') never returns status/lastResult/
449        // executionHistory/limits — those live on the separate
450        // /indexers('name')/status resource (SearchIndexerStatus), fetched
451        // by `rigg az indexer status` / `rigg_indexer_status`, never merged
452        // into the indexer document itself.
453        read_only_fields: &[],
454        secret_fields: &[],
455        write_only_fields: &[],
456        sidecar_fields: &[],
457        reference_fields: &[
458            RefField {
459                path: "dataSourceName",
460                to: ResourceKind::DataSource,
461            },
462            RefField {
463                path: "targetIndexName",
464                to: ResourceKind::Index,
465            },
466            RefField {
467                path: "skillsetName",
468                to: ResourceKind::Skillset,
469            },
470        ],
471        immutable_fields: &[],
472        schema_definition: "SearchIndexer",
473    },
474    KindMeta {
475        kind: ResourceKind::SynonymMap,
476        domain: Domain::Search,
477        collection_path: "synonymmaps",
478        dir_name: "synonym-maps",
479        channel: Channel::Stable,
480        volatile_fields: COMMON_VOLATILE,
481        read_only_fields: &[],
482        secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
483        write_only_fields: &[],
484        sidecar_fields: &[],
485        reference_fields: &[],
486        immutable_fields: &[],
487        schema_definition: "SynonymMap",
488    },
489    KindMeta {
490        kind: ResourceKind::Alias,
491        domain: Domain::Search,
492        collection_path: "aliases",
493        dir_name: "aliases",
494        channel: Channel::Stable,
495        volatile_fields: COMMON_VOLATILE,
496        read_only_fields: &[],
497        secret_fields: &[],
498        write_only_fields: &[],
499        sidecar_fields: &[],
500        reference_fields: &[RefField {
501            path: "indexes[]",
502            to: ResourceKind::Index,
503        }],
504        immutable_fields: &[],
505        schema_definition: "SearchAlias",
506    },
507    KindMeta {
508        kind: ResourceKind::KnowledgeSource,
509        domain: Domain::Search,
510        collection_path: "knowledgeSources",
511        dir_name: "knowledge-sources",
512        channel: Channel::Stable,
513        volatile_fields: COMMON_VOLATILE,
514        // Explicit-only model: Rigg never manages Azure-created sub-resources.
515        // `createdResources` is per-kind (nested under the active
516        // `<kind>Parameters` block); list every managed-ingestion kind that
517        // has one. `ingestionPermissionOptions` (preview-only, nested under
518        // `<kind>Parameters.ingestionParameters`) does not exist in the
519        // pinned stable channel this kind uses, so there is nothing to strip.
520        read_only_fields: &[
521            "azureBlobParameters.createdResources",
522            "indexedOneLakeParameters.createdResources",
523        ],
524        // azureBlobParameters.connectionString is credential material for
525        // the managed-ingestion (azureBlob) KS shape: `validate` rejects key
526        // values in it, and `promote` translates it through the environment's
527        // storage binding like any other infrastructure reference.
528        secret_fields: &[
529            "searchIndexParameters.apiKey",
530            "azureBlobParameters.connectionString",
531        ],
532        write_only_fields: &[],
533        sidecar_fields: &[],
534        reference_fields: &[RefField {
535            path: "searchIndexParameters.searchIndexName",
536            to: ResourceKind::Index,
537        }],
538        // A knowledge source's kind (azureBlob, searchIndex, ...) cannot be
539        // changed by PUT — push replaces (delete + recreate) instead.
540        immutable_fields: &["kind"],
541        schema_definition: "KnowledgeSource",
542    },
543    KindMeta {
544        kind: ResourceKind::KnowledgeBase,
545        domain: Domain::Search,
546        collection_path: "knowledgeBases",
547        dir_name: "knowledge-bases",
548        // Preview: the retrieval & output configuration
549        // (retrievalInstructions, answerInstructions, outputMode,
550        // retrievalReasoningEffort, per-source serving flags) does not
551        // exist in the stable api-version — a stable GET silently omits it
552        // and a stable PUT cannot set it.
553        channel: Channel::Preview,
554        volatile_fields: COMMON_VOLATILE,
555        read_only_fields: &[],
556        secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
557        write_only_fields: &[],
558        sidecar_fields: &[],
559        reference_fields: &[RefField {
560            path: "knowledgeSources[].name",
561            to: ResourceKind::KnowledgeSource,
562        }],
563        immutable_fields: &[],
564        schema_definition: "KnowledgeBase",
565    },
566    KindMeta {
567        kind: ResourceKind::Agent,
568        domain: Domain::FoundryData,
569        collection_path: "agents",
570        dir_name: "agents",
571        channel: Channel::Stable,
572        volatile_fields: &[
573            "@odata.etag",
574            "@odata.context",
575            "id",
576            "object",
577            "created_at",
578            "updated_at",
579            "version",
580            "metadata.modified_at",
581        ],
582        read_only_fields: &[],
583        secret_fields: &[],
584        write_only_fields: &[],
585        sidecar_fields: &["instructions"],
586        reference_fields: &[
587            RefField {
588                path: "model",
589                to: ResourceKind::Deployment,
590            },
591            RefField {
592                path: "tools[].project_connection_id",
593                to: ResourceKind::Connection,
594            },
595        ],
596        immutable_fields: &[],
597        schema_definition: "",
598    },
599    KindMeta {
600        kind: ResourceKind::Deployment,
601        domain: Domain::FoundryArm,
602        collection_path: "deployments",
603        dir_name: "deployments",
604        channel: Channel::Stable,
605        volatile_fields: &[
606            "id",
607            "type",
608            "systemData",
609            "etag",
610            "properties.provisioningState",
611            "properties.capabilities",
612            "properties.rateLimits",
613            "properties.model.callRateLimit",
614            "properties.currentCapacity",
615            "properties.deploymentState",
616        ],
617        read_only_fields: &[],
618        secret_fields: &[],
619        write_only_fields: &[],
620        sidecar_fields: &[],
621        reference_fields: &[RefField {
622            path: "properties.raiPolicyName",
623            to: ResourceKind::Guardrail,
624        }],
625        immutable_fields: &[],
626        schema_definition: "Deployment",
627    },
628    KindMeta {
629        kind: ResourceKind::Connection,
630        domain: Domain::FoundryArm,
631        collection_path: "connections",
632        dir_name: "connections",
633        channel: Channel::Stable,
634        volatile_fields: &[
635            "id",
636            "type",
637            "systemData",
638            "etag",
639            "properties.provisioningState",
640        ],
641        read_only_fields: &[],
642        // Identity-based auth only — any credential payload is rejected.
643        secret_fields: &[
644            "properties.credentials.key",
645            "properties.credentials.keys",
646            "properties.credentials.secret",
647            "properties.credentials.clientSecret",
648            "properties.credentials.pat",
649            "properties.credentials.sas",
650        ],
651        write_only_fields: &[],
652        sidecar_fields: &[],
653        reference_fields: &[],
654        immutable_fields: &[],
655        schema_definition: "ConnectionPropertiesV2",
656    },
657    KindMeta {
658        kind: ResourceKind::Guardrail,
659        domain: Domain::FoundryArm,
660        collection_path: "raiPolicies",
661        dir_name: "guardrails",
662        channel: Channel::Stable,
663        volatile_fields: &["id", "type", "systemData", "etag"],
664        read_only_fields: &[],
665        secret_fields: &[],
666        write_only_fields: &[],
667        sidecar_fields: &[],
668        reference_fields: &[],
669        immutable_fields: &[],
670        schema_definition: "RaiPolicy",
671    },
672];
673
674/// All kinds, in push-friendly declaration order.
675pub fn all_kinds() -> &'static [ResourceKind] {
676    static ORDER: &[ResourceKind] = &[
677        ResourceKind::DataSource,
678        ResourceKind::Index,
679        ResourceKind::Skillset,
680        ResourceKind::Indexer,
681        ResourceKind::SynonymMap,
682        ResourceKind::Alias,
683        ResourceKind::KnowledgeSource,
684        ResourceKind::KnowledgeBase,
685        ResourceKind::Agent,
686        ResourceKind::Deployment,
687        ResourceKind::Connection,
688        ResourceKind::Guardrail,
689    ];
690    ORDER
691}
692
693/// Metadata for a kind. Total over all kinds.
694pub fn meta(kind: ResourceKind) -> &'static KindMeta {
695    KINDS
696        .iter()
697        .find(|m| m.kind == kind)
698        .expect("registry entry exists for every ResourceKind")
699}
700
701/// The shape an [`InfraRef`] value takes — how to recognize the physical
702/// Azure resource it names, and how to rewrite it for a different
703/// environment. See `infra::parse`/`infra::render` for the per-form rules.
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub enum InfraForm {
706    /// `ResourceId=/subscriptions/…/storageAccounts/X;…` → storage.
707    StorageResourceId,
708    /// `{ "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
709    ///    "userAssignedIdentity": "/subscriptions/…/userAssignedIdentities/X" }`
710    /// → identity (null means system-assigned).
711    UserAssignedIdentity,
712    /// `https://X.openai.azure.com`, `https://X.cognitiveservices.azure.com`,
713    /// `https://X.services.ai.azure.com` → a model host (ai-services, or the
714    /// implicit `foundry` target).
715    OpenAiEndpoint,
716    /// Same hosts as [`InfraForm::OpenAiEndpoint`], but always ai-services
717    /// (used by `KnowledgeSource.azureBlobParameters.ingestionParameters.aiServices.uri`).
718    AiServicesSubdomain,
719    /// `https://X.azurewebsites.net/…` → function-app; any other
720    /// `http(s)://HOST/…` → an external `api` binding (prefix match).
721    ApiUri,
722    /// `https://X.vault.azure.net/…` → key-vault.
723    KeyVaultUri,
724    /// `https://X.search.windows.net/knowledgebases/<kb>/mcp?…` → the
725    /// implicit `search` target, plus a sibling reference to
726    /// `knowledge-bases/<kb>`.
727    SearchKbMcpUrl,
728    /// A composite endpoint field that may hold any endpoint shape — a
729    /// connection's `properties.target`, an agent tool's `server_url`.
730    /// Recognized in order: the [`InfraForm::SearchKbMcpUrl`] shape, an
731    /// OpenAI / AI-services host ([`InfraForm::OpenAiEndpoint`]), a bare
732    /// `https://X.search.windows.net[/…]` endpoint (the implicit `search`
733    /// target, with no knowledge base), then the [`InfraForm::ApiUri`]
734    /// rules (function app, else external `api`).
735    Endpoint,
736}
737
738impl InfraForm {
739    /// The environment binding type a value of this form binds to, as the
740    /// `dependencies:` key that declares it in `rigg.yaml`.
741    ///
742    /// This is the [`crate::binding::BindingType`] `infra::binding_type_for`
743    /// returns for the [`crate::infra::Target`] this form parses to, spelled
744    /// out for documentation. Composite forms — the ones whose target
745    /// depends on the value's host — list every type they can yield, in the
746    /// order `infra::parse` tries them. `search` is the implicit
747    /// environment target rather than a declared dependency.
748    pub fn binding_type_label(&self) -> &'static str {
749        match self {
750            InfraForm::StorageResourceId => "storage",
751            InfraForm::UserAssignedIdentity => "identity",
752            InfraForm::OpenAiEndpoint | InfraForm::AiServicesSubdomain => "ai-services",
753            InfraForm::ApiUri => "function-app or api",
754            InfraForm::KeyVaultUri => "key-vault",
755            InfraForm::SearchKbMcpUrl => "search",
756            InfraForm::Endpoint => "search, ai-services, function-app or api",
757        }
758    }
759}
760
761/// An infrastructure-reference field: `path` (registry path syntax, `a.b[].c`)
762/// addresses a value that names supporting Azure infrastructure rather than
763/// another rigg-managed resource. `only_odata_type`, when set, restricts the
764/// rule to array elements whose `@odata.type` ends with the given string's
765/// last dot-segment (e.g. `"#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill"`
766/// matches any `...AzureOpenAIEmbeddingSkill` namespace).
767#[derive(Debug, Clone, Copy)]
768pub struct InfraRef {
769    pub path: &'static str,
770    pub form: InfraForm,
771    pub only_odata_type: Option<&'static str>,
772}
773
774static DATA_SOURCE_INFRA: &[InfraRef] = &[
775    InfraRef {
776        path: "credentials.connectionString",
777        form: InfraForm::StorageResourceId,
778        only_odata_type: None,
779    },
780    InfraRef {
781        path: "identity",
782        form: InfraForm::UserAssignedIdentity,
783        only_odata_type: None,
784    },
785    InfraRef {
786        path: "encryptionKey.keyVaultUri",
787        form: InfraForm::KeyVaultUri,
788        only_odata_type: None,
789    },
790    InfraRef {
791        path: "encryptionKey.identity",
792        form: InfraForm::UserAssignedIdentity,
793        only_odata_type: None,
794    },
795];
796
797static INDEX_INFRA: &[InfraRef] = &[
798    InfraRef {
799        path: "vectorSearch.vectorizers[].azureOpenAIParameters.resourceUri",
800        form: InfraForm::OpenAiEndpoint,
801        only_odata_type: None,
802    },
803    InfraRef {
804        path: "vectorSearch.vectorizers[].azureOpenAIParameters.authIdentity",
805        form: InfraForm::UserAssignedIdentity,
806        only_odata_type: None,
807    },
808    InfraRef {
809        path: "encryptionKey.keyVaultUri",
810        form: InfraForm::KeyVaultUri,
811        only_odata_type: None,
812    },
813    InfraRef {
814        path: "encryptionKey.identity",
815        form: InfraForm::UserAssignedIdentity,
816        only_odata_type: None,
817    },
818];
819
820static SKILLSET_INFRA: &[InfraRef] = &[
821    InfraRef {
822        path: "skills[].resourceUri",
823        form: InfraForm::OpenAiEndpoint,
824        only_odata_type: Some("#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill"),
825    },
826    InfraRef {
827        path: "skills[].authIdentity",
828        form: InfraForm::UserAssignedIdentity,
829        only_odata_type: None,
830    },
831    InfraRef {
832        path: "skills[].uri",
833        form: InfraForm::ApiUri,
834        only_odata_type: Some("#Microsoft.Skills.Custom.WebApiSkill"),
835    },
836    InfraRef {
837        path: "cognitiveServices.subdomainUrl",
838        form: InfraForm::AiServicesSubdomain,
839        only_odata_type: None,
840    },
841    InfraRef {
842        path: "cognitiveServices.identity",
843        form: InfraForm::UserAssignedIdentity,
844        only_odata_type: None,
845    },
846    InfraRef {
847        path: "knowledgeStore.storageConnectionString",
848        form: InfraForm::StorageResourceId,
849        only_odata_type: None,
850    },
851    InfraRef {
852        path: "knowledgeStore.identity",
853        form: InfraForm::UserAssignedIdentity,
854        only_odata_type: None,
855    },
856    InfraRef {
857        path: "encryptionKey.keyVaultUri",
858        form: InfraForm::KeyVaultUri,
859        only_odata_type: None,
860    },
861    InfraRef {
862        path: "encryptionKey.identity",
863        form: InfraForm::UserAssignedIdentity,
864        only_odata_type: None,
865    },
866];
867
868// The indexer's incremental-enrichment cache (`cache.storageConnectionString`,
869// `cache.identity`) is preview-only and out of scope for rigg 2.0 — it is
870// deliberately not modelled here.
871static INDEXER_INFRA: &[InfraRef] = &[
872    InfraRef {
873        path: "encryptionKey.keyVaultUri",
874        form: InfraForm::KeyVaultUri,
875        only_odata_type: None,
876    },
877    InfraRef {
878        path: "encryptionKey.identity",
879        form: InfraForm::UserAssignedIdentity,
880        only_odata_type: None,
881    },
882];
883
884static KNOWLEDGE_SOURCE_INFRA: &[InfraRef] = &[
885    InfraRef {
886        path: "azureBlobParameters.connectionString",
887        form: InfraForm::StorageResourceId,
888        only_odata_type: None,
889    },
890    InfraRef {
891        path: "azureBlobParameters.ingestionParameters.identity",
892        form: InfraForm::UserAssignedIdentity,
893        only_odata_type: None,
894    },
895    InfraRef {
896        path: "azureBlobParameters.ingestionParameters.embeddingModel.azureOpenAIParameters.resourceUri",
897        form: InfraForm::OpenAiEndpoint,
898        only_odata_type: None,
899    },
900    InfraRef {
901        path: "azureBlobParameters.ingestionParameters.embeddingModel.azureOpenAIParameters.authIdentity",
902        form: InfraForm::UserAssignedIdentity,
903        only_odata_type: None,
904    },
905    InfraRef {
906        path: "azureBlobParameters.ingestionParameters.chatCompletionModel.azureOpenAIParameters.resourceUri",
907        form: InfraForm::OpenAiEndpoint,
908        only_odata_type: None,
909    },
910    InfraRef {
911        path: "azureBlobParameters.ingestionParameters.chatCompletionModel.azureOpenAIParameters.authIdentity",
912        form: InfraForm::UserAssignedIdentity,
913        only_odata_type: None,
914    },
915    InfraRef {
916        path: "azureBlobParameters.ingestionParameters.aiServices.uri",
917        form: InfraForm::AiServicesSubdomain,
918        only_odata_type: None,
919    },
920    InfraRef {
921        path: "azureBlobParameters.ingestionParameters.assetStore.connectionString",
922        form: InfraForm::StorageResourceId,
923        only_odata_type: None,
924    },
925    InfraRef {
926        path: "encryptionKey.keyVaultUri",
927        form: InfraForm::KeyVaultUri,
928        only_odata_type: None,
929    },
930    InfraRef {
931        path: "encryptionKey.identity",
932        form: InfraForm::UserAssignedIdentity,
933        only_odata_type: None,
934    },
935];
936
937static KNOWLEDGE_BASE_INFRA: &[InfraRef] = &[
938    InfraRef {
939        path: "models[].azureOpenAIParameters.resourceUri",
940        form: InfraForm::OpenAiEndpoint,
941        only_odata_type: None,
942    },
943    InfraRef {
944        path: "models[].azureOpenAIParameters.authIdentity",
945        form: InfraForm::UserAssignedIdentity,
946        only_odata_type: None,
947    },
948    InfraRef {
949        path: "encryptionKey.keyVaultUri",
950        form: InfraForm::KeyVaultUri,
951        only_odata_type: None,
952    },
953    InfraRef {
954        path: "encryptionKey.identity",
955        form: InfraForm::UserAssignedIdentity,
956        only_odata_type: None,
957    },
958];
959
960// An agent tool's `server_url` and a connection's `properties.target` are
961// not knowledge-base MCP URLs only: they hold whatever endpoint the tool or
962// connection category calls for (a search service, a model host, a function,
963// an external API). `Endpoint` recognizes each shape, so all of them are
964// visible to translation, `validate` and push's preflight.
965static AGENT_INFRA: &[InfraRef] = &[InfraRef {
966    path: "tools[].server_url",
967    form: InfraForm::Endpoint,
968    only_odata_type: None,
969}];
970
971static CONNECTION_INFRA: &[InfraRef] = &[InfraRef {
972    path: "properties.target",
973    form: InfraForm::Endpoint,
974    only_odata_type: None,
975}];
976
977/// Infrastructure-reference fields for `kind`, per spec §2.3. Empty for
978/// kinds with no infrastructure references (Deployment, Guardrail,
979/// SynonymMap, Alias).
980pub fn infra_refs(kind: ResourceKind) -> &'static [InfraRef] {
981    match kind {
982        ResourceKind::DataSource => DATA_SOURCE_INFRA,
983        ResourceKind::Index => INDEX_INFRA,
984        ResourceKind::Skillset => SKILLSET_INFRA,
985        ResourceKind::Indexer => INDEXER_INFRA,
986        ResourceKind::SynonymMap => &[],
987        ResourceKind::Alias => &[],
988        ResourceKind::KnowledgeSource => KNOWLEDGE_SOURCE_INFRA,
989        ResourceKind::KnowledgeBase => KNOWLEDGE_BASE_INFRA,
990        ResourceKind::Agent => AGENT_INFRA,
991        ResourceKind::Deployment => &[],
992        ResourceKind::Connection => CONNECTION_INFRA,
993        ResourceKind::Guardrail => &[],
994    }
995}
996
997/// Valid `type` strings for Azure AI Search data sources per channel.
998///
999/// rigg supports Azure Blob Storage only (including ADLS Gen2, which is the
1000/// same data source type with hierarchical namespace enabled).
1001pub fn valid_datasource_types(_channel: Channel) -> &'static [&'static str] {
1002    &["azureblob", "adlsgen2"]
1003}
1004
1005/// The key used by Rigg-local cross-service references
1006/// (e.g. an agent tool pointing at a knowledge base by name).
1007pub const X_RIGG_REF: &str = "x-rigg-ref";
1008/// The key linking a WebApiSkill to an OpenAPI spec in `apis/`.
1009pub const X_RIGG_API: &str = "x-rigg-api";
1010/// Per-resource annotation (array of dot-paths) in a TARGET env's file naming
1011/// additional fields `rigg promote` should keep pinned to that env's current
1012/// value, beyond the kind's registry defaults. Lives alongside other
1013/// `x-rigg-*` keys: kept on disk, stripped before any PUT/POST.
1014pub const X_RIGG_PIN: &str = "x-rigg-pin";
1015/// Per-skill annotation on a WebApiSkill recording how the search service
1016/// authenticates to the function it calls (`function-key` when a key in the
1017/// URI or an `x-functions-key` header is the carrier). Environment-specific:
1018/// it authorizes ONE environment's function app and never crosses a promote.
1019pub const X_RIGG_AUTH: &str = "x-rigg-auth";
1020/// The [`X_RIGG_AUTH`] value meaning "a function key is the carrier": the
1021/// key itself lives in ARM, never on disk.
1022pub const X_RIGG_AUTH_FUNCTION_KEY: &str = "function-key";
1023/// Prefix of the [`X_RIGG_AUTH`] value naming an Azure Key Vault secret as
1024/// the key source: `key-vault:<secret-name>@<key-vault binding>` (spec
1025/// `2026-09-09-identity-and-auth-design.md` §6). The secret's value is read
1026/// at push time and placed in the outgoing body only — never on disk.
1027pub const X_RIGG_AUTH_KEY_VAULT_PREFIX: &str = "key-vault:";
1028
1029/// Split a `key-vault:<secret>@<binding>` [`X_RIGG_AUTH`] value into its
1030/// secret name and key-vault binding name. `None` for any other value, or
1031/// when either half is empty.
1032///
1033/// The secret name is everything up to the LAST `@`, so a binding name is
1034/// unambiguous even though Key Vault secret names cannot contain `@`.
1035pub fn parse_key_vault_auth(value: &str) -> Option<(&str, &str)> {
1036    let rest = value.strip_prefix(X_RIGG_AUTH_KEY_VAULT_PREFIX)?;
1037    let (secret, binding) = rest.rsplit_once('@')?;
1038    let (secret, binding) = (secret.trim(), binding.trim());
1039    (!secret.is_empty() && !binding.is_empty()).then_some((secret, binding))
1040}
1041
1042/// Whether an [`X_RIGG_AUTH`] value is a key source rigg knows how to
1043/// resolve at push time.
1044pub fn is_known_auth_annotation(value: &str) -> bool {
1045    value == X_RIGG_AUTH_FUNCTION_KEY || parse_key_vault_auth(value).is_some()
1046}
1047
1048/// Mutable counterpart of [`collect_path`]: visit every value at `path`.
1049fn collect_path_mut(v: &mut Value, path: &str, f: &mut dyn FnMut(&mut Value)) {
1050    fn walk(v: &mut Value, segments: &[&str], f: &mut dyn FnMut(&mut Value)) {
1051        let Some((head, rest)) = segments.split_first() else {
1052            f(v);
1053            return;
1054        };
1055        if let Some(key) = head.strip_suffix("[]") {
1056            let target = if key.is_empty() {
1057                Some(v)
1058            } else {
1059                v.get_mut(key)
1060            };
1061            if let Some(Value::Array(arr)) = target {
1062                for item in arr {
1063                    walk(item, rest, f);
1064                }
1065            }
1066        } else if let Some(next) = v.get_mut(*head) {
1067            walk(next, rest, f);
1068        }
1069    }
1070    let segments: Vec<&str> = path.split('.').collect();
1071    walk(v, &segments, f);
1072}
1073
1074/// Rewrite reference values in `body`: every `reference_fields` path of
1075/// `kind` that points at `to` and currently equals `old` is set to `new`.
1076/// Registry-driven so renames (e.g. side-by-side migration) follow the same
1077/// table as graph ordering — a reference the graph can see is a reference a
1078/// rename will rewrite.
1079pub fn rename_reference(
1080    kind: ResourceKind,
1081    body: &mut Value,
1082    to: ResourceKind,
1083    old: &str,
1084    new: &str,
1085) {
1086    for rf in meta(kind).reference_fields {
1087        if rf.to != to {
1088            continue;
1089        }
1090        collect_path_mut(body, rf.path, &mut |v| {
1091            if v.as_str() == Some(old) {
1092                *v = Value::String(new.to_string());
1093            }
1094        });
1095    }
1096}
1097
1098/// Rewrite `x-rigg-ref` annotations: every `x-rigg-ref` value (at any depth)
1099/// equal to `"<dir_name>/<old>"` becomes `"<dir_name>/<new>"`. The
1100/// annotation counterpart of [`rename_reference`] — `rigg promote` uses both
1101/// to follow a sibling that is physically named differently in the target
1102/// environment.
1103pub fn rename_x_rigg_ref(body: &mut Value, dir_name: &str, old: &str, new: &str) {
1104    fn walk(v: &mut Value, from: &str, to: &str) {
1105        match v {
1106            Value::Object(map) => {
1107                for (k, val) in map.iter_mut() {
1108                    if k == X_RIGG_REF {
1109                        if val.as_str() == Some(from) {
1110                            *val = Value::String(to.to_string());
1111                        }
1112                    } else {
1113                        walk(val, from, to);
1114                    }
1115                }
1116            }
1117            Value::Array(arr) => {
1118                for item in arr {
1119                    walk(item, from, to);
1120                }
1121            }
1122            _ => {}
1123        }
1124    }
1125    walk(
1126        body,
1127        &format!("{dir_name}/{old}"),
1128        &format!("{dir_name}/{new}"),
1129    );
1130}
1131
1132/// Extract all references from `body` per the kind's `reference_fields`,
1133/// plus any `x-rigg-ref` values (`"<dir-name>/<name>"`) found at any depth.
1134pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
1135    let mut out = Vec::new();
1136    for rf in meta(kind).reference_fields {
1137        collect_path(body, rf.path, &mut |v| {
1138            if let Some(s) = v.as_str()
1139                && !s.is_empty()
1140            {
1141                out.push((rf.to, s.to_string()));
1142            }
1143        });
1144    }
1145    collect_x_rigg_refs(body, &mut out);
1146    if kind == ResourceKind::Agent {
1147        collect_portal_agent_refs(body, &mut out);
1148    }
1149    out.sort();
1150    out.dedup();
1151    out
1152}
1153
1154/// Portal-authored agents reference Search knowledge bases by raw MCP URL
1155/// (`https://<svc>.search.windows.net/knowledgebases/<name>/mcp?...`) rather
1156/// than an `x-rigg-ref` annotation. Recognize the shape so dependency
1157/// expansion can cross the service boundary.
1158fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
1159    match v {
1160        Value::Object(map) => {
1161            if let Some(url) = map.get("server_url").and_then(Value::as_str)
1162                && let Some(kb) = parse_kb_mcp_url(url)
1163            {
1164                out.push((ResourceKind::KnowledgeBase, kb));
1165            }
1166            for val in map.values() {
1167                collect_portal_agent_refs(val, out);
1168            }
1169        }
1170        Value::Array(arr) => {
1171            for item in arr {
1172                collect_portal_agent_refs(item, out);
1173            }
1174        }
1175        _ => {}
1176    }
1177}
1178
1179/// `https://<host>.search.windows.net/knowledgebases/<name>/mcp[?...]` → name.
1180fn parse_kb_mcp_url(url: &str) -> Option<String> {
1181    let rest = url.strip_prefix("https://")?;
1182    let (host, path) = rest.split_once('/')?;
1183    if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
1184        return None;
1185    }
1186    let path = path.split('?').next().unwrap_or(path);
1187    let mut segs = path.split('/').filter(|s| !s.is_empty());
1188    let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
1189    (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
1190        .then(|| name.to_string())
1191}
1192
1193/// Platform-provided resource instances (e.g. Microsoft's built-in guardrail
1194/// policies) cannot be modified or deleted by the user. They are excluded
1195/// from adoption and from "unmanaged" reporting: local files should only
1196/// track configuration the user actually controls. References to them (e.g.
1197/// a deployment's `raiPolicyName`) live in the referencing resource's file.
1198pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
1199    match kind {
1200        ResourceKind::Guardrail => {
1201            let system = body
1202                .pointer("/properties/type")
1203                .and_then(Value::as_str)
1204                .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
1205                .unwrap_or(false);
1206            // Name-prefix fallback for docs that omit properties.type.
1207            let name = body.get("name").and_then(Value::as_str).unwrap_or("");
1208            system || name.starts_with("Microsoft.")
1209        }
1210        _ => false,
1211    }
1212}
1213
1214/// Managed-ingestion knowledge sources auto-create their backing pipeline
1215/// (index, indexer, data source, skillset); Azure names them in the KS's
1216/// `createdResources`. Rigg never manages these sub-resources — the knowledge
1217/// source definition is their source of truth — so they are excluded from
1218/// adoption and unmanaged reporting. Returns resource key → creating KS name.
1219pub fn auto_created_by(
1220    snapshot: &[(ResourceRef, Value)],
1221) -> std::collections::BTreeMap<String, String> {
1222    let mut out = std::collections::BTreeMap::new();
1223    for (r, doc) in snapshot {
1224        if r.kind != ResourceKind::KnowledgeSource {
1225            continue;
1226        }
1227        collect_created_resources(doc, &r.name, &mut out);
1228    }
1229    out
1230}
1231
1232fn collect_created_resources(
1233    v: &Value,
1234    ks_name: &str,
1235    out: &mut std::collections::BTreeMap<String, String>,
1236) {
1237    if let Value::Object(map) = v {
1238        if let Some(Value::Object(created)) = map.get("createdResources") {
1239            for (member, name) in created {
1240                let kind = match member.as_str() {
1241                    "datasource" => Some(ResourceKind::DataSource),
1242                    "indexer" => Some(ResourceKind::Indexer),
1243                    "skillset" => Some(ResourceKind::Skillset),
1244                    "index" => Some(ResourceKind::Index),
1245                    _ => None, // future member names: ignore
1246                };
1247                if let (Some(kind), Some(name)) = (kind, name.as_str()) {
1248                    out.insert(
1249                        ResourceRef::new(kind, name.to_string()).key(),
1250                        ks_name.to_string(),
1251                    );
1252                }
1253            }
1254        }
1255        for val in map.values() {
1256            collect_created_resources(val, ks_name, out);
1257        }
1258    } else if let Value::Array(arr) = v {
1259        for item in arr {
1260            collect_created_resources(item, ks_name, out);
1261        }
1262    }
1263}
1264
1265/// Immutable fields whose local and remote values differ — a non-empty
1266/// result means an in-place PUT cannot reconcile the two documents and the
1267/// resource must be replaced (delete + recreate). Returns
1268/// `(path, remote value, local value)` per differing field. A value missing
1269/// on one side counts as a difference when the other side has one.
1270pub fn immutable_diff(
1271    kind: ResourceKind,
1272    local: &Value,
1273    remote: &Value,
1274) -> Vec<(&'static str, String, String)> {
1275    fn values_at(doc: &Value, path: &str) -> Vec<Value> {
1276        let mut vals = Vec::new();
1277        collect_path(doc, path, &mut |v| vals.push(v.clone()));
1278        vals
1279    }
1280    fn show(vals: &[Value]) -> String {
1281        vals.iter()
1282            .map(|v| {
1283                v.as_str()
1284                    .map(str::to_string)
1285                    .unwrap_or_else(|| v.to_string())
1286            })
1287            .collect::<Vec<_>>()
1288            .join(",")
1289    }
1290    let mut out = Vec::new();
1291    for path in meta(kind).immutable_fields {
1292        let l = values_at(local, path);
1293        let r = values_at(remote, path);
1294        if l != r {
1295            out.push((*path, show(&r), show(&l)));
1296        }
1297    }
1298    out
1299}
1300
1301fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
1302    match v {
1303        Value::Object(map) => {
1304            for (k, val) in map {
1305                if k == X_RIGG_REF {
1306                    if let Some(s) = val.as_str()
1307                        && let Some((dir, name)) = s.split_once('/')
1308                        && let Some(kind) = ResourceKind::from_directory_name(dir)
1309                    {
1310                        out.push((kind, name.to_string()));
1311                    }
1312                } else {
1313                    collect_x_rigg_refs(val, out);
1314                }
1315            }
1316        }
1317        Value::Array(arr) => {
1318            for item in arr {
1319                collect_x_rigg_refs(item, out);
1320            }
1321        }
1322        _ => {}
1323    }
1324}
1325
1326/// Walk a registry path (`a.b`, `arr[].field`, `arr[]`) and invoke `f` on each
1327/// matched terminal value.
1328pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
1329    fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
1330        let Some((head, rest)) = segments.split_first() else {
1331            f(v);
1332            return;
1333        };
1334        if let Some(key) = head.strip_suffix("[]") {
1335            let target = if key.is_empty() { Some(v) } else { v.get(key) };
1336            if let Some(Value::Array(arr)) = target {
1337                for item in arr {
1338                    walk(item, rest, f);
1339                }
1340            }
1341        } else if let Some(next) = v.get(*head) {
1342            walk(next, rest, f);
1343        }
1344    }
1345    let segments: Vec<&str> = path.split('.').collect();
1346    walk(v, &segments, f);
1347}
1348
1349/// Restore `dst`'s value(s) at `path` from the corresponding value(s) in
1350/// `src` at the SAME path — the SET counterpart to [`collect_path`], used to
1351/// apply `rigg promote`'s pinned fields (keep the target's value at pinned
1352/// paths: `dst` is the merged/source-cloned doc, `src` is the target env's
1353/// current doc). Mirrors `collect_path`'s traversal (`a.b`, `arr[].field`,
1354/// `arr[]`).
1355///
1356/// For `[]` segments, `dst` and `src` arrays are paired by POSITION (index),
1357/// not by an identity key — pinned paths (e.g. an agent's tool list) may have
1358/// no stable name to match on. When the arrays differ in length:
1359///
1360/// - `src` (the target) longer: the extra elements are appended to `dst`
1361///   WHOLESALE — they are target-only customizations (e.g. an extra tool
1362///   only prod has) and must survive promote; dropping them would be silent
1363///   data loss.
1364/// - `dst` (the merged/source side) longer: its extra elements are left
1365///   as-is — they come from the source (that IS the promotion) and there is
1366///   nothing on the target side to pin from.
1367///
1368/// Missing intermediate objects in `dst` are created (mirroring how the
1369/// value is nested in `src`); when `src` doesn't have a value at some point
1370/// along the path, that position in `dst` is left untouched.
1371pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
1372    let segments: Vec<&str> = path.split('.').collect();
1373    restore_path_walk(dst, src, &segments);
1374}
1375
1376fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
1377    let Some((head, rest)) = segments.split_first() else {
1378        *dst = src.clone();
1379        return;
1380    };
1381    if let Some(key) = head.strip_suffix("[]") {
1382        if key.is_empty() {
1383            pair_arrays(dst, src, rest);
1384        } else {
1385            let Value::Object(src_map) = src else { return };
1386            let Some(src_val) = src_map.get(key) else {
1387                return;
1388            };
1389            let Value::Object(dst_map) = dst else { return };
1390            let entry = dst_map
1391                .entry(key.to_string())
1392                .or_insert_with(|| Value::Array(Vec::new()));
1393            pair_arrays(entry, src_val, rest);
1394        }
1395    } else {
1396        let Value::Object(src_map) = src else { return };
1397        let Some(src_val) = src_map.get(*head) else {
1398            return;
1399        };
1400        let Value::Object(dst_map) = dst else { return };
1401        if rest.is_empty() {
1402            // Leaf: assign directly rather than inserting a placeholder and
1403            // recursing — an inserted `Null` wouldn't be an `Object` yet if
1404            // some OTHER path later needed to nest under this same key.
1405            dst_map.insert((*head).to_string(), src_val.clone());
1406        } else {
1407            let entry = dst_map
1408                .entry((*head).to_string())
1409                .or_insert_with(|| Value::Object(serde_json::Map::new()));
1410            restore_path_walk(entry, src_val, rest);
1411        }
1412    }
1413}
1414
1415/// Pair `dst`/`src` arrays by index and recurse `rest` into each matched
1416/// pair; `src`-only elements (the target env's extra array members) are then
1417/// appended to `dst` wholesale so promote never drops them. Idempotent
1418/// across multiple pinned paths through the same array: after the first
1419/// restore appends the extras, later paths find equal lengths and simply
1420/// re-pair.
1421fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
1422    let (Value::Array(d), Value::Array(s)) = (dst, src) else {
1423        return;
1424    };
1425    let n = d.len().min(s.len());
1426    for i in 0..n {
1427        restore_path_walk(&mut d[i], &s[i], rest);
1428    }
1429    if s.len() > d.len() {
1430        d.extend(s[n..].iter().cloned());
1431    }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437    use serde_json::json;
1438
1439    #[test]
1440    fn registry_paths_exist_in_the_pinned_schema() {
1441        use crate::schema::fixture_for;
1442        for kind in ResourceKind::search_kinds() {
1443            let m = meta(kind);
1444            let f = fixture_for(kind);
1445            let props = f
1446                .definition(m.schema_definition)
1447                .expect(m.schema_definition);
1448            let infra: Vec<&'static str> = infra_refs(kind).iter().map(|r| r.path).collect();
1449            for path in m
1450                .volatile_fields
1451                .iter()
1452                .chain(m.read_only_fields)
1453                .chain(m.secret_fields)
1454                .chain(m.write_only_fields)
1455                .chain(m.immutable_fields)
1456                .chain(m.reference_fields.iter().map(|r| &r.path))
1457                .chain(infra.iter())
1458            {
1459                let head = path.split('.').next().unwrap().trim_end_matches("[]");
1460                if head.starts_with("@odata") || head == "etag" || head == "e_tag" {
1461                    continue;
1462                }
1463                assert!(
1464                    props.contains(head),
1465                    "{kind:?}: `{path}` not in {} ({})",
1466                    m.schema_definition,
1467                    f.version
1468                );
1469            }
1470        }
1471    }
1472
1473    #[test]
1474    fn key_vault_auth_annotations_parse_and_are_recognized() {
1475        assert_eq!(
1476            parse_key_vault_auth("key-vault:fn-key@secrets"),
1477            Some(("fn-key", "secrets"))
1478        );
1479        // The LAST `@` separates the halves, so a secret name that somehow
1480        // carries one still resolves to the right binding.
1481        assert_eq!(
1482            parse_key_vault_auth("key-vault:a@b@vault"),
1483            Some(("a@b", "vault"))
1484        );
1485        assert_eq!(parse_key_vault_auth("key-vault:@vault"), None);
1486        assert_eq!(parse_key_vault_auth("key-vault:secret@"), None);
1487        assert_eq!(parse_key_vault_auth("key-vault:secret"), None);
1488        assert_eq!(parse_key_vault_auth(X_RIGG_AUTH_FUNCTION_KEY), None);
1489
1490        assert!(is_known_auth_annotation(X_RIGG_AUTH_FUNCTION_KEY));
1491        assert!(is_known_auth_annotation("key-vault:fn-key@secrets"));
1492        assert!(!is_known_auth_annotation("managed-identity"));
1493        assert!(!is_known_auth_annotation(""));
1494    }
1495
1496    #[test]
1497    fn infra_ref_table_matches_the_spec() {
1498        let ks: Vec<&str> = infra_refs(ResourceKind::KnowledgeSource)
1499            .iter()
1500            .map(|r| r.path)
1501            .collect();
1502        for p in [
1503            "azureBlobParameters.connectionString",
1504            "azureBlobParameters.ingestionParameters.identity",
1505            "azureBlobParameters.ingestionParameters.embeddingModel.azureOpenAIParameters.resourceUri",
1506            "azureBlobParameters.ingestionParameters.chatCompletionModel.azureOpenAIParameters.resourceUri",
1507            "azureBlobParameters.ingestionParameters.aiServices.uri",
1508            "azureBlobParameters.ingestionParameters.assetStore.connectionString",
1509            "encryptionKey.keyVaultUri",
1510        ] {
1511            assert!(ks.contains(&p), "missing {p}");
1512        }
1513        assert!(infra_refs(ResourceKind::Deployment).is_empty());
1514        assert_eq!(
1515            infra_refs(ResourceKind::Agent)[0].path,
1516            "tools[].server_url"
1517        );
1518
1519        let index: Vec<&str> = infra_refs(ResourceKind::Index)
1520            .iter()
1521            .map(|r| r.path)
1522            .collect();
1523        assert!(index.contains(&"vectorSearch.vectorizers[].azureOpenAIParameters.resourceUri"));
1524
1525        let skillset: Vec<&str> = infra_refs(ResourceKind::Skillset)
1526            .iter()
1527            .map(|r| r.path)
1528            .collect();
1529        assert!(skillset.contains(&"skills[].uri"));
1530
1531        let kb: Vec<&str> = infra_refs(ResourceKind::KnowledgeBase)
1532            .iter()
1533            .map(|r| r.path)
1534            .collect();
1535        assert!(kb.contains(&"models[].azureOpenAIParameters.resourceUri"));
1536
1537        let connection: Vec<&str> = infra_refs(ResourceKind::Connection)
1538            .iter()
1539            .map(|r| r.path)
1540            .collect();
1541        assert!(connection.contains(&"properties.target"));
1542
1543        // Every kind that carries `encryptionKey.keyVaultUri` also carries
1544        // its sibling `encryptionKey.identity`, so a CMK edge is attributed
1545        // to the identity that actually fetches the key (spec §3.2 row 9).
1546        for kind in all_kinds() {
1547            let paths: Vec<&str> = infra_refs(*kind).iter().map(|r| r.path).collect();
1548            if paths.contains(&"encryptionKey.keyVaultUri") {
1549                assert!(
1550                    paths.contains(&"encryptionKey.identity"),
1551                    "{kind:?} has a CMK vault reference but no identity reference"
1552                );
1553            }
1554        }
1555
1556        // Total row count across every kind, per the spec table — 31 rows
1557        // minus the two preview-only Indexer `cache.*` rows this rigg 2.0
1558        // scope deliberately does not model, plus the six
1559        // `encryptionKey.identity` siblings.
1560        let total: usize = all_kinds().iter().map(|k| infra_refs(*k).len()).sum();
1561        assert_eq!(total, 35);
1562    }
1563
1564    #[test]
1565    fn meta_is_total_and_consistent() {
1566        for kind in all_kinds() {
1567            let m = meta(*kind);
1568            assert_eq!(m.kind, *kind);
1569            assert!(!m.collection_path.is_empty());
1570            assert!(!m.dir_name.is_empty());
1571        }
1572        assert_eq!(all_kinds().len(), 12);
1573    }
1574
1575    #[test]
1576    fn dir_names_unique() {
1577        let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
1578        dirs.sort();
1579        dirs.dedup();
1580        assert_eq!(dirs.len(), 12);
1581    }
1582
1583    #[test]
1584    fn indexer_references() {
1585        let indexer = json!({
1586            "name": "idxr",
1587            "dataSourceName": "my-ds",
1588            "targetIndexName": "my-index",
1589            "skillsetName": "my-skills"
1590        });
1591        let refs = extract_references(ResourceKind::Indexer, &indexer);
1592        assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
1593        assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
1594        assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
1595    }
1596
1597    #[test]
1598    fn knowledge_base_and_alias_references() {
1599        let kb = json!({
1600            "name": "kb",
1601            "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
1602        });
1603        let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
1604        assert_eq!(
1605            refs,
1606            vec![
1607                (ResourceKind::KnowledgeSource, "ks-a".to_string()),
1608                (ResourceKind::KnowledgeSource, "ks-b".to_string()),
1609            ]
1610        );
1611
1612        let alias = json!({"name": "a", "indexes": ["i1"]});
1613        let refs = extract_references(ResourceKind::Alias, &alias);
1614        assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
1615    }
1616
1617    #[test]
1618    fn x_rigg_ref_extracted_at_depth() {
1619        let agent = json!({
1620            "name": "agent",
1621            "model": "gpt-5-mini",
1622            "tools": [
1623                {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
1624            ]
1625        });
1626        let refs = extract_references(ResourceKind::Agent, &agent);
1627        assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
1628        assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
1629    }
1630
1631    #[test]
1632    fn agent_extracts_portal_kb_url_and_connection_id() {
1633        let agent = serde_json::json!({
1634            "name": "Regulus",
1635            "model": "gpt-5.2-chat",
1636            "tools": [{
1637                "type": "mcp",
1638                "server_label": "kb_regulatory_kb",
1639                "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2026-08-01-preview",
1640                "project_connection_id": "kb-regulatory-kb-9kdyn"
1641            }]
1642        });
1643        let refs = extract_references(ResourceKind::Agent, &agent);
1644        assert!(
1645            refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
1646            "{refs:?}"
1647        );
1648        assert!(
1649            refs.contains(&(
1650                ResourceKind::Connection,
1651                "kb-regulatory-kb-9kdyn".to_string()
1652            )),
1653            "{refs:?}"
1654        );
1655        assert!(
1656            refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
1657            "{refs:?}"
1658        );
1659    }
1660
1661    #[test]
1662    fn agent_ignores_non_search_mcp_urls() {
1663        let agent = serde_json::json!({
1664            "name": "a",
1665            "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
1666        });
1667        let refs = extract_references(ResourceKind::Agent, &agent);
1668        assert!(
1669            !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
1670            "{refs:?}"
1671        );
1672    }
1673
1674    #[test]
1675    fn deployment_runtime_state_is_volatile() {
1676        let vf = meta(ResourceKind::Deployment).volatile_fields;
1677        assert!(vf.contains(&"properties.currentCapacity"));
1678        assert!(vf.contains(&"properties.deploymentState"));
1679    }
1680
1681    #[test]
1682    fn agent_portal_timestamp_is_volatile() {
1683        assert!(
1684            meta(ResourceKind::Agent)
1685                .volatile_fields
1686                .contains(&"metadata.modified_at")
1687        );
1688    }
1689
1690    #[test]
1691    fn is_platform_managed_true_for_system_managed_guardrail() {
1692        let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
1693        assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
1694    }
1695
1696    #[test]
1697    fn is_platform_managed_false_for_user_managed_guardrail() {
1698        let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
1699        assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
1700    }
1701
1702    #[test]
1703    fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
1704        let doc = json!({"name": "Microsoft.Default"});
1705        assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
1706    }
1707
1708    #[test]
1709    fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
1710        let doc = json!({"name": "my-policy"});
1711        assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
1712    }
1713
1714    #[test]
1715    fn is_platform_managed_only_applies_to_guardrail_kind() {
1716        let doc = json!({"name": "Microsoft.whatever"});
1717        assert!(!is_platform_managed(ResourceKind::Index, &doc));
1718    }
1719
1720    #[test]
1721    fn auto_created_by_finds_nested_created_resources() {
1722        // Live shape: createdResources nests under azureBlobParameters.
1723        let ks = serde_json::json!({
1724            "name": "regulatory",
1725            "kind": "azureBlob",
1726            "azureBlobParameters": {
1727                "containerName": "regulatory",
1728                "createdResources": {
1729                    "datasource": "regulatory-datasource",
1730                    "indexer": "regulatory-indexer",
1731                    "skillset": "regulatory-skillset",
1732                    "index": "regulatory-index",
1733                    "somethingFuture": "ignored-name"
1734                }
1735            }
1736        });
1737        let index_doc = serde_json::json!({"name": "regulatory-index"});
1738        let snapshot = vec![
1739            (
1740                ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
1741                ks,
1742            ),
1743            (
1744                ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
1745                index_doc,
1746            ),
1747        ];
1748        let map = auto_created_by(&snapshot);
1749        assert_eq!(
1750            map.get("indexes/regulatory-index").map(String::as_str),
1751            Some("regulatory")
1752        );
1753        assert_eq!(
1754            map.get("indexers/regulatory-indexer").map(String::as_str),
1755            Some("regulatory")
1756        );
1757        assert_eq!(
1758            map.get("data-sources/regulatory-datasource")
1759                .map(String::as_str),
1760            Some("regulatory")
1761        );
1762        assert_eq!(
1763            map.get("skillsets/regulatory-skillset").map(String::as_str),
1764            Some("regulatory")
1765        );
1766        assert!(
1767            !map.values().any(|v| v == "ignored-name"),
1768            "unknown member names ignored: {map:?}"
1769        );
1770        assert_eq!(map.len(), 4);
1771    }
1772
1773    #[test]
1774    fn auto_created_by_ignores_non_knowledge_source_docs() {
1775        let idx = serde_json::json!({
1776            "name": "i",
1777            "createdResources": {"index": "x"}
1778        });
1779        let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
1780        assert!(auto_created_by(&snapshot).is_empty());
1781    }
1782
1783    #[test]
1784    fn datasource_types_are_blob_only_on_both_channels() {
1785        assert_eq!(
1786            valid_datasource_types(Channel::Stable),
1787            &["azureblob", "adlsgen2"]
1788        );
1789        assert_eq!(
1790            valid_datasource_types(Channel::Preview),
1791            &["azureblob", "adlsgen2"]
1792        );
1793    }
1794
1795    #[test]
1796    fn ks_points_at_index() {
1797        let ks = json!({
1798            "name": "ks",
1799            "kind": "searchIndex",
1800            "searchIndexParameters": {"searchIndexName": "docs"}
1801        });
1802        let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
1803        assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
1804    }
1805
1806    #[test]
1807    fn immutable_diff_detects_kind_change() {
1808        let local = json!({"name": "ks", "kind": "searchIndex",
1809            "searchIndexParameters": {"searchIndexName": "docs"}});
1810        let remote = json!({"name": "ks", "kind": "azureBlob",
1811            "azureBlobParameters": {"containerName": "c"}});
1812        let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1813        assert_eq!(
1814            diff,
1815            vec![("kind", "azureBlob".to_string(), "searchIndex".to_string())]
1816        );
1817    }
1818
1819    #[test]
1820    fn immutable_diff_empty_when_kind_unchanged() {
1821        let local = json!({"name": "ks", "kind": "azureBlob", "description": "new"});
1822        let remote = json!({"name": "ks", "kind": "azureBlob"});
1823        assert!(immutable_diff(ResourceKind::KnowledgeSource, &local, &remote).is_empty());
1824    }
1825
1826    #[test]
1827    fn immutable_diff_empty_for_kinds_without_immutable_fields() {
1828        let local = json!({"name": "i", "kind": "a"});
1829        let remote = json!({"name": "i", "kind": "b"});
1830        assert!(immutable_diff(ResourceKind::Index, &local, &remote).is_empty());
1831    }
1832
1833    #[test]
1834    fn immutable_diff_counts_missing_side_as_difference() {
1835        let local = json!({"name": "ks", "kind": "searchIndex"});
1836        let remote = json!({"name": "ks"});
1837        let diff = immutable_diff(ResourceKind::KnowledgeSource, &local, &remote);
1838        assert_eq!(
1839            diff,
1840            vec![("kind", String::new(), "searchIndex".to_string())]
1841        );
1842    }
1843
1844    #[test]
1845    fn knowledge_source_blob_connection_is_credential_material() {
1846        // The azureBlob KS shape carries a per-env storage connection string:
1847        // it must be validate-rejected as credential material (promote
1848        // translates it through the storage binding, like every other
1849        // infrastructure reference).
1850        assert!(
1851            meta(ResourceKind::KnowledgeSource)
1852                .secret_fields
1853                .contains(&"azureBlobParameters.connectionString")
1854        );
1855    }
1856
1857    #[test]
1858    fn restore_path_plain_field() {
1859        let mut dst = json!({"name": "b-name", "model": "m1"});
1860        let src = json!({"name": "a-name", "model": "m2"});
1861        restore_path(&mut dst, &src, "name");
1862        assert_eq!(dst["name"], json!("a-name"));
1863        assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1864    }
1865
1866    #[test]
1867    fn restore_path_creates_missing_intermediate_objects() {
1868        let mut dst = json!({"name": "x"});
1869        let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1870        restore_path(&mut dst, &src, "credentials.connectionString");
1871        assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1872    }
1873
1874    #[test]
1875    fn restore_path_array_paired_by_index_not_identity() {
1876        let mut dst = json!({
1877            "tools": [
1878                {"type": "mcp", "server_url": "https://dst-a"},
1879                {"type": "mcp", "server_url": "https://dst-b"}
1880            ]
1881        });
1882        let src = json!({
1883            "tools": [
1884                {"type": "mcp", "server_url": "https://src-a"},
1885                {"type": "mcp", "server_url": "https://src-b"}
1886            ]
1887        });
1888        restore_path(&mut dst, &src, "tools[].server_url");
1889        assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1890        assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1891        assert_eq!(
1892            dst["tools"][0]["type"],
1893            json!("mcp"),
1894            "unrelated sibling kept"
1895        );
1896    }
1897
1898    #[test]
1899    fn restore_path_array_min_prefix_when_lengths_differ() {
1900        // dst has 3 tools, src only 2: only the first two get src's value;
1901        // the third is left as dst had it (nothing to pin from).
1902        let mut dst = json!({
1903            "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1904        });
1905        let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1906        restore_path(&mut dst, &src, "tools[].server_url");
1907        assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1908        assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1909        assert_eq!(
1910            dst["tools"][2]["server_url"],
1911            json!("d3"),
1912            "no src counterpart — left untouched"
1913        );
1914    }
1915
1916    #[test]
1917    fn restore_path_appends_src_only_array_elements_wholesale() {
1918        // CRITICAL regression (promote data-loss): merged doc = SOURCE clone,
1919        // so its array has the source's length. When the TARGET (`src` of the
1920        // restore) has MORE elements, the extras must be appended wholesale —
1921        // otherwise promote silently deletes the target's extra tools.
1922        let mut dst = json!({
1923            "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1924        });
1925        let src = json!({
1926            "tools": [
1927                {"type": "mcp", "server_url": "https://tgt-a"},
1928                {"type": "file_search", "vector_store_ids": ["vs1"]},
1929                {"type": "mcp", "server_url": "https://tgt-c"}
1930            ]
1931        });
1932        restore_path(&mut dst, &src, "tools[].server_url");
1933        let tools = dst["tools"].as_array().unwrap();
1934        assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1935        assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1936        assert_eq!(
1937            tools[1],
1938            json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1939            "extra element appended wholesale, not just the leaf field"
1940        );
1941        assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1942    }
1943
1944    #[test]
1945    fn restore_path_missing_in_src_leaves_dst_untouched() {
1946        let mut dst = json!({"name": "b", "model": "kept"});
1947        let src = json!({"name": "a"});
1948        restore_path(&mut dst, &src, "model");
1949        assert_eq!(dst["model"], json!("kept"));
1950    }
1951
1952    #[test]
1953    fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1954        let mut dst = json!({"tools": [{"server_url": "kept"}]});
1955        let src = json!({"name": "a"});
1956        restore_path(&mut dst, &src, "tools[].server_url");
1957        assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1958    }
1959
1960    #[test]
1961    fn provider_table_is_complete_and_current() {
1962        for p in [
1963            Provider::SearchData,
1964            Provider::FoundryData,
1965            Provider::CognitiveServicesArm,
1966            Provider::SearchArm,
1967            Provider::StorageArm,
1968            Provider::WebArm,
1969            Provider::AuthorizationArm,
1970            Provider::ResourcesArm,
1971            Provider::ManagedIdentityArm,
1972            Provider::KeyVaultArm,
1973            Provider::KeyVaultData,
1974            Provider::Graph,
1975        ] {
1976            let m = provider(p);
1977            assert_eq!(m.provider, p);
1978            assert!(!m.stable.is_empty());
1979            assert!(m.audience.starts_with("https://"));
1980        }
1981        assert_eq!(provider(Provider::SearchData).stable, "2026-04-01");
1982        assert_eq!(
1983            provider(Provider::SearchData).preview,
1984            Some("2026-08-01-preview")
1985        );
1986        assert_eq!(
1987            provider(Provider::CognitiveServicesArm).stable,
1988            "2026-05-01"
1989        );
1990        assert_eq!(provider(Provider::SearchArm).stable, "2025-05-01");
1991        assert_eq!(provider(Provider::StorageArm).stable, "2026-06-01");
1992        assert_eq!(provider(Provider::WebArm).stable, "2026-07-15");
1993        assert_eq!(provider(Provider::KeyVaultData).stable, "2025-07-01");
1994        assert!(provider(Provider::FoundryData).route_versioned);
1995        assert!(provider(Provider::Graph).route_versioned);
1996        assert_eq!(providers().len(), 12);
1997    }
1998
1999    #[test]
2000    fn cognitive_services_is_held_at_the_version_arm_registers_for_connections() {
2001        let m = provider(Provider::CognitiveServicesArm);
2002        assert_eq!(m.stable, "2026-05-01");
2003        let hold = m.hold.expect("hold documented");
2004        assert_eq!(hold.newer, "2026-07-01");
2005        let arm = m.arm.expect("arm registration");
2006        assert_eq!(arm.namespace, "Microsoft.CognitiveServices");
2007        assert!(
2008            arm.resource_types
2009                .contains(&"accounts/projects/connections")
2010        );
2011    }
2012
2013    #[test]
2014    fn every_arm_provider_declares_its_registration() {
2015        for m in providers() {
2016            if m.audience == "https://management.azure.com" && m.spec_path.is_some() {
2017                // Microsoft.Resources is the provider-registration API itself and has no resource type to look up.
2018                if m.provider == Provider::ResourcesArm {
2019                    continue;
2020                }
2021                assert!(m.arm.is_some(), "{} lacks ArmRegistration", m.label);
2022            }
2023        }
2024    }
2025}
2026
2027#[cfg(test)]
2028mod index_projection_ref_tests {
2029    use super::*;
2030    use serde_json::json;
2031
2032    #[test]
2033    fn skillset_index_projections_reference_the_index() {
2034        let ss = json!({
2035            "name": "ss",
2036            "skills": [],
2037            "indexProjections": {
2038                "selectors": [
2039                    {"targetIndexName": "proj-index-a"},
2040                    {"targetIndexName": "proj-index-b"}
2041                ]
2042            }
2043        });
2044        let refs = extract_references(ResourceKind::Skillset, &ss);
2045        assert!(refs.contains(&(ResourceKind::Index, "proj-index-a".into())));
2046        assert!(refs.contains(&(ResourceKind::Index, "proj-index-b".into())));
2047    }
2048
2049    #[test]
2050    fn rename_reference_rewrites_only_matching_values() {
2051        let mut ss = json!({
2052            "name": "ss",
2053            "indexProjections": {
2054                "selectors": [
2055                    {"targetIndexName": "old-index"},
2056                    {"targetIndexName": "other-index"}
2057                ]
2058            }
2059        });
2060        rename_reference(
2061            ResourceKind::Skillset,
2062            &mut ss,
2063            ResourceKind::Index,
2064            "old-index",
2065            "new-index",
2066        );
2067        assert_eq!(
2068            ss["indexProjections"]["selectors"][0]["targetIndexName"],
2069            "new-index"
2070        );
2071        assert_eq!(
2072            ss["indexProjections"]["selectors"][1]["targetIndexName"],
2073            "other-index"
2074        );
2075    }
2076
2077    #[test]
2078    fn rename_reference_rewrites_indexer_fields() {
2079        let mut idxr = json!({
2080            "name": "i",
2081            "dataSourceName": "old-ds",
2082            "targetIndexName": "old-index",
2083            "skillsetName": "old-ss"
2084        });
2085        rename_reference(
2086            ResourceKind::Indexer,
2087            &mut idxr,
2088            ResourceKind::DataSource,
2089            "old-ds",
2090            "new-ds",
2091        );
2092        assert_eq!(idxr["dataSourceName"], "new-ds");
2093        assert_eq!(
2094            idxr["targetIndexName"], "old-index",
2095            "other kinds untouched"
2096        );
2097    }
2098
2099    #[test]
2100    fn rename_x_rigg_ref_rewrites_only_the_matching_annotation() {
2101        let mut agent = json!({
2102            "name": "a",
2103            "tools": [
2104                {"type": "mcp", "x-rigg-ref": "knowledge-bases/kb-dev"},
2105                {"type": "mcp", "x-rigg-ref": "knowledge-bases/other"},
2106                {"type": "mcp", "x-rigg-ref": "connections/kb-dev"},
2107                {"type": "mcp", "server_url": "knowledge-bases/kb-dev"}
2108            ]
2109        });
2110        rename_x_rigg_ref(&mut agent, "knowledge-bases", "kb-dev", "kb");
2111        assert_eq!(agent["tools"][0][X_RIGG_REF], "knowledge-bases/kb");
2112        assert_eq!(agent["tools"][1][X_RIGG_REF], "knowledge-bases/other");
2113        assert_eq!(
2114            agent["tools"][2][X_RIGG_REF], "connections/kb-dev",
2115            "another directory is a different resource"
2116        );
2117        assert_eq!(
2118            agent["tools"][3]["server_url"], "knowledge-bases/kb-dev",
2119            "only the annotation key is rewritten"
2120        );
2121    }
2122}