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}
80
81const COMMON_VOLATILE: &[&str] = &["@odata.etag", "@odata.context", "e_tag", "etag"];
82
83static KINDS: &[KindMeta] = &[
84    KindMeta {
85        kind: ResourceKind::DataSource,
86        domain: Domain::Search,
87        collection_path: "datasources",
88        dir_name: "data-sources",
89        channel: Channel::Stable,
90        volatile_fields: COMMON_VOLATILE,
91        read_only_fields: &[],
92        secret_fields: &["credentials.connectionString"],
93        write_only_fields: &["credentials.connectionString"],
94        sidecar_fields: &[],
95        reference_fields: &[],
96    },
97    KindMeta {
98        kind: ResourceKind::Index,
99        domain: Domain::Search,
100        collection_path: "indexes",
101        dir_name: "indexes",
102        channel: Channel::Stable,
103        volatile_fields: COMMON_VOLATILE,
104        read_only_fields: &[],
105        secret_fields: &[
106            "encryptionKey.accessCredentials.applicationSecret",
107            "vectorSearch.vectorizers[].azureOpenAIParameters.apiKey",
108        ],
109        write_only_fields: &[],
110        sidecar_fields: &[],
111        reference_fields: &[],
112    },
113    KindMeta {
114        kind: ResourceKind::Skillset,
115        domain: Domain::Search,
116        collection_path: "skillsets",
117        dir_name: "skillsets",
118        channel: Channel::Stable,
119        volatile_fields: COMMON_VOLATILE,
120        read_only_fields: &[],
121        secret_fields: &[
122            "cognitiveServices.key",
123            "skills[].apiKey",
124            "encryptionKey.accessCredentials.applicationSecret",
125        ],
126        write_only_fields: &[],
127        sidecar_fields: &[],
128        reference_fields: &[
129            // SearchIndexKnowledgeStore / index projections target the index by name.
130            RefField {
131                path: "knowledgeStore.projections[].objects[].storageContainer",
132                to: ResourceKind::Index,
133            },
134        ],
135    },
136    KindMeta {
137        kind: ResourceKind::Indexer,
138        domain: Domain::Search,
139        collection_path: "indexers",
140        dir_name: "indexers",
141        channel: Channel::Stable,
142        volatile_fields: COMMON_VOLATILE,
143        read_only_fields: &["status", "lastResult", "executionHistory", "limits"],
144        secret_fields: &[],
145        write_only_fields: &[],
146        sidecar_fields: &[],
147        reference_fields: &[
148            RefField {
149                path: "dataSourceName",
150                to: ResourceKind::DataSource,
151            },
152            RefField {
153                path: "targetIndexName",
154                to: ResourceKind::Index,
155            },
156            RefField {
157                path: "skillsetName",
158                to: ResourceKind::Skillset,
159            },
160        ],
161    },
162    KindMeta {
163        kind: ResourceKind::SynonymMap,
164        domain: Domain::Search,
165        collection_path: "synonymmaps",
166        dir_name: "synonym-maps",
167        channel: Channel::Stable,
168        volatile_fields: COMMON_VOLATILE,
169        read_only_fields: &[],
170        secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
171        write_only_fields: &[],
172        sidecar_fields: &[],
173        reference_fields: &[],
174    },
175    KindMeta {
176        kind: ResourceKind::Alias,
177        domain: Domain::Search,
178        collection_path: "aliases",
179        dir_name: "aliases",
180        channel: Channel::Stable,
181        volatile_fields: COMMON_VOLATILE,
182        read_only_fields: &[],
183        secret_fields: &[],
184        write_only_fields: &[],
185        sidecar_fields: &[],
186        reference_fields: &[RefField {
187            path: "indexes[]",
188            to: ResourceKind::Index,
189        }],
190    },
191    KindMeta {
192        kind: ResourceKind::KnowledgeSource,
193        domain: Domain::Search,
194        collection_path: "knowledgeSources",
195        dir_name: "knowledge-sources",
196        channel: Channel::Stable,
197        volatile_fields: COMMON_VOLATILE,
198        // Explicit-only model: Rigg never manages Azure-created sub-resources.
199        read_only_fields: &["createdResources", "ingestionPermissionOptions"],
200        // azureBlobParameters.connectionString is credential material for the
201        // managed-ingestion (azureBlob) KS shape: rejecting key values in
202        // validate AND keeping it env-pinned during promote (via env_pinned's
203        // secret ∪ write-only ∪ extras union).
204        secret_fields: &[
205            "searchIndexParameters.apiKey",
206            "azureBlobParameters.connectionString",
207        ],
208        write_only_fields: &[],
209        sidecar_fields: &[],
210        reference_fields: &[RefField {
211            path: "searchIndexParameters.searchIndexName",
212            to: ResourceKind::Index,
213        }],
214    },
215    KindMeta {
216        kind: ResourceKind::KnowledgeBase,
217        domain: Domain::Search,
218        collection_path: "knowledgeBases",
219        dir_name: "knowledge-bases",
220        channel: Channel::Stable,
221        volatile_fields: COMMON_VOLATILE,
222        read_only_fields: &[],
223        secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
224        write_only_fields: &[],
225        sidecar_fields: &[],
226        reference_fields: &[RefField {
227            path: "knowledgeSources[].name",
228            to: ResourceKind::KnowledgeSource,
229        }],
230    },
231    KindMeta {
232        kind: ResourceKind::Agent,
233        domain: Domain::FoundryData,
234        collection_path: "agents",
235        dir_name: "agents",
236        channel: Channel::Stable,
237        volatile_fields: &[
238            "@odata.etag",
239            "@odata.context",
240            "id",
241            "object",
242            "created_at",
243            "updated_at",
244            "version",
245            "metadata.modified_at",
246        ],
247        read_only_fields: &[],
248        secret_fields: &[],
249        write_only_fields: &[],
250        sidecar_fields: &["instructions"],
251        reference_fields: &[
252            RefField {
253                path: "model",
254                to: ResourceKind::Deployment,
255            },
256            RefField {
257                path: "tools[].project_connection_id",
258                to: ResourceKind::Connection,
259            },
260        ],
261    },
262    KindMeta {
263        kind: ResourceKind::Deployment,
264        domain: Domain::FoundryArm,
265        collection_path: "deployments",
266        dir_name: "deployments",
267        channel: Channel::Stable,
268        volatile_fields: &[
269            "id",
270            "type",
271            "systemData",
272            "etag",
273            "properties.provisioningState",
274            "properties.capabilities",
275            "properties.rateLimits",
276            "properties.model.callRateLimit",
277            "properties.currentCapacity",
278            "properties.deploymentState",
279        ],
280        read_only_fields: &[],
281        secret_fields: &[],
282        write_only_fields: &[],
283        sidecar_fields: &[],
284        reference_fields: &[RefField {
285            path: "properties.raiPolicyName",
286            to: ResourceKind::Guardrail,
287        }],
288    },
289    KindMeta {
290        kind: ResourceKind::Connection,
291        domain: Domain::FoundryArm,
292        collection_path: "connections",
293        dir_name: "connections",
294        channel: Channel::Stable,
295        volatile_fields: &[
296            "id",
297            "type",
298            "systemData",
299            "etag",
300            "properties.provisioningState",
301        ],
302        read_only_fields: &[],
303        // Identity-based auth only — any credential payload is rejected.
304        secret_fields: &[
305            "properties.credentials.key",
306            "properties.credentials.keys",
307            "properties.credentials.secret",
308            "properties.credentials.clientSecret",
309            "properties.credentials.pat",
310            "properties.credentials.sas",
311        ],
312        write_only_fields: &[],
313        sidecar_fields: &[],
314        reference_fields: &[],
315    },
316    KindMeta {
317        kind: ResourceKind::Guardrail,
318        domain: Domain::FoundryArm,
319        collection_path: "raiPolicies",
320        dir_name: "guardrails",
321        channel: Channel::Stable,
322        volatile_fields: &["id", "type", "systemData", "etag"],
323        read_only_fields: &[],
324        secret_fields: &[],
325        write_only_fields: &[],
326        sidecar_fields: &[],
327        reference_fields: &[],
328    },
329];
330
331/// All kinds, in push-friendly declaration order.
332pub fn all_kinds() -> &'static [ResourceKind] {
333    static ORDER: &[ResourceKind] = &[
334        ResourceKind::DataSource,
335        ResourceKind::Index,
336        ResourceKind::Skillset,
337        ResourceKind::Indexer,
338        ResourceKind::SynonymMap,
339        ResourceKind::Alias,
340        ResourceKind::KnowledgeSource,
341        ResourceKind::KnowledgeBase,
342        ResourceKind::Agent,
343        ResourceKind::Deployment,
344        ResourceKind::Connection,
345        ResourceKind::Guardrail,
346    ];
347    ORDER
348}
349
350/// Metadata for a kind. Total over all kinds.
351pub fn meta(kind: ResourceKind) -> &'static KindMeta {
352    KINDS
353        .iter()
354        .find(|m| m.kind == kind)
355        .expect("registry entry exists for every ResourceKind")
356}
357
358/// Valid `type` strings for Azure AI Search data sources per channel.
359///
360/// Note Azure's own inconsistency: the stable reference spells Azure Files
361/// `azurefile`, the preview reference `azurefiles`. Both are accepted (and
362/// validation warns to double-check against the pinned api-version).
363pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
364    const GA: &[&str] = &[
365        "azureblob",
366        "adlsgen2",
367        "azuretable",
368        "azuresql",
369        "cosmosdb",
370        "onelake",
371    ];
372    const PREVIEW: &[&str] = &[
373        "azureblob",
374        "adlsgen2",
375        "azuretable",
376        "azuresql",
377        "cosmosdb",
378        "onelake",
379        "mysql",
380        "sharepoint",
381        "azurefile",
382        "azurefiles",
383    ];
384    match channel {
385        Channel::Stable => GA,
386        Channel::Preview => PREVIEW,
387    }
388}
389
390/// Data source types that are preview-only (or preview-spelled).
391pub fn preview_only_datasource_types() -> &'static [&'static str] {
392    &["mysql", "sharepoint", "azurefile", "azurefiles"]
393}
394
395/// The key used by Rigg-local cross-service references
396/// (e.g. an agent tool pointing at a knowledge base by name).
397pub const X_RIGG_REF: &str = "x-rigg-ref";
398/// The key linking a WebApiSkill to an OpenAPI spec in `apis/`.
399pub const X_RIGG_API: &str = "x-rigg-api";
400/// Per-resource annotation (array of dot-paths) in a TARGET env's file naming
401/// additional fields `rigg promote` should keep pinned to that env's current
402/// value, beyond the kind's registry defaults. Lives alongside other
403/// `x-rigg-*` keys: kept on disk, stripped before any PUT/POST.
404pub const X_RIGG_PIN: &str = "x-rigg-pin";
405
406/// Per-kind fields that are genuinely environment-specific but not already
407/// covered by `secret_fields`/`write_only_fields` (e.g. an Agent's MCP tool
408/// pointing at a per-environment Search endpoint and Foundry connection, or a
409/// Connection's target endpoint). Consulted only by [`env_pinned`].
410fn env_pinned_extra(kind: ResourceKind) -> &'static [&'static str] {
411    match kind {
412        ResourceKind::Agent => &["tools[].server_url", "tools[].project_connection_id"],
413        ResourceKind::Connection => &["properties.target"],
414        _ => &[],
415    }
416}
417
418/// Fields `rigg promote` keeps pinned to the TARGET environment's existing
419/// value by default: the kind's `secret_fields` ∪ `write_only_fields` ∪
420/// [`env_pinned_extra`] (de-duplicated; order-stable). `"name"` is pinned by
421/// the promote code itself, not the registry — it isn't a per-kind concern.
422pub fn env_pinned(kind: ResourceKind) -> Vec<&'static str> {
423    let m = meta(kind);
424    let mut out: Vec<&'static str> = Vec::new();
425    for field in m
426        .secret_fields
427        .iter()
428        .chain(m.write_only_fields)
429        .chain(env_pinned_extra(kind))
430    {
431        if !out.contains(field) {
432            out.push(field);
433        }
434    }
435    out
436}
437
438/// Extract all references from `body` per the kind's `reference_fields`,
439/// plus any `x-rigg-ref` values (`"<dir-name>/<name>"`) found at any depth.
440pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
441    let mut out = Vec::new();
442    for rf in meta(kind).reference_fields {
443        collect_path(body, rf.path, &mut |v| {
444            if let Some(s) = v.as_str() {
445                if !s.is_empty() {
446                    out.push((rf.to, s.to_string()));
447                }
448            }
449        });
450    }
451    collect_x_rigg_refs(body, &mut out);
452    if kind == ResourceKind::Agent {
453        collect_portal_agent_refs(body, &mut out);
454    }
455    out.sort();
456    out.dedup();
457    out
458}
459
460/// Portal-authored agents reference Search knowledge bases by raw MCP URL
461/// (`https://<svc>.search.windows.net/knowledgebases/<name>/mcp?...`) rather
462/// than an `x-rigg-ref` annotation. Recognize the shape so dependency
463/// expansion can cross the service boundary.
464fn collect_portal_agent_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
465    match v {
466        Value::Object(map) => {
467            if let Some(url) = map.get("server_url").and_then(Value::as_str) {
468                if let Some(kb) = parse_kb_mcp_url(url) {
469                    out.push((ResourceKind::KnowledgeBase, kb));
470                }
471            }
472            for val in map.values() {
473                collect_portal_agent_refs(val, out);
474            }
475        }
476        Value::Array(arr) => {
477            for item in arr {
478                collect_portal_agent_refs(item, out);
479            }
480        }
481        _ => {}
482    }
483}
484
485/// `https://<host>.search.windows.net/knowledgebases/<name>/mcp[?...]` → name.
486fn parse_kb_mcp_url(url: &str) -> Option<String> {
487    let rest = url.strip_prefix("https://")?;
488    let (host, path) = rest.split_once('/')?;
489    if !host.to_ascii_lowercase().ends_with(".search.windows.net") {
490        return None;
491    }
492    let path = path.split('?').next().unwrap_or(path);
493    let mut segs = path.split('/').filter(|s| !s.is_empty());
494    let (a, name, c) = (segs.next()?, segs.next()?, segs.next()?);
495    (a.eq_ignore_ascii_case("knowledgebases") && c.eq_ignore_ascii_case("mcp"))
496        .then(|| name.to_string())
497}
498
499/// Platform-provided resource instances (e.g. Microsoft's built-in guardrail
500/// policies) cannot be modified or deleted by the user. They are excluded
501/// from adoption and from "unmanaged" reporting: local files should only
502/// track configuration the user actually controls. References to them (e.g.
503/// a deployment's `raiPolicyName`) live in the referencing resource's file.
504pub fn is_platform_managed(kind: ResourceKind, body: &Value) -> bool {
505    match kind {
506        ResourceKind::Guardrail => {
507            let system = body
508                .pointer("/properties/type")
509                .and_then(Value::as_str)
510                .map(|t| t.eq_ignore_ascii_case("SystemManaged"))
511                .unwrap_or(false);
512            // Name-prefix fallback for docs that omit properties.type.
513            let name = body.get("name").and_then(Value::as_str).unwrap_or("");
514            system || name.starts_with("Microsoft.")
515        }
516        _ => false,
517    }
518}
519
520/// Managed-ingestion knowledge sources auto-create their backing pipeline
521/// (index, indexer, data source, skillset); Azure names them in the KS's
522/// `createdResources`. Rigg never manages these sub-resources — the knowledge
523/// source definition is their source of truth — so they are excluded from
524/// adoption and unmanaged reporting. Returns resource key → creating KS name.
525pub fn auto_created_by(
526    snapshot: &[(ResourceRef, Value)],
527) -> std::collections::BTreeMap<String, String> {
528    let mut out = std::collections::BTreeMap::new();
529    for (r, doc) in snapshot {
530        if r.kind != ResourceKind::KnowledgeSource {
531            continue;
532        }
533        collect_created_resources(doc, &r.name, &mut out);
534    }
535    out
536}
537
538fn collect_created_resources(
539    v: &Value,
540    ks_name: &str,
541    out: &mut std::collections::BTreeMap<String, String>,
542) {
543    if let Value::Object(map) = v {
544        if let Some(Value::Object(created)) = map.get("createdResources") {
545            for (member, name) in created {
546                let kind = match member.as_str() {
547                    "datasource" => Some(ResourceKind::DataSource),
548                    "indexer" => Some(ResourceKind::Indexer),
549                    "skillset" => Some(ResourceKind::Skillset),
550                    "index" => Some(ResourceKind::Index),
551                    _ => None, // future member names: ignore
552                };
553                if let (Some(kind), Some(name)) = (kind, name.as_str()) {
554                    out.insert(
555                        ResourceRef::new(kind, name.to_string()).key(),
556                        ks_name.to_string(),
557                    );
558                }
559            }
560        }
561        for val in map.values() {
562            collect_created_resources(val, ks_name, out);
563        }
564    } else if let Value::Array(arr) = v {
565        for item in arr {
566            collect_created_resources(item, ks_name, out);
567        }
568    }
569}
570
571fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
572    match v {
573        Value::Object(map) => {
574            for (k, val) in map {
575                if k == X_RIGG_REF {
576                    if let Some(s) = val.as_str() {
577                        if let Some((dir, name)) = s.split_once('/') {
578                            if let Some(kind) = ResourceKind::from_directory_name(dir) {
579                                out.push((kind, name.to_string()));
580                            }
581                        }
582                    }
583                } else {
584                    collect_x_rigg_refs(val, out);
585                }
586            }
587        }
588        Value::Array(arr) => {
589            for item in arr {
590                collect_x_rigg_refs(item, out);
591            }
592        }
593        _ => {}
594    }
595}
596
597/// Walk a registry path (`a.b`, `arr[].field`, `arr[]`) and invoke `f` on each
598/// matched terminal value.
599pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
600    fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
601        let Some((head, rest)) = segments.split_first() else {
602            f(v);
603            return;
604        };
605        if let Some(key) = head.strip_suffix("[]") {
606            let target = if key.is_empty() { Some(v) } else { v.get(key) };
607            if let Some(Value::Array(arr)) = target {
608                for item in arr {
609                    walk(item, rest, f);
610                }
611            }
612        } else if let Some(next) = v.get(*head) {
613            walk(next, rest, f);
614        }
615    }
616    let segments: Vec<&str> = path.split('.').collect();
617    walk(v, &segments, f);
618}
619
620/// Restore `dst`'s value(s) at `path` from the corresponding value(s) in
621/// `src` at the SAME path — the SET counterpart to [`collect_path`], used to
622/// apply `rigg promote`'s pinned fields (keep the target's value at pinned
623/// paths: `dst` is the merged/source-cloned doc, `src` is the target env's
624/// current doc). Mirrors `collect_path`'s traversal (`a.b`, `arr[].field`,
625/// `arr[]`).
626///
627/// For `[]` segments, `dst` and `src` arrays are paired by POSITION (index),
628/// not by an identity key — pinned paths (e.g. an agent's tool list) may have
629/// no stable name to match on. When the arrays differ in length:
630///
631/// - `src` (the target) longer: the extra elements are appended to `dst`
632///   WHOLESALE — they are target-only customizations (e.g. an extra tool
633///   only prod has) and must survive promote; dropping them would be silent
634///   data loss.
635/// - `dst` (the merged/source side) longer: its extra elements are left
636///   as-is — they come from the source (that IS the promotion) and there is
637///   nothing on the target side to pin from.
638///
639/// Missing intermediate objects in `dst` are created (mirroring how the
640/// value is nested in `src`); when `src` doesn't have a value at some point
641/// along the path, that position in `dst` is left untouched.
642pub fn restore_path(dst: &mut Value, src: &Value, path: &str) {
643    let segments: Vec<&str> = path.split('.').collect();
644    restore_path_walk(dst, src, &segments);
645}
646
647fn restore_path_walk(dst: &mut Value, src: &Value, segments: &[&str]) {
648    let Some((head, rest)) = segments.split_first() else {
649        *dst = src.clone();
650        return;
651    };
652    if let Some(key) = head.strip_suffix("[]") {
653        if key.is_empty() {
654            pair_arrays(dst, src, rest);
655        } else {
656            let Value::Object(src_map) = src else { return };
657            let Some(src_val) = src_map.get(key) else {
658                return;
659            };
660            let Value::Object(dst_map) = dst else { return };
661            let entry = dst_map
662                .entry(key.to_string())
663                .or_insert_with(|| Value::Array(Vec::new()));
664            pair_arrays(entry, src_val, rest);
665        }
666    } else {
667        let Value::Object(src_map) = src else { return };
668        let Some(src_val) = src_map.get(*head) else {
669            return;
670        };
671        let Value::Object(dst_map) = dst else { return };
672        if rest.is_empty() {
673            // Leaf: assign directly rather than inserting a placeholder and
674            // recursing — an inserted `Null` wouldn't be an `Object` yet if
675            // some OTHER path later needed to nest under this same key.
676            dst_map.insert((*head).to_string(), src_val.clone());
677        } else {
678            let entry = dst_map
679                .entry((*head).to_string())
680                .or_insert_with(|| Value::Object(serde_json::Map::new()));
681            restore_path_walk(entry, src_val, rest);
682        }
683    }
684}
685
686/// Pair `dst`/`src` arrays by index and recurse `rest` into each matched
687/// pair; `src`-only elements (the target env's extra array members) are then
688/// appended to `dst` wholesale so promote never drops them. Idempotent
689/// across multiple pinned paths through the same array: after the first
690/// restore appends the extras, later paths find equal lengths and simply
691/// re-pair.
692fn pair_arrays(dst: &mut Value, src: &Value, rest: &[&str]) {
693    let (Value::Array(d), Value::Array(s)) = (dst, src) else {
694        return;
695    };
696    let n = d.len().min(s.len());
697    for i in 0..n {
698        restore_path_walk(&mut d[i], &s[i], rest);
699    }
700    if s.len() > d.len() {
701        d.extend(s[n..].iter().cloned());
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use serde_json::json;
709
710    #[test]
711    fn meta_is_total_and_consistent() {
712        for kind in all_kinds() {
713            let m = meta(*kind);
714            assert_eq!(m.kind, *kind);
715            assert!(!m.collection_path.is_empty());
716            assert!(!m.dir_name.is_empty());
717        }
718        assert_eq!(all_kinds().len(), 12);
719    }
720
721    #[test]
722    fn dir_names_unique() {
723        let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
724        dirs.sort();
725        dirs.dedup();
726        assert_eq!(dirs.len(), 12);
727    }
728
729    #[test]
730    fn indexer_references() {
731        let indexer = json!({
732            "name": "idxr",
733            "dataSourceName": "my-ds",
734            "targetIndexName": "my-index",
735            "skillsetName": "my-skills"
736        });
737        let refs = extract_references(ResourceKind::Indexer, &indexer);
738        assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
739        assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
740        assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
741    }
742
743    #[test]
744    fn knowledge_base_and_alias_references() {
745        let kb = json!({
746            "name": "kb",
747            "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
748        });
749        let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
750        assert_eq!(
751            refs,
752            vec![
753                (ResourceKind::KnowledgeSource, "ks-a".to_string()),
754                (ResourceKind::KnowledgeSource, "ks-b".to_string()),
755            ]
756        );
757
758        let alias = json!({"name": "a", "indexes": ["i1"]});
759        let refs = extract_references(ResourceKind::Alias, &alias);
760        assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
761    }
762
763    #[test]
764    fn x_rigg_ref_extracted_at_depth() {
765        let agent = json!({
766            "name": "agent",
767            "model": "gpt-5-mini",
768            "tools": [
769                {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
770            ]
771        });
772        let refs = extract_references(ResourceKind::Agent, &agent);
773        assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
774        assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
775    }
776
777    #[test]
778    fn agent_extracts_portal_kb_url_and_connection_id() {
779        let agent = serde_json::json!({
780            "name": "Regulus",
781            "model": "gpt-5.2-chat",
782            "tools": [{
783                "type": "mcp",
784                "server_label": "kb_regulatory_kb",
785                "server_url": "https://mklabsrch.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version=2025-11-01-Preview",
786                "project_connection_id": "kb-regulatory-kb-9kdyn"
787            }]
788        });
789        let refs = extract_references(ResourceKind::Agent, &agent);
790        assert!(
791            refs.contains(&(ResourceKind::KnowledgeBase, "regulatory-kb".to_string())),
792            "{refs:?}"
793        );
794        assert!(
795            refs.contains(&(
796                ResourceKind::Connection,
797                "kb-regulatory-kb-9kdyn".to_string()
798            )),
799            "{refs:?}"
800        );
801        assert!(
802            refs.contains(&(ResourceKind::Deployment, "gpt-5.2-chat".to_string())),
803            "{refs:?}"
804        );
805    }
806
807    #[test]
808    fn agent_ignores_non_search_mcp_urls() {
809        let agent = serde_json::json!({
810            "name": "a",
811            "tools": [{"type": "mcp", "server_url": "https://example.com/knowledgebases/x/mcp"}]
812        });
813        let refs = extract_references(ResourceKind::Agent, &agent);
814        assert!(
815            !refs.iter().any(|(k, _)| *k == ResourceKind::KnowledgeBase),
816            "{refs:?}"
817        );
818    }
819
820    #[test]
821    fn deployment_runtime_state_is_volatile() {
822        let vf = meta(ResourceKind::Deployment).volatile_fields;
823        assert!(vf.contains(&"properties.currentCapacity"));
824        assert!(vf.contains(&"properties.deploymentState"));
825    }
826
827    #[test]
828    fn agent_portal_timestamp_is_volatile() {
829        assert!(
830            meta(ResourceKind::Agent)
831                .volatile_fields
832                .contains(&"metadata.modified_at")
833        );
834    }
835
836    #[test]
837    fn is_platform_managed_true_for_system_managed_guardrail() {
838        let doc = json!({"name": "Microsoft.DefaultV2", "properties": {"type": "SystemManaged"}});
839        assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
840    }
841
842    #[test]
843    fn is_platform_managed_false_for_user_managed_guardrail() {
844        let doc = json!({"name": "my-policy", "properties": {"type": "UserManaged"}});
845        assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
846    }
847
848    #[test]
849    fn is_platform_managed_falls_back_to_name_prefix_without_properties() {
850        let doc = json!({"name": "Microsoft.Default"});
851        assert!(is_platform_managed(ResourceKind::Guardrail, &doc));
852    }
853
854    #[test]
855    fn is_platform_managed_false_for_user_named_guardrail_without_properties() {
856        let doc = json!({"name": "my-policy"});
857        assert!(!is_platform_managed(ResourceKind::Guardrail, &doc));
858    }
859
860    #[test]
861    fn is_platform_managed_only_applies_to_guardrail_kind() {
862        let doc = json!({"name": "Microsoft.whatever"});
863        assert!(!is_platform_managed(ResourceKind::Index, &doc));
864    }
865
866    #[test]
867    fn auto_created_by_finds_nested_created_resources() {
868        // Live shape: createdResources nests under azureBlobParameters.
869        let ks = serde_json::json!({
870            "name": "regulatory",
871            "kind": "azureBlob",
872            "azureBlobParameters": {
873                "containerName": "regulatory",
874                "createdResources": {
875                    "datasource": "regulatory-datasource",
876                    "indexer": "regulatory-indexer",
877                    "skillset": "regulatory-skillset",
878                    "index": "regulatory-index",
879                    "somethingFuture": "ignored-name"
880                }
881            }
882        });
883        let index_doc = serde_json::json!({"name": "regulatory-index"});
884        let snapshot = vec![
885            (
886                ResourceRef::new(ResourceKind::KnowledgeSource, "regulatory".to_string()),
887                ks,
888            ),
889            (
890                ResourceRef::new(ResourceKind::Index, "regulatory-index".to_string()),
891                index_doc,
892            ),
893        ];
894        let map = auto_created_by(&snapshot);
895        assert_eq!(
896            map.get("indexes/regulatory-index").map(String::as_str),
897            Some("regulatory")
898        );
899        assert_eq!(
900            map.get("indexers/regulatory-indexer").map(String::as_str),
901            Some("regulatory")
902        );
903        assert_eq!(
904            map.get("data-sources/regulatory-datasource")
905                .map(String::as_str),
906            Some("regulatory")
907        );
908        assert_eq!(
909            map.get("skillsets/regulatory-skillset").map(String::as_str),
910            Some("regulatory")
911        );
912        assert!(
913            !map.values().any(|v| v == "ignored-name"),
914            "unknown member names ignored: {map:?}"
915        );
916        assert_eq!(map.len(), 4);
917    }
918
919    #[test]
920    fn auto_created_by_ignores_non_knowledge_source_docs() {
921        let idx = serde_json::json!({
922            "name": "i",
923            "createdResources": {"index": "x"}
924        });
925        let snapshot = vec![(ResourceRef::new(ResourceKind::Index, "i".to_string()), idx)];
926        assert!(auto_created_by(&snapshot).is_empty());
927    }
928
929    #[test]
930    fn datasource_types_per_channel() {
931        assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
932        assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
933        assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
934        assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
935        // Azure's own spelling inconsistency: both accepted in preview.
936        assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
937        assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
938    }
939
940    #[test]
941    fn ks_points_at_index() {
942        let ks = json!({
943            "name": "ks",
944            "kind": "searchIndex",
945            "searchIndexParameters": {"searchIndexName": "docs"}
946        });
947        let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
948        assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
949    }
950
951    #[test]
952    fn env_pinned_agent_covers_tool_server_fields() {
953        let pinned = env_pinned(ResourceKind::Agent);
954        assert!(pinned.contains(&"tools[].server_url"));
955        assert!(pinned.contains(&"tools[].project_connection_id"));
956    }
957
958    #[test]
959    fn env_pinned_connection_covers_target_endpoint() {
960        let pinned = env_pinned(ResourceKind::Connection);
961        assert!(pinned.contains(&"properties.target"));
962        // Credential fields already covered by secret_fields — no duplicate.
963        assert_eq!(
964            pinned.iter().filter(|f| **f == "properties.target").count(),
965            1
966        );
967    }
968
969    #[test]
970    fn env_pinned_datasource_is_covered_by_secret_and_write_only_alone() {
971        // credentials.connectionString appears in both secret_fields and
972        // write_only_fields — env_pinned must de-duplicate it, not double it.
973        let pinned = env_pinned(ResourceKind::DataSource);
974        assert_eq!(
975            pinned
976                .iter()
977                .filter(|f| **f == "credentials.connectionString")
978                .count(),
979            1
980        );
981    }
982
983    #[test]
984    fn env_pinned_empty_for_kinds_with_no_defaults() {
985        assert!(env_pinned(ResourceKind::Guardrail).is_empty());
986    }
987
988    #[test]
989    fn knowledge_source_blob_connection_is_secret_and_env_pinned() {
990        // The azureBlob KS shape carries a per-env storage connection string:
991        // it must be validate-rejected as credential material AND kept pinned
992        // to the target env during promote.
993        assert!(
994            meta(ResourceKind::KnowledgeSource)
995                .secret_fields
996                .contains(&"azureBlobParameters.connectionString")
997        );
998        assert!(
999            env_pinned(ResourceKind::KnowledgeSource)
1000                .contains(&"azureBlobParameters.connectionString"),
1001            "env_pinned includes it via the secret_fields union"
1002        );
1003    }
1004
1005    #[test]
1006    fn restore_path_plain_field() {
1007        let mut dst = json!({"name": "b-name", "model": "m1"});
1008        let src = json!({"name": "a-name", "model": "m2"});
1009        restore_path(&mut dst, &src, "name");
1010        assert_eq!(dst["name"], json!("a-name"));
1011        assert_eq!(dst["model"], json!("m1"), "unrelated field untouched");
1012    }
1013
1014    #[test]
1015    fn restore_path_creates_missing_intermediate_objects() {
1016        let mut dst = json!({"name": "x"});
1017        let src = json!({"name": "x", "credentials": {"connectionString": "secret"}});
1018        restore_path(&mut dst, &src, "credentials.connectionString");
1019        assert_eq!(dst["credentials"]["connectionString"], json!("secret"));
1020    }
1021
1022    #[test]
1023    fn restore_path_array_paired_by_index_not_identity() {
1024        let mut dst = json!({
1025            "tools": [
1026                {"type": "mcp", "server_url": "https://dst-a"},
1027                {"type": "mcp", "server_url": "https://dst-b"}
1028            ]
1029        });
1030        let src = json!({
1031            "tools": [
1032                {"type": "mcp", "server_url": "https://src-a"},
1033                {"type": "mcp", "server_url": "https://src-b"}
1034            ]
1035        });
1036        restore_path(&mut dst, &src, "tools[].server_url");
1037        assert_eq!(dst["tools"][0]["server_url"], json!("https://src-a"));
1038        assert_eq!(dst["tools"][1]["server_url"], json!("https://src-b"));
1039        assert_eq!(
1040            dst["tools"][0]["type"],
1041            json!("mcp"),
1042            "unrelated sibling kept"
1043        );
1044    }
1045
1046    #[test]
1047    fn restore_path_array_min_prefix_when_lengths_differ() {
1048        // dst has 3 tools, src only 2: only the first two get src's value;
1049        // the third is left as dst had it (nothing to pin from).
1050        let mut dst = json!({
1051            "tools": [{"server_url": "d1"}, {"server_url": "d2"}, {"server_url": "d3"}]
1052        });
1053        let src = json!({"tools": [{"server_url": "s1"}, {"server_url": "s2"}]});
1054        restore_path(&mut dst, &src, "tools[].server_url");
1055        assert_eq!(dst["tools"][0]["server_url"], json!("s1"));
1056        assert_eq!(dst["tools"][1]["server_url"], json!("s2"));
1057        assert_eq!(
1058            dst["tools"][2]["server_url"],
1059            json!("d3"),
1060            "no src counterpart — left untouched"
1061        );
1062    }
1063
1064    #[test]
1065    fn restore_path_appends_src_only_array_elements_wholesale() {
1066        // CRITICAL regression (promote data-loss): merged doc = SOURCE clone,
1067        // so its array has the source's length. When the TARGET (`src` of the
1068        // restore) has MORE elements, the extras must be appended wholesale —
1069        // otherwise promote silently deletes the target's extra tools.
1070        let mut dst = json!({
1071            "tools": [{"type": "mcp", "server_url": "https://src-a"}]
1072        });
1073        let src = json!({
1074            "tools": [
1075                {"type": "mcp", "server_url": "https://tgt-a"},
1076                {"type": "file_search", "vector_store_ids": ["vs1"]},
1077                {"type": "mcp", "server_url": "https://tgt-c"}
1078            ]
1079        });
1080        restore_path(&mut dst, &src, "tools[].server_url");
1081        let tools = dst["tools"].as_array().unwrap();
1082        assert_eq!(tools.len(), 3, "target-only elements survive: {tools:?}");
1083        assert_eq!(tools[0]["server_url"], json!("https://tgt-a"), "paired");
1084        assert_eq!(
1085            tools[1],
1086            json!({"type": "file_search", "vector_store_ids": ["vs1"]}),
1087            "extra element appended wholesale, not just the leaf field"
1088        );
1089        assert_eq!(tools[2]["server_url"], json!("https://tgt-c"));
1090    }
1091
1092    #[test]
1093    fn restore_path_missing_in_src_leaves_dst_untouched() {
1094        let mut dst = json!({"name": "b", "model": "kept"});
1095        let src = json!({"name": "a"});
1096        restore_path(&mut dst, &src, "model");
1097        assert_eq!(dst["model"], json!("kept"));
1098    }
1099
1100    #[test]
1101    fn restore_path_missing_array_in_src_leaves_dst_untouched() {
1102        let mut dst = json!({"tools": [{"server_url": "kept"}]});
1103        let src = json!({"name": "a"});
1104        restore_path(&mut dst, &src, "tools[].server_url");
1105        assert_eq!(dst["tools"][0]["server_url"], json!("kept"));
1106    }
1107}