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