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/// Default data-plane api-versions. Overridable per connection in `rigg.yaml`.
17pub 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";
20/// ARM api-version for Microsoft.CognitiveServices (deployments, connections, RAI policies).
21pub const ARM_COGNITIVE_API_VERSION: &str = "2026-05-01";
22
23/// Which service/plane a kind is managed through.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Domain {
26    /// Azure AI Search data plane.
27    Search,
28    /// Microsoft Foundry project data plane (`api-version=v1`).
29    FoundryData,
30    /// ARM control plane under Microsoft.CognitiveServices.
31    FoundryArm,
32}
33
34/// API version channel a kind (or capability) requires.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Channel {
37    Stable,
38    Preview,
39}
40
41/// A reference-bearing field: `path` addresses a string (or array of strings)
42/// naming resources of kind `to`.
43///
44/// Path syntax: dot-separated keys; `[]` after a key descends into each array
45/// element. Examples: `"dataSourceName"`, `"knowledgeSources[].name"`,
46/// `"indexes[].name"`.
47#[derive(Debug, Clone, Copy)]
48pub struct RefField {
49    pub path: &'static str,
50    pub to: ResourceKind,
51}
52
53/// Declarative metadata for one resource kind.
54#[derive(Debug, Clone, Copy)]
55pub struct KindMeta {
56    pub kind: ResourceKind,
57    pub domain: Domain,
58    /// API collection path (exact casing as the REST API expects).
59    pub collection_path: &'static str,
60    /// Directory name on disk, relative to the project's `search/` or `foundry/` dir.
61    pub dir_name: &'static str,
62    /// Minimum channel required for the kind itself.
63    pub channel: Channel,
64    /// Stripped on pull and ignored in diff (dot paths, applied at any depth
65    /// for `@odata.*`; top-level otherwise).
66    pub volatile_fields: &'static [&'static str],
67    /// Returned by GET but rejected by PUT — never written to files.
68    pub read_only_fields: &'static [&'static str],
69    /// Paths that may carry key material — validation rejects files where
70    /// these contain anything but identity-based placeholders.
71    pub secret_fields: &'static [&'static str],
72    /// Fields the server accepts on PUT but never returns on GET (redacted).
73    /// Kept in local files, sent on push, excluded from comparisons.
74    pub write_only_fields: &'static [&'static str],
75    /// String fields extracted to Markdown sidecars on pull by default.
76    pub sidecar_fields: &'static [&'static str],
77    /// How this kind references other resources.
78    pub reference_fields: &'static [RefField],
79    /// Fields the service will not change in place — a differing local value
80    /// means an in-place PUT cannot reconcile the documents and the resource
81    /// must be deleted and re-created (`rigg push` shows `replace`).
82    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            // SearchIndexKnowledgeStore / index projections target the index by name.
136            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        // Explicit-only model: Rigg never manages Azure-created sub-resources.
209        read_only_fields: &["createdResources", "ingestionPermissionOptions"],
210        // azureBlobParameters.connectionString is credential material for the
211        // managed-ingestion (azureBlob) KS shape: rejecting key values in
212        // validate AND keeping it env-pinned during promote (via env_pinned's
213        // secret ∪ write-only ∪ extras union).
214        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        // A knowledge source's kind (azureBlob, searchIndex, ...) cannot be
225        // changed by PUT — push replaces (delete + recreate) instead.
226        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        // Identity-based auth only — any credential payload is rejected.
320        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
349/// All kinds, in push-friendly declaration order.
350pub 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
368/// Metadata for a kind. Total over all kinds.
369pub 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
376/// Valid `type` strings for Azure AI Search data sources per channel.
377///
378/// Note Azure's own inconsistency: the stable reference spells Azure Files
379/// `azurefile`, the preview reference `azurefiles`. Both are accepted (and
380/// validation warns to double-check against the pinned api-version).
381pub 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
408/// Data source types that are preview-only (or preview-spelled).
409pub fn preview_only_datasource_types() -> &'static [&'static str] {
410    &["mysql", "sharepoint", "azurefile", "azurefiles"]
411}
412
413/// The key used by Rigg-local cross-service references
414/// (e.g. an agent tool pointing at a knowledge base by name).
415pub const X_RIGG_REF: &str = "x-rigg-ref";
416/// The key linking a WebApiSkill to an OpenAPI spec in `apis/`.
417pub const X_RIGG_API: &str = "x-rigg-api";
418/// Per-resource annotation (array of dot-paths) in a TARGET env's file naming
419/// additional fields `rigg promote` should keep pinned to that env's current
420/// value, beyond the kind's registry defaults. Lives alongside other
421/// `x-rigg-*` keys: kept on disk, stripped before any PUT/POST.
422pub const X_RIGG_PIN: &str = "x-rigg-pin";
423
424/// Per-kind fields that are genuinely environment-specific but not already
425/// covered by `secret_fields`/`write_only_fields` (e.g. an Agent's MCP tool
426/// pointing at a per-environment Search endpoint and Foundry connection, or a
427/// Connection's target endpoint). Consulted only by [`env_pinned`].
428fn 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
436/// Fields `rigg promote` keeps pinned to the TARGET environment's existing
437/// value by default: the kind's `secret_fields` ∪ `write_only_fields` ∪
438/// [`env_pinned_extra`] (de-duplicated; order-stable). `"name"` is pinned by
439/// the promote code itself, not the registry — it isn't a per-kind concern.
440pub 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
456/// Extract all references from `body` per the kind's `reference_fields`,
457/// plus any `x-rigg-ref` values (`"<dir-name>/<name>"`) found at any depth.
458pub 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
478/// Portal-authored agents reference Search knowledge bases by raw MCP URL
479/// (`https://<svc>.search.windows.net/knowledgebases/<name>/mcp?...`) rather
480/// than an `x-rigg-ref` annotation. Recognize the shape so dependency
481/// expansion can cross the service boundary.
482fn 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
503/// `https://<host>.search.windows.net/knowledgebases/<name>/mcp[?...]` → name.
504fn 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
517/// Platform-provided resource instances (e.g. Microsoft's built-in guardrail
518/// policies) cannot be modified or deleted by the user. They are excluded
519/// from adoption and from "unmanaged" reporting: local files should only
520/// track configuration the user actually controls. References to them (e.g.
521/// a deployment's `raiPolicyName`) live in the referencing resource's file.
522pub 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            // Name-prefix fallback for docs that omit properties.type.
531            let name = body.get("name").and_then(Value::as_str).unwrap_or("");
532            system || name.starts_with("Microsoft.")
533        }
534        _ => false,
535    }
536}
537
538/// Managed-ingestion knowledge sources auto-create their backing pipeline
539/// (index, indexer, data source, skillset); Azure names them in the KS's
540/// `createdResources`. Rigg never manages these sub-resources — the knowledge
541/// source definition is their source of truth — so they are excluded from
542/// adoption and unmanaged reporting. Returns resource key → creating KS name.
543pub 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, // future member names: ignore
570                };
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
589/// Immutable fields whose local and remote values differ — a non-empty
590/// result means an in-place PUT cannot reconcile the two documents and the
591/// resource must be replaced (delete + recreate). Returns
592/// `(path, remote value, local value)` per differing field. A value missing
593/// on one side counts as a difference when the other side has one.
594pub 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
651/// Walk a registry path (`a.b`, `arr[].field`, `arr[]`) and invoke `f` on each
652/// matched terminal value.
653pub 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
674/// Restore `dst`'s value(s) at `path` from the corresponding value(s) in
675/// `src` at the SAME path — the SET counterpart to [`collect_path`], used to
676/// apply `rigg promote`'s pinned fields (keep the target's value at pinned
677/// paths: `dst` is the merged/source-cloned doc, `src` is the target env's
678/// current doc). Mirrors `collect_path`'s traversal (`a.b`, `arr[].field`,
679/// `arr[]`).
680///
681/// For `[]` segments, `dst` and `src` arrays are paired by POSITION (index),
682/// not by an identity key — pinned paths (e.g. an agent's tool list) may have
683/// no stable name to match on. When the arrays differ in length:
684///
685/// - `src` (the target) longer: the extra elements are appended to `dst`
686///   WHOLESALE — they are target-only customizations (e.g. an extra tool
687///   only prod has) and must survive promote; dropping them would be silent
688///   data loss.
689/// - `dst` (the merged/source side) longer: its extra elements are left
690///   as-is — they come from the source (that IS the promotion) and there is
691///   nothing on the target side to pin from.
692///
693/// Missing intermediate objects in `dst` are created (mirroring how the
694/// value is nested in `src`); when `src` doesn't have a value at some point
695/// along the path, that position in `dst` is left untouched.
696pub 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            // Leaf: assign directly rather than inserting a placeholder and
728            // recursing — an inserted `Null` wouldn't be an `Object` yet if
729            // some OTHER path later needed to nest under this same key.
730            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
740/// Pair `dst`/`src` arrays by index and recurse `rest` into each matched
741/// pair; `src`-only elements (the target env's extra array members) are then
742/// appended to `dst` wholesale so promote never drops them. Idempotent
743/// across multiple pinned paths through the same array: after the first
744/// restore appends the extras, later paths find equal lengths and simply
745/// re-pair.
746fn 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        // Live shape: createdResources nests under azureBlobParameters.
923        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        // Azure's own spelling inconsistency: both accepted in preview.
990        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        // Credential fields already covered by secret_fields — no duplicate.
1055        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        // credentials.connectionString appears in both secret_fields and
1064        // write_only_fields — env_pinned must de-duplicate it, not double it.
1065        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        // The azureBlob KS shape carries a per-env storage connection string:
1083        // it must be validate-rejected as credential material AND kept pinned
1084        // to the target env during promote.
1085        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        // dst has 3 tools, src only 2: only the first two get src's value;
1141        // the third is left as dst had it (nothing to pin from).
1142        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        // CRITICAL regression (promote data-loss): merged doc = SOURCE clone,
1159        // so its array has the source's length. When the TARGET (`src` of the
1160        // restore) has MORE elements, the extras must be appended wholesale —
1161        // otherwise promote silently deletes the target's extra tools.
1162        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}