Skip to main content

rigg_core/
promote.rs

1//! Translating one environment's resource documents into another's —
2//! the engine behind `rigg promote` (spec
3//! `docs/superpowers/specs/2026-09-09-promote-v2-design.md` §2).
4//!
5//! [`translate`] takes both environments as [`EnvDocs`] (bindings + the
6//! project's documents, correlated by LOGICAL id — the file stem — never by
7//! physical name) and produces a [`Plan`]: for every source document, the
8//! document the target environment should have. Five rules, in order:
9//!
10//! 1. **Local annotations and auth carriers are stripped** — `x-rigg-pin`
11//!    belongs to the target's file, and a WebApiSkill's key/`authResourceId`/
12//!    `x-rigg-auth` authorizes the SOURCE environment's function app and must
13//!    never cross ([`AuthCarrier::Stripped`]).
14//! 2. **Infrastructure translation** — every `registry::InfraRef` value is
15//!    parsed to a physical resource, mapped to the binding name it has in the
16//!    source, and re-rendered from the target's binding of the same name
17//!    ([`Rewire`]). Same physical value on both sides = `shared`: no change,
18//!    still reported.
19//! 3. **Sibling translation** — registry reference fields, `x-rigg-ref`
20//!    annotations and the knowledge-base name inside an MCP URL follow a
21//!    sibling that is physically named differently in the target
22//!    ([`Renamed`]).
23//! 4. **Kept from the target** — its `name`, every path its `x-rigg-pin`
24//!    lists (with the array semantics of [`registry::restore_path`]) and the
25//!    annotation itself; plus its own Web API auth carriers
26//!    ([`AuthCarrier::Kept`]).
27//! 5. **Everything else comes from the source** — that is the promotion.
28//!
29//! Anything translation cannot decide becomes a [`Pending`] question instead
30//! of a guess; the value is then left exactly as the source had it.
31
32use std::collections::{BTreeMap, BTreeSet};
33
34use serde_json::Value;
35
36use crate::binding::{
37    BindingEntry, BindingKind, BindingType, BindingValue, EnvBindings, RESERVED_BINDING_NAMES,
38};
39use crate::infra::{self, RenderTarget, Target};
40use crate::registry::{self, X_RIGG_AUTH, X_RIGG_AUTH_FUNCTION_KEY, X_RIGG_PIN, X_RIGG_REF};
41use crate::resources::ResourceKind;
42
43/// The `x-functions-key` header a WebApiSkill uses to carry a function key.
44const FUNCTION_KEY_HEADER: &str = "x-functions-key";
45/// The placeholder Azure itself returns in place of a redacted key — and
46/// what a file records for a key that lives in ARM, never on disk.
47const REDACTED_KEY: &str = "<redacted>";
48
49/// Where one infrastructure reference or binding is used:
50/// `(kind, file stem, path within the document)`.
51pub type Usage = (ResourceKind, String, String);
52
53/// One environment's side of a promotion: its bindings and the project's
54/// documents in it.
55#[derive(Debug, Clone)]
56pub struct EnvDocs {
57    pub env: String,
58    pub bindings: EnvBindings,
59    pub docs: Vec<Doc>,
60}
61
62/// One resource document, identified logically by `stem` (its file name) and
63/// physically by `physical` (its `name` field, when it has one).
64#[derive(Debug, Clone)]
65pub struct Doc {
66    pub kind: ResourceKind,
67    pub stem: String,
68    pub physical: String,
69    pub body: Value,
70}
71
72/// One infrastructure reference re-pointed at the target environment.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Rewire {
75    /// Concrete path in the document, e.g. `skills[0].uri`.
76    pub path: String,
77    /// The binding name both environments know this resource by.
78    pub binding: String,
79    pub target: Target,
80    /// The source binding's physical name.
81    pub from: String,
82    /// The target binding's physical name.
83    pub to: String,
84    /// Both environments point at the same physical resource.
85    pub shared: bool,
86}
87
88/// One reference rewritten to follow a sibling that is physically named
89/// differently in the target environment.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Renamed {
92    /// The reference field's path (registry syntax), `x-rigg-ref`, or the
93    /// concrete path of the MCP URL carrying a knowledge-base name.
94    pub path: String,
95    /// The kind of the sibling being referenced.
96    pub kind: ResourceKind,
97    /// The sibling's logical id (file stem).
98    pub stem: String,
99    pub from: String,
100    pub to: String,
101}
102
103/// What happened to one skill's Web API auth carrier.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum AuthCarrier {
106    /// The target's existing carrier was kept, on the merged document's
107    /// `skills[i]` named by `path`.
108    Kept { path: String },
109    /// The source's carrier was removed (it authorizes the source
110    /// environment's function app). `used_key` records that the source
111    /// authenticated with a function key, so the target's carrier can be
112    /// re-derived in the same shape.
113    Stripped { path: String, used_key: bool },
114}
115
116/// How an [`Item`] relates to what the target environment has today.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Change {
119    New,
120    Changed,
121    Unchanged,
122}
123
124/// One logical resource's translation.
125#[derive(Debug, Clone)]
126pub struct Item {
127    pub kind: ResourceKind,
128    pub stem: String,
129    /// The physical name the document will have in the target environment.
130    pub target_name: String,
131    pub is_new: bool,
132    /// The target's current document, when it has one.
133    pub before: Option<Value>,
134    pub merged: Value,
135    pub rewired: Vec<Rewire>,
136    pub renamed: Vec<Renamed>,
137    pub auth: Vec<AuthCarrier>,
138    /// The paths the target's own `x-rigg-pin` asked to keep, restored from
139    /// its document (sorted, de-duplicated). The target's `name` and the
140    /// annotation itself are always kept and are not listed here — this is
141    /// the user's pin list, not rigg's.
142    pub pinned: Vec<String>,
143}
144
145impl Item {
146    pub fn label(&self) -> String {
147        format!("{}/{}", self.kind.directory_name(), self.stem)
148    }
149
150    /// New, changed, or unchanged relative to the target's current document
151    /// (semantic comparison — key order and nulls do not count).
152    pub fn change(&self) -> Change {
153        match &self.before {
154            None => Change::New,
155            Some(before) => {
156                if rigg_diff::semantic::diff(before, &self.merged, "name").is_equal {
157                    Change::Unchanged
158                } else {
159                    Change::Changed
160                }
161            }
162        }
163    }
164}
165
166/// Something translation cannot decide on its own. Each is a question in the
167/// interaction model's protocol (spec §3); nothing is guessed.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Pending {
170    /// A value in the source names a physical resource no source binding
171    /// covers — there is nothing to map it through.
172    UnboundInSource {
173        kind: ResourceKind,
174        stem: String,
175        path: String,
176        target: Target,
177        physical: String,
178        proposed_name: String,
179    },
180    /// The source binding has no counterpart in the target environment.
181    /// `binding_type` is `None` for the implicit `search`/`foundry` targets.
182    MissingInTarget {
183        binding: String,
184        binding_type: Option<BindingType>,
185        source_physical: String,
186        used_by: Vec<Usage>,
187    },
188    /// The target binding exists but is declared by name only (or otherwise
189    /// lacks what the value's shape needs, e.g. a full ARM id or a base URL):
190    /// `rigg env show <to> --refresh`, or declare the ARM id.
191    /// `binding_type` is `None` for the implicit `search`/`foundry` targets.
192    UnresolvedTarget {
193        binding: String,
194        binding_type: Option<BindingType>,
195        used_by: Vec<Usage>,
196    },
197    /// An external endpoint bound in neither environment.
198    External { host: String, used_by: Vec<Usage> },
199}
200
201impl Pending {
202    fn sort_key(&self) -> (u8, String, String) {
203        match self {
204            Pending::UnboundInSource {
205                kind, stem, path, ..
206            } => (0, format!("{}/{stem}", kind.directory_name()), path.clone()),
207            Pending::MissingInTarget { binding, .. } => (1, binding.clone(), String::new()),
208            Pending::UnresolvedTarget { binding, .. } => (2, binding.clone(), String::new()),
209            Pending::External { host, .. } => (3, host.clone(), String::new()),
210        }
211    }
212}
213
214/// The whole translation of one project from `from` to `to`.
215#[derive(Debug, Clone)]
216pub struct Plan {
217    pub from: String,
218    pub to: String,
219    /// Every source resource, in (kind, stem) order — new, changed and
220    /// unchanged alike; ask [`Item::change`] which.
221    pub items: Vec<Item>,
222    /// Resources only the target has. Never touched by promote.
223    pub kept_only_in_to: Vec<(ResourceKind, String)>,
224    pub pending: Vec<Pending>,
225}
226
227/// Translate every document of `source` into the document `target` should
228/// have. Pure and offline: no Azure calls, no file I/O.
229pub fn translate(source: &EnvDocs, target: &EnvDocs) -> Plan {
230    let target_docs: BTreeMap<(ResourceKind, &str), &Doc> = target
231        .docs
232        .iter()
233        .map(|d| ((d.kind, d.stem.as_str()), d))
234        .collect();
235    let renames = Renames::build(source, target);
236    let mut pending = PendingSet::default();
237
238    let mut ordered: Vec<&Doc> = source.docs.iter().collect();
239    ordered.sort_by(|a, b| {
240        kind_order(a.kind)
241            .cmp(&kind_order(b.kind))
242            .then_with(|| a.stem.cmp(&b.stem))
243    });
244
245    let mut items = Vec::with_capacity(ordered.len());
246    for doc in ordered {
247        let target_doc = target_docs.get(&(doc.kind, doc.stem.as_str())).copied();
248        let before = target_doc.map(|d| &d.body);
249        let mut merged = doc.body.clone();
250        // The annotation lives in the TARGET's file; a source-side copy must
251        // not leak. The target's own is restored by `keep_from_target`.
252        if let Some(map) = merged.as_object_mut() {
253            map.remove(X_RIGG_PIN);
254        }
255        let mut auth = strip_source_auth(&mut merged);
256        let (rewired, mut renamed) =
257            rewire_infra(doc, &mut merged, source, target, &renames, &mut pending);
258        renamed.extend(rename_siblings(doc.kind, &mut merged, &renames));
259        let mut pinned = Vec::new();
260        if let Some(target_doc) = target_doc {
261            pinned = keep_from_target(&mut merged, target_doc);
262            auth.extend(keep_target_auth(&mut merged, &target_doc.body));
263        }
264        // The target's physical identity always wins; only a brand-new
265        // resource is named by the source (or, failing that, by its stem).
266        let target_name = match target_doc {
267            Some(target_doc) => target_doc.physical.clone(),
268            None => merged
269                .get("name")
270                .and_then(Value::as_str)
271                .unwrap_or(&doc.stem)
272                .to_string(),
273        };
274        items.push(Item {
275            kind: doc.kind,
276            stem: doc.stem.clone(),
277            target_name,
278            is_new: target_doc.is_none(),
279            before: before.cloned(),
280            merged,
281            rewired,
282            renamed,
283            auth,
284            pinned,
285        });
286    }
287
288    let source_ids: BTreeSet<(ResourceKind, &str)> = source
289        .docs
290        .iter()
291        .map(|d| (d.kind, d.stem.as_str()))
292        .collect();
293    let mut kept_only_in_to: Vec<(ResourceKind, String)> = target
294        .docs
295        .iter()
296        .filter(|d| !source_ids.contains(&(d.kind, d.stem.as_str())))
297        .map(|d| (d.kind, d.stem.clone()))
298        .collect();
299    kept_only_in_to.sort();
300    kept_only_in_to.dedup();
301
302    Plan {
303        from: source.env.clone(),
304        to: target.env.clone(),
305        items,
306        kept_only_in_to,
307        pending: pending.into_sorted(),
308    }
309}
310
311/// One row per binding the plan rewires: `(binding, target, from, to,
312/// shared, reference count)`, ordered by binding name. The binding's
313/// [`Target`] is the first one seen — an `ai-services` binding can serve
314/// both `ModelHost` and `AiServices` references, and the preview shows one
315/// row per binding.
316pub fn rewiring_table(plan: &Plan) -> Vec<(String, Target, String, String, bool, usize)> {
317    let mut rows: BTreeMap<String, (Target, String, String, bool, usize)> = BTreeMap::new();
318    for rewire in plan.items.iter().flat_map(|i| &i.rewired) {
319        rows.entry(rewire.binding.clone())
320            .and_modify(|row| row.4 += 1)
321            .or_insert((
322                rewire.target,
323                rewire.from.clone(),
324                rewire.to.clone(),
325                rewire.shared,
326                1,
327            ));
328    }
329    rows.into_iter()
330        .map(|(binding, (target, from, to, shared, refs))| {
331            (binding, target, from, to, shared, refs)
332        })
333        .collect()
334}
335
336/// A binding name proposed for an unbound physical resource: lower-kebab of
337/// the name (same rule as `rigg env learn`), never a reserved name.
338pub fn proposed_binding_name(physical: &str) -> String {
339    let mut name = String::with_capacity(physical.len());
340    for c in physical.chars() {
341        if c.is_ascii_alphanumeric() {
342            name.push(c.to_ascii_lowercase());
343        } else if !name.ends_with('-') {
344            name.push('-');
345        }
346    }
347    let mut name = name.trim_matches('-').to_string();
348    if name.is_empty() {
349        name = "binding".to_string();
350    }
351    if RESERVED_BINDING_NAMES.contains(&name.as_str()) {
352        name.push_str("-1");
353    }
354    name
355}
356
357// ---------------------------------------------------------------------
358// step 1 — strip what belongs to the source environment only
359// ---------------------------------------------------------------------
360
361/// Remove every skill's Web API auth carrier: the key in the URI, the
362/// `x-functions-key` header, `authResourceId` and the `x-rigg-auth`
363/// annotation. Each authorizes the SOURCE environment's function app; the
364/// target's carrier is kept (or re-derived) instead.
365fn strip_source_auth(merged: &mut Value) -> Vec<AuthCarrier> {
366    let mut out = Vec::new();
367    let Some(skills) = merged.get_mut("skills").and_then(Value::as_array_mut) else {
368        return out;
369    };
370    for (i, skill) in skills.iter_mut().enumerate() {
371        let Some(map) = skill.as_object_mut() else {
372            continue;
373        };
374        let uri = map
375            .get("uri")
376            .and_then(Value::as_str)
377            .unwrap_or_default()
378            .to_string();
379        let key_in_uri = has_code_param(&uri);
380        let key_header = map
381            .get("httpHeaders")
382            .and_then(|h| h.get(FUNCTION_KEY_HEADER))
383            .is_some();
384        let carrier = key_in_uri
385            || key_header
386            || map.contains_key("authResourceId")
387            || map.contains_key(X_RIGG_AUTH);
388        if !carrier {
389            continue;
390        }
391        if key_in_uri {
392            map.insert("uri".to_string(), Value::String(strip_code_param(&uri)));
393        }
394        if key_header && let Some(Value::Object(headers)) = map.get_mut("httpHeaders") {
395            headers.remove(FUNCTION_KEY_HEADER);
396            if headers.is_empty() {
397                map.remove("httpHeaders");
398            }
399        }
400        map.remove("authResourceId");
401        map.remove(X_RIGG_AUTH);
402        out.push(AuthCarrier::Stripped {
403            path: format!("skills[{i}]"),
404            used_key: key_in_uri || key_header,
405        });
406    }
407    out
408}
409
410fn has_code_param(uri: &str) -> bool {
411    uri.split_once('?')
412        .is_some_and(|(_, query)| query.split('&').any(is_code_param))
413}
414
415/// Put `key` into the uri's `code` query parameter, replacing any existing
416/// one. The inverse of [`strip_code_param`]; shared with the CLI's
417/// credential plumbing so a key lands the same way everywhere.
418pub fn set_code_param(uri: &str, key: &str) -> String {
419    let (base, query) = match uri.split_once('?') {
420        Some((b, q)) => (b, q),
421        None => (uri, ""),
422    };
423    let mut params: Vec<String> = query
424        .split('&')
425        .filter(|p| !p.is_empty() && !is_code_param(p))
426        .map(str::to_string)
427        .collect();
428    params.push(format!("code={key}"));
429    format!("{base}?{}", params.join("&"))
430}
431
432fn strip_code_param(uri: &str) -> String {
433    let Some((base, query)) = uri.split_once('?') else {
434        return uri.to_string();
435    };
436    let kept: Vec<&str> = query.split('&').filter(|p| !is_code_param(p)).collect();
437    if kept.is_empty() {
438        base.to_string()
439    } else {
440        format!("{base}?{}", kept.join("&"))
441    }
442}
443
444fn is_code_param(param: &str) -> bool {
445    param
446        .split_once('=')
447        .is_some_and(|(k, _)| k.eq_ignore_ascii_case("code"))
448}
449
450// ---------------------------------------------------------------------
451// step 2 — infrastructure translation
452// ---------------------------------------------------------------------
453
454fn rewire_infra(
455    doc: &Doc,
456    merged: &mut Value,
457    source: &EnvDocs,
458    target: &EnvDocs,
459    renames: &Renames,
460    pending: &mut PendingSet,
461) -> (Vec<Rewire>, Vec<Renamed>) {
462    let mut rewired = Vec::new();
463    let mut renamed = Vec::new();
464
465    for found in infra::extract(doc.kind, merged) {
466        let kind_of_target = found.physical.target;
467        let physical = found.physical.physical.clone();
468        let usage: Usage = (doc.kind, doc.stem.clone(), found.path.clone());
469
470        // `Api` references are matched by URL prefix, exactly as
471        // `infra::classify` matches them — never by host equality, or a
472        // binding scoped to `https://api.x/v1` would claim `…/v2/enrich`.
473        let src_binding = if kind_of_target == Target::Api {
474            let ref_url = found.physical.original.as_str().unwrap_or_default();
475            infra::find_api_binding(&source.bindings, ref_url)
476        } else {
477            source
478                .bindings
479                .find_physical(infra::wanted_for(kind_of_target), &physical)
480        };
481        let Some(src_entry) = src_binding else {
482            if kind_of_target == Target::Api {
483                pending.note_external(&physical, usage);
484            } else {
485                pending.note_unbound(Pending::UnboundInSource {
486                    kind: doc.kind,
487                    stem: doc.stem.clone(),
488                    path: found.path.clone(),
489                    target: kind_of_target,
490                    physical: physical.clone(),
491                    proposed_name: proposed_binding_name(&physical),
492                });
493            }
494            continue;
495        };
496        let Some(tgt_entry) = target.bindings.get(&src_entry.name) else {
497            pending.note_missing(
498                &src_entry.name,
499                declared_type(src_entry.kind),
500                &src_entry.physical_name,
501                usage,
502            );
503            continue;
504        };
505
506        // A knowledge-base name inside an MCP URL is a sibling reference:
507        // translate it before rendering the URL around it.
508        let kb_rename = found.physical.kb_name.as_deref().and_then(|kb| {
509            renames
510                .get(ResourceKind::KnowledgeBase, kb)
511                .map(|(new, stem)| (kb.to_string(), new.to_string(), stem.to_string()))
512        });
513        let kb_name = match (&kb_rename, &found.physical.kb_name) {
514            (Some((_, new, _)), _) => Some(new.clone()),
515            (None, kb) => kb.clone(),
516        };
517
518        let render_target = RenderTarget {
519            physical: tgt_entry.physical_name.clone(),
520            arm_id: arm_id_of(tgt_entry),
521            base_url: base_url_of(tgt_entry),
522            kb_name,
523            source_base_url: base_url_of(src_entry),
524        };
525        let Ok(value) = infra::render(found.form, &found.physical.original, &render_target) else {
526            // The target binding is known, but not well enough to rewrite
527            // this value's shape (no ARM id, no base URL).
528            pending.note_unresolved(
529                &tgt_entry.name,
530                infra::binding_type_for(kind_of_target),
531                usage,
532            );
533            continue;
534        };
535        if !set_path(merged, &found.path, value) {
536            // Nothing was written — never claim a rewiring that did not
537            // happen.
538            continue;
539        }
540        rewired.push(Rewire {
541            path: found.path.clone(),
542            binding: src_entry.name.clone(),
543            target: kind_of_target,
544            from: src_entry.physical_name.clone(),
545            to: tgt_entry.physical_name.clone(),
546            shared: src_entry.physical_name == tgt_entry.physical_name,
547        });
548        if let Some((old, new, stem)) = kb_rename {
549            renamed.push(Renamed {
550                path: found.path,
551                kind: ResourceKind::KnowledgeBase,
552                stem,
553                from: old,
554                to: new,
555            });
556        }
557    }
558    (rewired, renamed)
559}
560
561fn declared_type(kind: BindingKind) -> Option<BindingType> {
562    match kind {
563        BindingKind::Declared(t) => Some(t),
564        BindingKind::ImplicitSearch | BindingKind::ImplicitFoundry => None,
565    }
566}
567
568fn arm_id_of(entry: &BindingEntry) -> Option<String> {
569    entry
570        .resolved
571        .as_ref()
572        .and_then(|r| r.arm_id.clone())
573        .or_else(|| {
574            entry
575                .declared
576                .as_ref()
577                .and_then(|b| b.arm_id().map(str::to_string))
578        })
579}
580
581fn base_url_of(entry: &BindingEntry) -> Option<String> {
582    if let Some(declared) = &entry.declared
583        && let BindingValue::Url(url) = declared.value()
584    {
585        return Some(url);
586    }
587    entry.resolved.as_ref().and_then(|r| r.endpoint.clone())
588}
589
590/// Set `value` at a CONCRETE path (`a.b[2].c`) — the shape
591/// [`infra::extract`] reports, so every segment already exists. Returns
592/// whether the write actually happened.
593fn set_path(root: &mut Value, path: &str, value: Value) -> bool {
594    fn walk(v: &mut Value, segments: &[(&str, Option<usize>)], value: Value) -> bool {
595        let Some(((key, index), rest)) = segments.split_first() else {
596            *v = value;
597            return true;
598        };
599        let Some(next) = v.get_mut(key) else {
600            return false;
601        };
602        let next = match index {
603            Some(i) => match next.get_mut(*i) {
604                Some(item) => item,
605                None => return false,
606            },
607            None => next,
608        };
609        walk(next, rest, value)
610    }
611    let segments: Vec<(&str, Option<usize>)> = path.split('.').map(split_index).collect();
612    walk(root, &segments, value)
613}
614
615/// `skills[2]` → `("skills", Some(2))`; `uri` → `("uri", None)`.
616fn split_index(segment: &str) -> (&str, Option<usize>) {
617    match segment.split_once('[') {
618        Some((key, rest)) => (key, rest.trim_end_matches(']').parse().ok()),
619        None => (segment, None),
620    }
621}
622
623// ---------------------------------------------------------------------
624// step 3 — sibling translation
625// ---------------------------------------------------------------------
626
627/// Which siblings are physically named differently in the target:
628/// `(kind, source physical name)` → `(target physical name, stem)`.
629struct Renames {
630    map: BTreeMap<(ResourceKind, String), (String, String)>,
631}
632
633impl Renames {
634    fn build(source: &EnvDocs, target: &EnvDocs) -> Renames {
635        let target_physical: BTreeMap<(ResourceKind, &str), &str> = target
636            .docs
637            .iter()
638            .map(|d| ((d.kind, d.stem.as_str()), d.physical.as_str()))
639            .collect();
640        let mut map = BTreeMap::new();
641        for doc in &source.docs {
642            if let Some(physical) = target_physical.get(&(doc.kind, doc.stem.as_str()))
643                && *physical != doc.physical.as_str()
644            {
645                map.insert(
646                    (doc.kind, doc.physical.clone()),
647                    ((*physical).to_string(), doc.stem.clone()),
648                );
649            }
650        }
651        Renames { map }
652    }
653
654    fn get(&self, kind: ResourceKind, physical: &str) -> Option<(&str, &str)> {
655        self.map
656            .get(&(kind, physical.to_string()))
657            .map(|(new, stem)| (new.as_str(), stem.as_str()))
658    }
659}
660
661fn rename_siblings(kind: ResourceKind, merged: &mut Value, renames: &Renames) -> Vec<Renamed> {
662    let mut out = Vec::new();
663
664    // Collect every reference value at a CONCRETE path first, across all of
665    // the kind's reference fields, then write each one back at its own path.
666    // Every value is mapped by what it was BEFORE any rewrite: a whole-
667    // document rename pass would corrupt a swap (dev `a` = `ks-1`, `b` =
668    // `ks-2`; prod the other way round) by rewriting `ks-1` to `ks-2` and
669    // then that same value back to `ks-1`.
670    let mut hits: Vec<(String, ResourceKind, String)> = Vec::new();
671    for field in registry::meta(kind).reference_fields {
672        collect_concrete(merged, field.path, &mut |path, value| {
673            if let Some(name) = value.as_str()
674                && !name.is_empty()
675            {
676                hits.push((path.to_string(), field.to, name.to_string()));
677            }
678        });
679    }
680    // One record per rewritten value, at the path it was rewritten at.
681    for (path, to, old) in hits {
682        let Some((new, stem)) = renames.get(to, &old) else {
683            continue;
684        };
685        let (new, stem) = (new.to_string(), stem.to_string());
686        if !set_path(merged, &path, Value::String(new.clone())) {
687            continue;
688        }
689        out.push(Renamed {
690            path,
691            kind: to,
692            stem,
693            from: old,
694            to: new,
695        });
696    }
697
698    // `x-rigg-ref` annotations follow the same rule, at their own concrete
699    // paths — one record per annotation, not one per distinct value.
700    let mut annotations: Vec<(String, String)> = Vec::new();
701    collect_x_rigg_refs(merged, "", &mut annotations);
702    for (path, value) in annotations {
703        let Some((dir, old)) = value.split_once('/') else {
704            continue;
705        };
706        let Some(referenced) = ResourceKind::from_directory_name(dir) else {
707            continue;
708        };
709        let Some((new, stem)) = renames.get(referenced, old) else {
710            continue;
711        };
712        let (new, stem) = (new.to_string(), stem.to_string());
713        if !set_path(merged, &path, Value::String(format!("{dir}/{new}"))) {
714            continue;
715        }
716        out.push(Renamed {
717            path,
718            kind: referenced,
719            stem,
720            from: old.to_string(),
721            to: new,
722        });
723    }
724    out
725}
726
727/// Visit every value at a registry `path` (`key[]` descends into arrays)
728/// with its CONCRETE path — `indexProjections.selectors[1].targetIndexName`.
729/// The read-only counterpart of the paths [`set_path`] understands.
730fn collect_concrete(root: &Value, path: &str, f: &mut dyn FnMut(&str, &Value)) {
731    fn walk(v: &Value, segments: &[&str], prefix: String, f: &mut dyn FnMut(&str, &Value)) {
732        let Some((head, rest)) = segments.split_first() else {
733            f(&prefix, v);
734            return;
735        };
736        if let Some(key) = head.strip_suffix("[]") {
737            let target = if key.is_empty() { Some(v) } else { v.get(key) };
738            if let Some(Value::Array(items)) = target {
739                for (i, item) in items.iter().enumerate() {
740                    let next = if prefix.is_empty() {
741                        format!("{key}[{i}]")
742                    } else {
743                        format!("{prefix}.{key}[{i}]")
744                    };
745                    walk(item, rest, next, f);
746                }
747            }
748        } else if let Some(next) = v.get(*head) {
749            let path = if prefix.is_empty() {
750                (*head).to_string()
751            } else {
752                format!("{prefix}.{head}")
753            };
754            walk(next, rest, path, f);
755        }
756    }
757    let segments: Vec<&str> = path.split('.').collect();
758    walk(root, &segments, String::new(), f);
759}
760
761/// Every `x-rigg-ref` annotation in `v`, as `(concrete path, value)` —
762/// `tools[0].x-rigg-ref`, the shape [`set_path`] understands.
763fn collect_x_rigg_refs(v: &Value, prefix: &str, out: &mut Vec<(String, String)>) {
764    match v {
765        Value::Object(map) => {
766            for (key, value) in map {
767                let path = if prefix.is_empty() {
768                    key.clone()
769                } else {
770                    format!("{prefix}.{key}")
771                };
772                if key == X_RIGG_REF {
773                    if let Some(s) = value.as_str() {
774                        out.push((path, s.to_string()));
775                    }
776                } else {
777                    collect_x_rigg_refs(value, &path, out);
778                }
779            }
780        }
781        Value::Array(items) => {
782            for (i, item) in items.iter().enumerate() {
783                collect_x_rigg_refs(item, &format!("{prefix}[{i}]"), out);
784            }
785        }
786        _ => {}
787    }
788}
789
790// ---------------------------------------------------------------------
791// step 4 — what the target keeps
792// ---------------------------------------------------------------------
793
794/// Restore the target's physical identity and pinned paths into `merged`,
795/// returning the paths its `x-rigg-pin` asked for.
796///
797/// Identity is unconditional: the promoted document is named
798/// [`Doc::physical`] — never the source's name. When the target's file
799/// carries no `name` key at all (its identity is the file stem) the key is
800/// removed instead, so the target keeps its shape as well as its name.
801fn keep_from_target(merged: &mut Value, target: &Doc) -> Vec<String> {
802    if let Some(map) = merged.as_object_mut() {
803        if target.body.get("name").is_none() && target.physical == target.stem {
804            map.remove("name");
805        } else {
806            map.insert("name".to_string(), Value::String(target.physical.clone()));
807        }
808    }
809
810    let mut pinned = Vec::new();
811    if let Some(paths) = target.body.get(X_RIGG_PIN).and_then(Value::as_array) {
812        for path in paths.iter().filter_map(Value::as_str) {
813            registry::restore_path(merged, &target.body, path);
814            pinned.push(path.to_string());
815        }
816    }
817    if target.body.get(X_RIGG_PIN).is_some() {
818        registry::restore_path(merged, &target.body, X_RIGG_PIN);
819    }
820    pinned.sort();
821    pinned.dedup();
822    pinned
823}
824
825/// Re-apply the target's own Web API auth carriers. A carrier authorizes ONE
826/// skill's endpoint, so the target's skill is matched to a merged skill by
827/// `name`, then by (already translated) `uri`, and only then — when the two
828/// skill lists have the same length, so position still means something — by
829/// index. Matched by nothing: the carrier is not re-applied and nothing is
830/// recorded, rather than landing on a skill it does not authorize.
831fn keep_target_auth(merged: &mut Value, target: &Value) -> Vec<AuthCarrier> {
832    let mut out = Vec::new();
833    let (Some(target_skills), Some(merged_skills)) = (
834        target.get("skills").and_then(Value::as_array),
835        merged.get("skills").and_then(Value::as_array),
836    ) else {
837        return out;
838    };
839    let same_length = target_skills.len() == merged_skills.len();
840
841    // Read every carrier out of the target first: applying them needs a
842    // mutable borrow of the same document `merged_skills` is read from.
843    let mut carriers: Vec<Carrier> = Vec::new();
844    for (j, target_skill) in target_skills.iter().enumerate() {
845        let carrier = Carrier {
846            skill: 0,
847            auth_resource_id: target_skill.get("authResourceId").cloned(),
848            annotation: target_skill.get(X_RIGG_AUTH).cloned(),
849            key_header: target_skill
850                .pointer(&format!("/httpHeaders/{FUNCTION_KEY_HEADER}"))
851                .cloned(),
852        };
853        if carrier.is_empty() {
854            continue;
855        }
856        let Some(skill) = match_skill(merged_skills, target_skill, j, same_length) else {
857            continue;
858        };
859        carriers.push(Carrier { skill, ..carrier });
860    }
861
862    for carrier in carriers {
863        let Some(skill) = merged
864            .get_mut("skills")
865            .and_then(Value::as_array_mut)
866            .and_then(|skills| skills.get_mut(carrier.skill))
867            .and_then(Value::as_object_mut)
868        else {
869            continue;
870        };
871        if let Some(value) = carrier.auth_resource_id {
872            skill.insert("authResourceId".to_string(), value);
873        }
874        if let Some(value) = carrier.annotation {
875            let function_key = value.as_str() == Some(X_RIGG_AUTH_FUNCTION_KEY);
876            skill.insert(X_RIGG_AUTH.to_string(), value);
877            // The target authenticates with a key in the uri, but the
878            // translated uri carries none (the source's was stripped): leave
879            // the placeholder `rigg push`'s auth gate reads, or the skill
880            // looks like an anonymous endpoint and the key is never resolved.
881            if function_key {
882                let uri = skill.get("uri").and_then(Value::as_str).unwrap_or_default();
883                let header = skill
884                    .get("httpHeaders")
885                    .and_then(|h| h.get(FUNCTION_KEY_HEADER))
886                    .is_some();
887                if !uri.is_empty() && !header && !has_code_param(uri) {
888                    let uri = set_code_param(uri, REDACTED_KEY);
889                    skill.insert("uri".to_string(), Value::String(uri));
890                }
891            }
892        }
893        if let Some(value) = carrier.key_header {
894            let headers = skill
895                .entry("httpHeaders".to_string())
896                .or_insert_with(|| Value::Object(serde_json::Map::new()));
897            if let Some(map) = headers.as_object_mut() {
898                map.insert(FUNCTION_KEY_HEADER.to_string(), value);
899            }
900        }
901        out.push(AuthCarrier::Kept {
902            path: format!("skills[{}]", carrier.skill),
903        });
904    }
905    out
906}
907
908/// One target skill's Web API auth carrier, and the merged `skills[i]` it
909/// belongs to.
910struct Carrier {
911    skill: usize,
912    auth_resource_id: Option<Value>,
913    annotation: Option<Value>,
914    key_header: Option<Value>,
915}
916
917impl Carrier {
918    fn is_empty(&self) -> bool {
919        self.auth_resource_id.is_none() && self.annotation.is_none() && self.key_header.is_none()
920    }
921}
922
923/// Which merged skill `target_skill` is: by `name`, else by `uri`, else by
924/// position when both lists have the same length.
925fn match_skill(
926    merged_skills: &[Value],
927    target_skill: &Value,
928    index: usize,
929    same_length: bool,
930) -> Option<usize> {
931    let by = |key: &str| {
932        target_skill
933            .get(key)
934            .and_then(Value::as_str)
935            .and_then(|wanted| {
936                merged_skills
937                    .iter()
938                    .position(|s| s.get(key).and_then(Value::as_str) == Some(wanted))
939            })
940    };
941    by("name")
942        .or_else(|| by("uri"))
943        .or_else(|| (same_length && index < merged_skills.len()).then_some(index))
944}
945
946// ---------------------------------------------------------------------
947// pending questions
948// ---------------------------------------------------------------------
949
950#[derive(Default)]
951struct PendingSet {
952    unbound: Vec<Pending>,
953    missing: BTreeMap<String, Pending>,
954    unresolved: BTreeMap<String, Pending>,
955    external: BTreeMap<String, Pending>,
956}
957
958impl PendingSet {
959    fn note_unbound(&mut self, question: Pending) {
960        if !self.unbound.contains(&question) {
961            self.unbound.push(question);
962        }
963    }
964
965    fn note_missing(
966        &mut self,
967        binding: &str,
968        binding_type: Option<BindingType>,
969        source_physical: &str,
970        usage: Usage,
971    ) {
972        let entry =
973            self.missing
974                .entry(binding.to_string())
975                .or_insert_with(|| Pending::MissingInTarget {
976                    binding: binding.to_string(),
977                    binding_type,
978                    source_physical: source_physical.to_string(),
979                    used_by: Vec::new(),
980                });
981        if let Pending::MissingInTarget { used_by, .. } = entry {
982            push_usage(used_by, usage);
983        }
984    }
985
986    fn note_unresolved(&mut self, binding: &str, binding_type: Option<BindingType>, usage: Usage) {
987        let entry = self
988            .unresolved
989            .entry(binding.to_string())
990            .or_insert_with(|| Pending::UnresolvedTarget {
991                binding: binding.to_string(),
992                binding_type,
993                used_by: Vec::new(),
994            });
995        if let Pending::UnresolvedTarget { used_by, .. } = entry {
996            push_usage(used_by, usage);
997        }
998    }
999
1000    fn note_external(&mut self, host: &str, usage: Usage) {
1001        let entry = self
1002            .external
1003            .entry(host.to_string())
1004            .or_insert_with(|| Pending::External {
1005                host: host.to_string(),
1006                used_by: Vec::new(),
1007            });
1008        if let Pending::External { used_by, .. } = entry {
1009            push_usage(used_by, usage);
1010        }
1011    }
1012
1013    fn into_sorted(self) -> Vec<Pending> {
1014        let mut out = self.unbound;
1015        out.extend(self.missing.into_values());
1016        out.extend(self.unresolved.into_values());
1017        out.extend(self.external.into_values());
1018        out.sort_by_key(Pending::sort_key);
1019        out.dedup();
1020        out
1021    }
1022}
1023
1024fn push_usage(used_by: &mut Vec<Usage>, usage: Usage) {
1025    if !used_by.contains(&usage) {
1026        used_by.push(usage);
1027    }
1028}
1029
1030/// Position of `kind` in [`ResourceKind::all`] — the registry's push-friendly
1031/// declaration order, which is also the order a plan lists resources in.
1032fn kind_order(kind: ResourceKind) -> usize {
1033    ResourceKind::all()
1034        .iter()
1035        .position(|k| *k == kind)
1036        .unwrap_or(usize::MAX)
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use crate::binding::{Binding, BindingType};
1043    use crate::registry::{SEARCH_PREVIEW_API_VERSION, X_RIGG_PIN};
1044    use crate::resources::ResourceKind;
1045    use crate::workspace::{Environment, FoundryConnection, SearchConnection};
1046    use serde_json::{Value, json};
1047
1048    const DEV_ACCT: &str =
1049        "/subscriptions/D/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/devacct";
1050    const PROD_ACCT: &str =
1051        "/subscriptions/P/resourceGroups/prg/providers/Microsoft.Storage/storageAccounts/prodacct";
1052    const SHARED_ACCT: &str =
1053        "/subscriptions/S/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sharedacct";
1054
1055    fn env(
1056        name: &str,
1057        search: &str,
1058        foundry: &str,
1059        deps: &[(&str, BindingType, &str)],
1060    ) -> EnvBindings {
1061        let environment = Environment {
1062            search: Some(SearchConnection {
1063                service: search.to_string(),
1064                ..Default::default()
1065            }),
1066            foundry: Some(FoundryConnection {
1067                account: foundry.to_string(),
1068                project: "p".to_string(),
1069                ..Default::default()
1070            }),
1071            dependencies: deps
1072                .iter()
1073                .map(|(n, kind, value)| {
1074                    (
1075                        n.to_string(),
1076                        Binding {
1077                            kind: *kind,
1078                            value: value.to_string(),
1079                        },
1080                    )
1081                })
1082                .collect(),
1083            ..Default::default()
1084        };
1085        EnvBindings::of_env(name, &environment, None)
1086    }
1087
1088    fn doc(kind: ResourceKind, stem: &str, body: Value) -> Doc {
1089        Doc {
1090            kind,
1091            stem: stem.to_string(),
1092            physical: body["name"].as_str().unwrap_or(stem).to_string(),
1093            body,
1094        }
1095    }
1096
1097    fn conn_string(arm_id: &str) -> String {
1098        format!("ResourceId={arm_id};")
1099    }
1100
1101    fn data_source(stem: &str, name: &str, arm_id: &str) -> Doc {
1102        doc(
1103            ResourceKind::DataSource,
1104            stem,
1105            json!({
1106                "name": name,
1107                "type": "azureblob",
1108                "credentials": {"connectionString": conn_string(arm_id)},
1109                "container": {"name": "c"}
1110            }),
1111        )
1112    }
1113
1114    fn item<'a>(plan: &'a Plan, kind: ResourceKind, stem: &str) -> &'a Item {
1115        plan.items
1116            .iter()
1117            .find(|i| i.kind == kind && i.stem == stem)
1118            .unwrap_or_else(|| panic!("no item {kind:?}/{stem} in {:?}", plan.items))
1119    }
1120
1121    #[test]
1122    fn new_in_target_data_source_is_rewired_to_the_target_storage_binding() {
1123        let src = EnvDocs {
1124            env: "dev".into(),
1125            bindings: env(
1126                "dev",
1127                "s-dev",
1128                "f-dev",
1129                &[("docs", BindingType::Storage, DEV_ACCT)],
1130            ),
1131            docs: vec![data_source("ds", "ds", DEV_ACCT)],
1132        };
1133        let tgt = EnvDocs {
1134            env: "prod".into(),
1135            bindings: env(
1136                "prod",
1137                "s-prod",
1138                "f-prod",
1139                &[("docs", BindingType::Storage, PROD_ACCT)],
1140            ),
1141            docs: vec![],
1142        };
1143
1144        let plan = translate(&src, &tgt);
1145        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
1146        assert_eq!(plan.from, "dev");
1147        assert_eq!(plan.to, "prod");
1148        let item = &plan.items[0];
1149        assert!(item.is_new);
1150        assert!(item.before.is_none());
1151        assert_eq!(item.change(), Change::New);
1152        assert_eq!(
1153            item.merged["credentials"]["connectionString"],
1154            json!(conn_string(PROD_ACCT))
1155        );
1156        assert_eq!(item.rewired.len(), 1);
1157        assert_eq!(item.rewired[0].binding, "docs");
1158        assert_eq!(item.rewired[0].path, "credentials.connectionString");
1159        assert_eq!(item.rewired[0].target, Target::Storage);
1160        assert_eq!(item.rewired[0].from, "devacct");
1161        assert_eq!(item.rewired[0].to, "prodacct");
1162        assert!(!item.rewired[0].shared);
1163        assert_eq!(item.target_name, "ds");
1164        assert!(item.pinned.is_empty(), "nothing to pin from: {item:?}");
1165    }
1166
1167    #[test]
1168    fn shared_binding_is_reported_shared_and_unchanged() {
1169        let src = EnvDocs {
1170            env: "dev".into(),
1171            bindings: env(
1172                "dev",
1173                "s-dev",
1174                "f-dev",
1175                &[("docs", BindingType::Storage, SHARED_ACCT)],
1176            ),
1177            docs: vec![data_source("ds", "ds", SHARED_ACCT)],
1178        };
1179        let tgt = EnvDocs {
1180            env: "prod".into(),
1181            bindings: env(
1182                "prod",
1183                "s-prod",
1184                "f-prod",
1185                &[("docs", BindingType::Storage, SHARED_ACCT)],
1186            ),
1187            docs: vec![],
1188        };
1189
1190        let plan = translate(&src, &tgt);
1191        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
1192        let item = &plan.items[0];
1193        assert_eq!(
1194            item.merged["credentials"]["connectionString"],
1195            json!(conn_string(SHARED_ACCT)),
1196            "a shared binding leaves the value untouched"
1197        );
1198        assert!(item.rewired[0].shared);
1199        assert_eq!(item.rewired[0].from, item.rewired[0].to);
1200
1201        let table = rewiring_table(&plan);
1202        assert_eq!(
1203            table,
1204            vec![(
1205                "docs".to_string(),
1206                Target::Storage,
1207                "sharedacct".to_string(),
1208                "sharedacct".to_string(),
1209                true,
1210                1
1211            )]
1212        );
1213    }
1214
1215    #[test]
1216    fn model_host_rewires_through_implicit_foundry() {
1217        let index = |name: &str, host: &str| {
1218            doc(
1219                ResourceKind::Index,
1220                "docs-index",
1221                json!({
1222                    "name": name,
1223                    "fields": [],
1224                    "vectorSearch": {"vectorizers": [
1225                        {"name": "v", "azureOpenAIParameters": {"resourceUri": format!("https://{host}.openai.azure.com")}}
1226                    ]}
1227                }),
1228            )
1229        };
1230        let src = EnvDocs {
1231            env: "dev".into(),
1232            bindings: env("dev", "s-dev", "f-dev", &[]),
1233            docs: vec![index("docs-index", "f-dev")],
1234        };
1235        let tgt = EnvDocs {
1236            env: "prod".into(),
1237            bindings: env("prod", "s-prod", "f-prod", &[]),
1238            docs: vec![],
1239        };
1240
1241        let plan = translate(&src, &tgt);
1242        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
1243        let item = &plan.items[0];
1244        assert_eq!(
1245            item.merged["vectorSearch"]["vectorizers"][0]["azureOpenAIParameters"]["resourceUri"],
1246            json!("https://f-prod.openai.azure.com")
1247        );
1248        assert_eq!(item.rewired[0].binding, "foundry");
1249        assert_eq!(item.rewired[0].target, Target::ModelHost);
1250        assert!(!item.rewired[0].shared);
1251    }
1252
1253    #[test]
1254    fn renamed_sibling_index_is_followed_by_the_indexer_and_by_kb_mcp_urls() {
1255        let mcp = |svc: &str, kb: &str| {
1256            format!(
1257                "https://{svc}.search.windows.net/knowledgebases/{kb}/mcp?api-version={SEARCH_PREVIEW_API_VERSION}"
1258            )
1259        };
1260        let src = EnvDocs {
1261            env: "dev".into(),
1262            bindings: env("dev", "s-dev", "f-dev", &[]),
1263            docs: vec![
1264                doc(
1265                    ResourceKind::Index,
1266                    "docs-index",
1267                    json!({"name": "docs-index-dev", "fields": []}),
1268                ),
1269                doc(
1270                    ResourceKind::Indexer,
1271                    "ix",
1272                    json!({"name": "ix", "dataSourceName": "ds", "targetIndexName": "docs-index-dev", "skillsetName": "sk"}),
1273                ),
1274                doc(
1275                    ResourceKind::Skillset,
1276                    "sk",
1277                    json!({
1278                        "name": "sk",
1279                        "skills": [],
1280                        "indexProjections": {"selectors": [{"targetIndexName": "docs-index-dev", "parentKeyFieldName": "p"}]}
1281                    }),
1282                ),
1283                doc(ResourceKind::KnowledgeBase, "kb", json!({"name": "kb-dev"})),
1284                doc(
1285                    ResourceKind::Agent,
1286                    "regulus",
1287                    json!({
1288                        "name": "Regulus",
1289                        "model": "gpt-5-mini",
1290                        "tools": [{
1291                            "type": "mcp",
1292                            "server_url": mcp("s-dev", "kb-dev"),
1293                            "x-rigg-ref": "knowledge-bases/kb-dev"
1294                        }]
1295                    }),
1296                ),
1297            ],
1298        };
1299        let tgt = EnvDocs {
1300            env: "prod".into(),
1301            bindings: env("prod", "s-prod", "f-prod", &[]),
1302            docs: vec![
1303                doc(
1304                    ResourceKind::Index,
1305                    "docs-index",
1306                    json!({"name": "docs-index", "fields": []}),
1307                ),
1308                doc(ResourceKind::KnowledgeBase, "kb", json!({"name": "kb"})),
1309            ],
1310        };
1311
1312        let plan = translate(&src, &tgt);
1313        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
1314
1315        let indexer = item(&plan, ResourceKind::Indexer, "ix");
1316        assert_eq!(indexer.merged["targetIndexName"], json!("docs-index"));
1317        assert_eq!(
1318            indexer.merged["dataSourceName"],
1319            json!("ds"),
1320            "unrenamed siblings are left alone"
1321        );
1322        assert_eq!(indexer.renamed.len(), 1);
1323        assert_eq!(indexer.renamed[0].kind, ResourceKind::Index);
1324        assert_eq!(indexer.renamed[0].stem, "docs-index");
1325        assert_eq!(indexer.renamed[0].from, "docs-index-dev");
1326        assert_eq!(indexer.renamed[0].to, "docs-index");
1327        assert_eq!(indexer.renamed[0].path, "targetIndexName");
1328
1329        let skillset = item(&plan, ResourceKind::Skillset, "sk");
1330        assert_eq!(
1331            skillset.merged["indexProjections"]["selectors"][0]["targetIndexName"],
1332            json!("docs-index")
1333        );
1334
1335        let agent = item(&plan, ResourceKind::Agent, "regulus");
1336        assert_eq!(
1337            agent.merged["tools"][0]["server_url"],
1338            json!(mcp("s-prod", "kb")),
1339            "the MCP URL follows both the search service and the renamed knowledge base"
1340        );
1341        assert_eq!(
1342            agent.merged["tools"][0]["x-rigg-ref"],
1343            json!("knowledge-bases/kb")
1344        );
1345        assert!(
1346            agent
1347                .renamed
1348                .iter()
1349                .any(|r| r.path == "tools[0].x-rigg-ref" && r.from == "kb-dev" && r.to == "kb"),
1350            "{:?}",
1351            agent.renamed
1352        );
1353        assert_eq!(agent.rewired[0].binding, "search");
1354        assert_eq!(agent.rewired[0].target, Target::SearchService);
1355
1356        let index = item(&plan, ResourceKind::Index, "docs-index");
1357        assert_eq!(
1358            index.merged["name"],
1359            json!("docs-index"),
1360            "the target keeps its own physical name"
1361        );
1362        assert_eq!(index.change(), Change::Unchanged);
1363    }
1364
1365    #[test]
1366    fn unbound_source_reference_becomes_a_pending_question_and_is_left_untouched() {
1367        let other = "/subscriptions/D/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/otheracct";
1368        let src = EnvDocs {
1369            env: "dev".into(),
1370            bindings: env(
1371                "dev",
1372                "s-dev",
1373                "f-dev",
1374                &[("docs", BindingType::Storage, DEV_ACCT)],
1375            ),
1376            docs: vec![data_source("ds", "ds", other)],
1377        };
1378        let tgt = EnvDocs {
1379            env: "prod".into(),
1380            bindings: env(
1381                "prod",
1382                "s-prod",
1383                "f-prod",
1384                &[("docs", BindingType::Storage, PROD_ACCT)],
1385            ),
1386            docs: vec![],
1387        };
1388
1389        let plan = translate(&src, &tgt);
1390        assert_eq!(plan.pending.len(), 1, "{:?}", plan.pending);
1391        match &plan.pending[0] {
1392            Pending::UnboundInSource {
1393                kind,
1394                stem,
1395                path,
1396                target,
1397                physical,
1398                proposed_name,
1399            } => {
1400                assert_eq!(*kind, ResourceKind::DataSource);
1401                assert_eq!(stem, "ds");
1402                assert_eq!(path, "credentials.connectionString");
1403                assert_eq!(*target, Target::Storage);
1404                assert_eq!(physical, "otheracct");
1405                assert_eq!(proposed_name, "otheracct");
1406            }
1407            other => panic!("expected UnboundInSource, got {other:?}"),
1408        }
1409        assert_eq!(
1410            plan.items[0].merged["credentials"]["connectionString"],
1411            json!(conn_string(other)),
1412            "an undecidable reference is left exactly as it was"
1413        );
1414        assert!(plan.items[0].rewired.is_empty());
1415    }
1416
1417    #[test]
1418    fn missing_target_binding_is_pending_once_with_all_users() {
1419        let src = EnvDocs {
1420            env: "dev".into(),
1421            bindings: env(
1422                "dev",
1423                "s-dev",
1424                "f-dev",
1425                &[("docs", BindingType::Storage, DEV_ACCT)],
1426            ),
1427            docs: vec![
1428                data_source("ds1", "ds1", DEV_ACCT),
1429                data_source("ds2", "ds2", DEV_ACCT),
1430            ],
1431        };
1432        let tgt = EnvDocs {
1433            env: "prod".into(),
1434            bindings: env("prod", "s-prod", "f-prod", &[]),
1435            docs: vec![],
1436        };
1437
1438        let plan = translate(&src, &tgt);
1439        assert_eq!(plan.pending.len(), 1, "{:?}", plan.pending);
1440        match &plan.pending[0] {
1441            Pending::MissingInTarget {
1442                binding,
1443                binding_type,
1444                source_physical,
1445                used_by,
1446            } => {
1447                assert_eq!(binding, "docs");
1448                assert_eq!(*binding_type, Some(BindingType::Storage));
1449                assert_eq!(source_physical, "devacct");
1450                assert_eq!(used_by.len(), 2, "{used_by:?}");
1451                assert_eq!(
1452                    used_by[0],
1453                    (
1454                        ResourceKind::DataSource,
1455                        "ds1".to_string(),
1456                        "credentials.connectionString".to_string()
1457                    )
1458                );
1459                assert_eq!(used_by[1].1, "ds2");
1460            }
1461            other => panic!("expected MissingInTarget, got {other:?}"),
1462        }
1463        for i in &plan.items {
1464            assert!(i.rewired.is_empty());
1465            assert_eq!(
1466                i.merged["credentials"]["connectionString"],
1467                json!(conn_string(DEV_ACCT))
1468            );
1469        }
1470    }
1471
1472    #[test]
1473    fn target_binding_known_only_by_name_is_an_unresolved_question() {
1474        let src = EnvDocs {
1475            env: "dev".into(),
1476            bindings: env(
1477                "dev",
1478                "s-dev",
1479                "f-dev",
1480                &[("docs", BindingType::Storage, DEV_ACCT)],
1481            ),
1482            docs: vec![data_source("ds", "ds", DEV_ACCT)],
1483        };
1484        let tgt = EnvDocs {
1485            env: "prod".into(),
1486            bindings: env(
1487                "prod",
1488                "s-prod",
1489                "f-prod",
1490                &[("docs", BindingType::Storage, "prodacct")],
1491            ),
1492            docs: vec![],
1493        };
1494
1495        let plan = translate(&src, &tgt);
1496        assert_eq!(plan.pending.len(), 1, "{:?}", plan.pending);
1497        match &plan.pending[0] {
1498            Pending::UnresolvedTarget {
1499                binding,
1500                binding_type,
1501                used_by,
1502            } => {
1503                assert_eq!(binding, "docs");
1504                assert_eq!(*binding_type, Some(BindingType::Storage));
1505                assert_eq!(used_by.len(), 1);
1506                assert_eq!(used_by[0].1, "ds");
1507            }
1508            other => panic!("expected UnresolvedTarget, got {other:?}"),
1509        }
1510        assert_eq!(
1511            plan.items[0].merged["credentials"]["connectionString"],
1512            json!(conn_string(DEV_ACCT)),
1513            "an unresolvable target leaves the value alone"
1514        );
1515        assert!(plan.items[0].rewired.is_empty());
1516    }
1517
1518    #[test]
1519    fn target_keeps_name_and_x_rigg_pin_paths_with_array_semantics() {
1520        let src = EnvDocs {
1521            env: "dev".into(),
1522            bindings: env("dev", "s-dev", "f-dev", &[]),
1523            docs: vec![doc(
1524                ResourceKind::Agent,
1525                "agent",
1526                json!({
1527                    "name": "agent-dev",
1528                    "model": "gpt-5-mini",
1529                    "tools": [{"type": "mcp", "server_url": "https://dev.example/x"}],
1530                    "x-rigg-pin": ["should-not-leak"]
1531                }),
1532            )],
1533        };
1534        let tgt = EnvDocs {
1535            env: "prod".into(),
1536            bindings: env("prod", "s-prod", "f-prod", &[]),
1537            docs: vec![doc(
1538                ResourceKind::Agent,
1539                "agent",
1540                json!({
1541                    "name": "agent",
1542                    "model": "gpt-4o-old",
1543                    "tools": [
1544                        {"type": "mcp", "server_url": "https://prod.example/x"},
1545                        {"type": "file_search", "vector_store_ids": ["vs-prod"]},
1546                        {"type": "mcp", "server_url": "https://prod.example/y",
1547                         "project_connection_id": "conn-prod-2"}
1548                    ],
1549                    "x-rigg-pin": ["tools[].server_url"]
1550                }),
1551            )],
1552        };
1553
1554        let plan = translate(&src, &tgt);
1555        let item = &plan.items[0];
1556        assert!(!item.is_new);
1557        assert_eq!(item.change(), Change::Changed);
1558        assert_eq!(item.merged["name"], json!("agent"), "target keeps its name");
1559        assert_eq!(item.target_name, "agent");
1560        assert_eq!(
1561            item.merged["model"],
1562            json!("gpt-5-mini"),
1563            "non-pinned promoted"
1564        );
1565
1566        let tools = item.merged["tools"].as_array().unwrap();
1567        assert_eq!(tools.len(), 3, "target-only tools survive: {tools:?}");
1568        assert_eq!(tools[0]["server_url"], json!("https://prod.example/x"));
1569        assert_eq!(
1570            tools[1],
1571            json!({"type": "file_search", "vector_store_ids": ["vs-prod"]})
1572        );
1573        assert_eq!(tools[2]["project_connection_id"], json!("conn-prod-2"));
1574
1575        assert_eq!(
1576            item.merged[X_RIGG_PIN],
1577            json!(["tools[].server_url"]),
1578            "the target's annotation travels, the source's is stripped"
1579        );
1580        assert_eq!(
1581            item.pinned,
1582            vec!["tools[].server_url".to_string()],
1583            "`pinned` is the user's pin list — not `name`, not the annotation"
1584        );
1585
1586        // No target at all: the source's own annotation still never leaks.
1587        let empty = EnvDocs {
1588            env: "prod".into(),
1589            bindings: env("prod", "s-prod", "f-prod", &[]),
1590            docs: vec![],
1591        };
1592        let fresh = translate(&src, &empty);
1593        assert!(fresh.items[0].merged.get(X_RIGG_PIN).is_none());
1594        assert!(fresh.items[0].pinned.is_empty());
1595    }
1596
1597    #[test]
1598    fn web_api_auth_carriers_never_cross_but_target_carriers_are_kept() {
1599        let source_skillset = doc(
1600            ResourceKind::Skillset,
1601            "sk",
1602            json!({
1603                "name": "sk",
1604                "skills": [{
1605                    "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
1606                    "name": "enrich",
1607                    "uri": "https://fn-dev.azurewebsites.net/api/enrich?code=<redacted>",
1608                    "x-rigg-auth": "function-key",
1609                    "inputs": [],
1610                    "outputs": []
1611                }]
1612            }),
1613        );
1614        let src = EnvDocs {
1615            env: "dev".into(),
1616            bindings: env(
1617                "dev",
1618                "s-dev",
1619                "f-dev",
1620                &[("enrich-fn", BindingType::FunctionApp, "fn-dev")],
1621            ),
1622            docs: vec![source_skillset],
1623        };
1624        let bindings_prod = || {
1625            env(
1626                "prod",
1627                "s-prod",
1628                "f-prod",
1629                &[("enrich-fn", BindingType::FunctionApp, "fn-prod")],
1630            )
1631        };
1632
1633        // New in the target: the source's carrier is stripped, never copied.
1634        let plan = translate(
1635            &src,
1636            &EnvDocs {
1637                env: "prod".into(),
1638                bindings: bindings_prod(),
1639                docs: vec![],
1640            },
1641        );
1642        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
1643        let item = &plan.items[0];
1644        assert_eq!(
1645            item.merged["skills"][0]["uri"],
1646            json!("https://fn-prod.azurewebsites.net/api/enrich"),
1647            "URI translated to the target function app, key dropped"
1648        );
1649        assert!(item.merged["skills"][0].get("x-rigg-auth").is_none());
1650        assert_eq!(
1651            item.auth,
1652            vec![AuthCarrier::Stripped {
1653                path: "skills[0]".to_string(),
1654                used_key: true
1655            }]
1656        );
1657        assert_eq!(item.rewired[0].binding, "enrich-fn");
1658        assert_eq!(item.rewired[0].target, Target::FunctionApp);
1659
1660        // The target already has its own carrier: it is kept.
1661        let plan = translate(
1662            &src,
1663            &EnvDocs {
1664                env: "prod".into(),
1665                bindings: bindings_prod(),
1666                docs: vec![doc(
1667                    ResourceKind::Skillset,
1668                    "sk",
1669                    json!({
1670                        "name": "sk",
1671                        "skills": [{
1672                            "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
1673                            "name": "enrich",
1674                            "uri": "https://fn-prod.azurewebsites.net/api/enrich",
1675                            "authResourceId": "api://prod-app",
1676                            "inputs": [],
1677                            "outputs": []
1678                        }]
1679                    }),
1680                )],
1681            },
1682        );
1683        let item = &plan.items[0];
1684        assert_eq!(
1685            item.merged["skills"][0]["authResourceId"],
1686            json!("api://prod-app"),
1687            "the target's own auth carrier survives the promote"
1688        );
1689        assert!(item.merged["skills"][0].get("x-rigg-auth").is_none());
1690        assert!(
1691            item.auth.contains(&AuthCarrier::Kept {
1692                path: "skills[0]".to_string()
1693            }),
1694            "{:?}",
1695            item.auth
1696        );
1697        assert!(
1698            item.auth
1699                .iter()
1700                .any(|a| matches!(a, AuthCarrier::Stripped { used_key: true, .. })),
1701            "{:?}",
1702            item.auth
1703        );
1704        assert_eq!(item.change(), Change::Unchanged, "{:?}", item.merged);
1705    }
1706
1707    #[test]
1708    fn kept_only_in_target_and_unchanged_classification() {
1709        let src = EnvDocs {
1710            env: "dev".into(),
1711            bindings: env("dev", "s-dev", "f-dev", &[]),
1712            docs: vec![doc(
1713                ResourceKind::Index,
1714                "idx",
1715                json!({"name": "idx", "fields": []}),
1716            )],
1717        };
1718        let tgt = EnvDocs {
1719            env: "prod".into(),
1720            bindings: env("prod", "s-prod", "f-prod", &[]),
1721            docs: vec![
1722                doc(
1723                    ResourceKind::Index,
1724                    "idx",
1725                    json!({"name": "idx", "fields": []}),
1726                ),
1727                doc(
1728                    ResourceKind::SynonymMap,
1729                    "syn",
1730                    json!({"name": "syn", "format": "solr", "synonyms": "a,b"}),
1731                ),
1732            ],
1733        };
1734
1735        let plan = translate(&src, &tgt);
1736        assert_eq!(plan.items.len(), 1);
1737        assert_eq!(plan.items[0].change(), Change::Unchanged);
1738        assert!(!plan.items[0].is_new);
1739        assert_eq!(
1740            plan.kept_only_in_to,
1741            vec![(ResourceKind::SynonymMap, "syn".to_string())]
1742        );
1743    }
1744
1745    #[test]
1746    fn a_target_document_without_a_name_key_never_takes_the_source_identity() {
1747        let src = EnvDocs {
1748            env: "dev".into(),
1749            bindings: env("dev", "s-dev", "f-dev", &[]),
1750            docs: vec![doc(
1751                ResourceKind::Agent,
1752                "regulus",
1753                json!({"name": "Regulus-dev", "model": "gpt-5-mini"}),
1754            )],
1755        };
1756        // The target's file has no `name` at all — its identity is the stem.
1757        let tgt = EnvDocs {
1758            env: "prod".into(),
1759            bindings: env("prod", "s-prod", "f-prod", &[]),
1760            docs: vec![doc(
1761                ResourceKind::Agent,
1762                "regulus",
1763                json!({"model": "gpt-4o-old"}),
1764            )],
1765        };
1766
1767        let plan = translate(&src, &tgt);
1768        let item = &plan.items[0];
1769        assert!(
1770            item.merged.get("name").is_none(),
1771            "the target's shape is kept, and `Regulus-dev` never crosses: {:?}",
1772            item.merged
1773        );
1774        assert_eq!(item.target_name, "regulus");
1775        assert_eq!(item.merged["model"], json!("gpt-5-mini"));
1776    }
1777
1778    #[test]
1779    fn a_target_documents_own_name_always_wins_over_the_sources() {
1780        let src = EnvDocs {
1781            env: "dev".into(),
1782            bindings: env("dev", "s-dev", "f-dev", &[]),
1783            docs: vec![doc(
1784                ResourceKind::Index,
1785                "docs-index",
1786                json!({"name": "docs-index-dev", "fields": []}),
1787            )],
1788        };
1789        let tgt = EnvDocs {
1790            env: "prod".into(),
1791            bindings: env("prod", "s-prod", "f-prod", &[]),
1792            docs: vec![doc(
1793                ResourceKind::Index,
1794                "docs-index",
1795                json!({"name": "docs-index-prod", "fields": []}),
1796            )],
1797        };
1798
1799        let plan = translate(&src, &tgt);
1800        assert_eq!(plan.items[0].merged["name"], json!("docs-index-prod"));
1801        assert_eq!(plan.items[0].target_name, "docs-index-prod");
1802    }
1803
1804    #[test]
1805    fn a_kept_auth_carrier_follows_the_named_skill_not_the_position() {
1806        // The source inserts a skill BEFORE the WebApiSkill, so the target's
1807        // `skills[0]` and the merged document's `skills[0]` are different
1808        // skills: matching by position would authorize the wrong one.
1809        let src = EnvDocs {
1810            env: "dev".into(),
1811            bindings: env(
1812                "dev",
1813                "s-dev",
1814                "f-dev",
1815                &[("enrich-fn", BindingType::FunctionApp, "fn-dev")],
1816            ),
1817            docs: vec![doc(
1818                ResourceKind::Skillset,
1819                "sk",
1820                json!({
1821                    "name": "sk",
1822                    "skills": [
1823                        {"@odata.type": "#Microsoft.Skills.Text.SplitSkill", "name": "split",
1824                         "inputs": [], "outputs": []},
1825                        {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill", "name": "enrich",
1826                         "uri": "https://fn-dev.azurewebsites.net/api/enrich",
1827                         "inputs": [], "outputs": []}
1828                    ]
1829                }),
1830            )],
1831        };
1832        let tgt = EnvDocs {
1833            env: "prod".into(),
1834            bindings: env(
1835                "prod",
1836                "s-prod",
1837                "f-prod",
1838                &[("enrich-fn", BindingType::FunctionApp, "fn-prod")],
1839            ),
1840            docs: vec![doc(
1841                ResourceKind::Skillset,
1842                "sk",
1843                json!({
1844                    "name": "sk",
1845                    "skills": [
1846                        {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill", "name": "enrich",
1847                         "uri": "https://fn-prod.azurewebsites.net/api/enrich",
1848                         "authResourceId": "api://prod-app",
1849                         "inputs": [], "outputs": []}
1850                    ]
1851                }),
1852            )],
1853        };
1854
1855        let plan = translate(&src, &tgt);
1856        let item = &plan.items[0];
1857        assert!(
1858            item.merged["skills"][0].get("authResourceId").is_none(),
1859            "the split skill is not the one the carrier authorizes: {:?}",
1860            item.merged["skills"][0]
1861        );
1862        assert_eq!(
1863            item.merged["skills"][1]["authResourceId"],
1864            json!("api://prod-app")
1865        );
1866        assert_eq!(
1867            item.auth,
1868            vec![AuthCarrier::Kept {
1869                path: "skills[1]".to_string()
1870            }]
1871        );
1872    }
1873
1874    #[test]
1875    fn an_api_reference_outside_the_bindings_prefix_is_external() {
1876        let skillset = |uri: &str| {
1877            doc(
1878                ResourceKind::Skillset,
1879                "sk",
1880                json!({
1881                    "name": "sk",
1882                    "skills": [{
1883                        "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
1884                        "name": "enrich", "uri": uri, "inputs": [], "outputs": []
1885                    }]
1886                }),
1887            )
1888        };
1889        let dev = |docs: Vec<Doc>| EnvDocs {
1890            env: "dev".into(),
1891            bindings: env(
1892                "dev",
1893                "s-dev",
1894                "f-dev",
1895                &[("partner", BindingType::Api, "https://api.x/v1")],
1896            ),
1897            docs,
1898        };
1899        let prod = EnvDocs {
1900            env: "prod".into(),
1901            bindings: env(
1902                "prod",
1903                "s-prod",
1904                "f-prod",
1905                &[("partner", BindingType::Api, "https://api.y/v2")],
1906            ),
1907            docs: vec![],
1908        };
1909
1910        // Same host, different path scope: the binding does not cover it.
1911        let plan = translate(&dev(vec![skillset("https://api.x/v2/enrich")]), &prod);
1912        assert_eq!(plan.pending.len(), 1, "{:?}", plan.pending);
1913        match &plan.pending[0] {
1914            Pending::External { host, used_by } => {
1915                assert_eq!(host, "api.x");
1916                assert_eq!(used_by.len(), 1);
1917                assert_eq!(used_by[0].2, "skills[0].uri");
1918            }
1919            other => panic!("expected External, got {other:?}"),
1920        }
1921        assert!(plan.items[0].rewired.is_empty());
1922        assert_eq!(
1923            plan.items[0].merged["skills"][0]["uri"],
1924            json!("https://api.x/v2/enrich"),
1925            "an unbound reference is left exactly as it was"
1926        );
1927
1928        // Inside the binding's prefix: rewired onto the target's base URL.
1929        let plan = translate(&dev(vec![skillset("https://api.x/v1/enrich")]), &prod);
1930        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
1931        assert_eq!(
1932            plan.items[0].merged["skills"][0]["uri"],
1933            json!("https://api.y/v2/enrich")
1934        );
1935        assert_eq!(plan.items[0].rewired[0].binding, "partner");
1936        assert_eq!(plan.items[0].rewired[0].target, Target::Api);
1937    }
1938
1939    #[test]
1940    fn every_rewritten_reference_value_is_recorded_at_its_own_concrete_path() {
1941        let src = EnvDocs {
1942            env: "dev".into(),
1943            bindings: env("dev", "s-dev", "f-dev", &[]),
1944            docs: vec![
1945                doc(
1946                    ResourceKind::Index,
1947                    "docs-index",
1948                    json!({"name": "docs-index-dev", "fields": []}),
1949                ),
1950                doc(
1951                    ResourceKind::Skillset,
1952                    "sk",
1953                    json!({
1954                        "name": "sk",
1955                        "skills": [],
1956                        "indexProjections": {"selectors": [
1957                            {"targetIndexName": "docs-index-dev", "parentKeyFieldName": "p"},
1958                            {"targetIndexName": "docs-index-dev", "parentKeyFieldName": "q"}
1959                        ]}
1960                    }),
1961                ),
1962            ],
1963        };
1964        let tgt = EnvDocs {
1965            env: "prod".into(),
1966            bindings: env("prod", "s-prod", "f-prod", &[]),
1967            docs: vec![doc(
1968                ResourceKind::Index,
1969                "docs-index",
1970                json!({"name": "docs-index", "fields": []}),
1971            )],
1972        };
1973
1974        let plan = translate(&src, &tgt);
1975        let skillset = item(&plan, ResourceKind::Skillset, "sk");
1976        let paths: Vec<&str> = skillset.renamed.iter().map(|r| r.path.as_str()).collect();
1977        assert_eq!(
1978            paths,
1979            vec![
1980                "indexProjections.selectors[0].targetIndexName",
1981                "indexProjections.selectors[1].targetIndexName"
1982            ],
1983            "one record per value rewritten, at its concrete path"
1984        );
1985        for selector in skillset.merged["indexProjections"]["selectors"]
1986            .as_array()
1987            .unwrap()
1988        {
1989            assert_eq!(selector["targetIndexName"], json!("docs-index"));
1990        }
1991    }
1992
1993    #[test]
1994    fn knowledge_source_storage_and_embedding_host_are_rewired() {
1995        let ks = |acct: &str, host: &str| {
1996            doc(
1997                ResourceKind::KnowledgeSource,
1998                "docs-ks",
1999                json!({
2000                    "name": "docs-ks",
2001                    "kind": "azureBlob",
2002                    "azureBlobParameters": {
2003                        "connectionString": conn_string(acct),
2004                        "containerName": "c",
2005                        "ingestionParameters": {
2006                            "embeddingModel": {"azureOpenAIParameters": {
2007                                "resourceUri": format!("https://{host}.openai.azure.com"),
2008                                "deploymentId": "embed"
2009                            }}
2010                        }
2011                    }
2012                }),
2013            )
2014        };
2015        let src = EnvDocs {
2016            env: "dev".into(),
2017            bindings: env(
2018                "dev",
2019                "s-dev",
2020                "f-dev",
2021                &[("docs", BindingType::Storage, DEV_ACCT)],
2022            ),
2023            docs: vec![ks(DEV_ACCT, "f-dev")],
2024        };
2025        let tgt = EnvDocs {
2026            env: "prod".into(),
2027            bindings: env(
2028                "prod",
2029                "s-prod",
2030                "f-prod",
2031                &[("docs", BindingType::Storage, PROD_ACCT)],
2032            ),
2033            docs: vec![],
2034        };
2035
2036        let plan = translate(&src, &tgt);
2037        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2038        let item = &plan.items[0];
2039        assert_eq!(
2040            item.merged["azureBlobParameters"]["connectionString"],
2041            json!(conn_string(PROD_ACCT))
2042        );
2043        assert_eq!(
2044            item.merged["azureBlobParameters"]["ingestionParameters"]["embeddingModel"]["azureOpenAIParameters"]
2045                ["resourceUri"],
2046            json!("https://f-prod.openai.azure.com")
2047        );
2048        let bindings: Vec<&str> = item.rewired.iter().map(|r| r.binding.as_str()).collect();
2049        assert_eq!(bindings, vec!["docs", "foundry"], "{:?}", item.rewired);
2050    }
2051
2052    #[test]
2053    fn knowledge_base_models_are_rewired_and_its_sources_follow_the_rename() {
2054        let src = EnvDocs {
2055            env: "dev".into(),
2056            bindings: env("dev", "s-dev", "f-dev", &[]),
2057            docs: vec![
2058                doc(
2059                    ResourceKind::KnowledgeSource,
2060                    "docs-ks",
2061                    json!({"name": "docs-ks-dev", "kind": "searchIndex"}),
2062                ),
2063                doc(
2064                    ResourceKind::KnowledgeBase,
2065                    "kb",
2066                    json!({
2067                        "name": "kb",
2068                        "knowledgeSources": [{"name": "docs-ks-dev", "kind": "searchIndex"}],
2069                        "models": [{"azureOpenAIParameters": {
2070                            "resourceUri": "https://f-dev.openai.azure.com",
2071                            "deploymentId": "chat"
2072                        }}]
2073                    }),
2074                ),
2075            ],
2076        };
2077        let tgt = EnvDocs {
2078            env: "prod".into(),
2079            bindings: env("prod", "s-prod", "f-prod", &[]),
2080            docs: vec![doc(
2081                ResourceKind::KnowledgeSource,
2082                "docs-ks",
2083                json!({"name": "docs-ks", "kind": "searchIndex"}),
2084            )],
2085        };
2086
2087        let plan = translate(&src, &tgt);
2088        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2089        let kb = item(&plan, ResourceKind::KnowledgeBase, "kb");
2090        assert_eq!(
2091            kb.merged["models"][0]["azureOpenAIParameters"]["resourceUri"],
2092            json!("https://f-prod.openai.azure.com")
2093        );
2094        assert_eq!(kb.rewired[0].binding, "foundry");
2095        assert_eq!(kb.rewired[0].target, Target::ModelHost);
2096        assert_eq!(kb.merged["knowledgeSources"][0]["name"], json!("docs-ks"));
2097        assert_eq!(kb.renamed.len(), 1, "{:?}", kb.renamed);
2098        assert_eq!(kb.renamed[0].path, "knowledgeSources[0].name");
2099        assert_eq!(kb.renamed[0].kind, ResourceKind::KnowledgeSource);
2100        assert_eq!(kb.renamed[0].from, "docs-ks-dev");
2101        assert_eq!(kb.renamed[0].to, "docs-ks");
2102    }
2103
2104    #[test]
2105    fn connection_target_url_is_rewired_to_the_target_search_service() {
2106        let mcp = |svc: &str, kb: &str| {
2107            format!(
2108                "https://{svc}.search.windows.net/knowledgebases/{kb}/mcp?api-version={SEARCH_PREVIEW_API_VERSION}"
2109            )
2110        };
2111        let src = EnvDocs {
2112            env: "dev".into(),
2113            bindings: env("dev", "s-dev", "f-dev", &[]),
2114            docs: vec![
2115                doc(ResourceKind::KnowledgeBase, "kb", json!({"name": "kb-dev"})),
2116                doc(
2117                    ResourceKind::Connection,
2118                    "kb-conn",
2119                    json!({
2120                        "name": "kb-conn",
2121                        "properties": {
2122                            "category": "CustomKeys",
2123                            "authType": "AAD",
2124                            "target": mcp("s-dev", "kb-dev")
2125                        }
2126                    }),
2127                ),
2128            ],
2129        };
2130        let tgt = EnvDocs {
2131            env: "prod".into(),
2132            bindings: env("prod", "s-prod", "f-prod", &[]),
2133            docs: vec![doc(
2134                ResourceKind::KnowledgeBase,
2135                "kb",
2136                json!({"name": "kb"}),
2137            )],
2138        };
2139
2140        let plan = translate(&src, &tgt);
2141        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2142        let conn = item(&plan, ResourceKind::Connection, "kb-conn");
2143        assert_eq!(
2144            conn.merged["properties"]["target"],
2145            json!(mcp("s-prod", "kb")),
2146            "both the search service and the renamed knowledge base follow"
2147        );
2148        assert_eq!(conn.rewired.len(), 1);
2149        assert_eq!(conn.rewired[0].path, "properties.target");
2150        assert_eq!(conn.rewired[0].binding, "search");
2151        assert_eq!(conn.rewired[0].target, Target::SearchService);
2152        assert_eq!(conn.rewired[0].to, "s-prod");
2153    }
2154
2155    #[test]
2156    fn agent_model_and_connection_references_follow_the_target_names() {
2157        let src = EnvDocs {
2158            env: "dev".into(),
2159            bindings: env("dev", "s-dev", "f-dev", &[]),
2160            docs: vec![
2161                doc(
2162                    ResourceKind::Agent,
2163                    "regulus",
2164                    json!({
2165                        "name": "Regulus",
2166                        "model": "gpt-5-mini-dev",
2167                        "tools": [{"type": "azure_ai_search", "project_connection_id": "aoai-dev"}]
2168                    }),
2169                ),
2170                doc(
2171                    ResourceKind::Deployment,
2172                    "chat",
2173                    json!({"name": "gpt-5-mini-dev", "properties": {"model": {"name": "gpt-5-mini"}}}),
2174                ),
2175                doc(
2176                    ResourceKind::Connection,
2177                    "aoai",
2178                    json!({"name": "aoai-dev", "properties": {"category": "AzureOpenAI"}}),
2179                ),
2180            ],
2181        };
2182        let tgt = EnvDocs {
2183            env: "prod".into(),
2184            bindings: env("prod", "s-prod", "f-prod", &[]),
2185            docs: vec![
2186                doc(
2187                    ResourceKind::Deployment,
2188                    "chat",
2189                    json!({"name": "gpt-5-mini", "properties": {"model": {"name": "gpt-5-mini"}}}),
2190                ),
2191                doc(
2192                    ResourceKind::Connection,
2193                    "aoai",
2194                    json!({"name": "aoai", "properties": {"category": "AzureOpenAI"}}),
2195                ),
2196            ],
2197        };
2198
2199        let plan = translate(&src, &tgt);
2200        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2201        let agent = item(&plan, ResourceKind::Agent, "regulus");
2202        assert_eq!(agent.merged["model"], json!("gpt-5-mini"));
2203        assert_eq!(
2204            agent.merged["tools"][0]["project_connection_id"],
2205            json!("aoai")
2206        );
2207        let renamed: Vec<(&str, &str, &str)> = agent
2208            .renamed
2209            .iter()
2210            .map(|r| (r.path.as_str(), r.from.as_str(), r.to.as_str()))
2211            .collect();
2212        assert_eq!(
2213            renamed,
2214            vec![
2215                ("model", "gpt-5-mini-dev", "gpt-5-mini"),
2216                ("tools[0].project_connection_id", "aoai-dev", "aoai")
2217            ]
2218        );
2219        assert_eq!(
2220            agent.merged["name"],
2221            json!("Regulus"),
2222            "a new-in-target agent keeps the source's name"
2223        );
2224    }
2225
2226    #[test]
2227    fn sibling_names_swapped_between_environments_are_not_collapsed() {
2228        // dev `a` = `ks-1`, `b` = `ks-2`; prod has them the other way round.
2229        // Each reference must be mapped by the value it had BEFORE any
2230        // rewrite — a whole-document rename pass would rewrite `ks-1` to
2231        // `ks-2` and then that same value back to `ks-1`.
2232        let ks = |stem: &str, name: &str| {
2233            doc(
2234                ResourceKind::KnowledgeSource,
2235                stem,
2236                json!({"name": name, "kind": "searchIndex"}),
2237            )
2238        };
2239        let kb = |first: &str, second: &str| {
2240            doc(
2241                ResourceKind::KnowledgeBase,
2242                "kb",
2243                json!({
2244                    "name": "kb",
2245                    "knowledgeSources": [{"name": first}, {"name": second}]
2246                }),
2247            )
2248        };
2249        let src = EnvDocs {
2250            env: "dev".into(),
2251            bindings: env("dev", "s-dev", "f-dev", &[]),
2252            docs: vec![ks("a", "ks-1"), ks("b", "ks-2"), kb("ks-1", "ks-2")],
2253        };
2254        let tgt = EnvDocs {
2255            env: "prod".into(),
2256            bindings: env("prod", "s-prod", "f-prod", &[]),
2257            docs: vec![ks("a", "ks-2"), ks("b", "ks-1"), kb("ks-2", "ks-1")],
2258        };
2259
2260        let plan = translate(&src, &tgt);
2261        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2262        let kb = item(&plan, ResourceKind::KnowledgeBase, "kb");
2263        assert_eq!(
2264            kb.merged["knowledgeSources"],
2265            json!([{"name": "ks-2"}, {"name": "ks-1"}]),
2266            "each reference follows its OWN sibling across the swap"
2267        );
2268        let renamed: Vec<(&str, &str, &str, &str)> = kb
2269            .renamed
2270            .iter()
2271            .map(|r| {
2272                (
2273                    r.path.as_str(),
2274                    r.stem.as_str(),
2275                    r.from.as_str(),
2276                    r.to.as_str(),
2277                )
2278            })
2279            .collect();
2280        assert_eq!(
2281            renamed,
2282            vec![
2283                ("knowledgeSources[0].name", "a", "ks-1", "ks-2"),
2284                ("knowledgeSources[1].name", "b", "ks-2", "ks-1"),
2285            ]
2286        );
2287        assert_eq!(kb.change(), Change::Unchanged);
2288    }
2289
2290    #[test]
2291    fn a_chain_of_sibling_renames_maps_each_value_by_its_own_source_name() {
2292        // dev x/y/z → prod y/z/w: applied as whole-document passes, `x`
2293        // would be rewritten to `y`, then to `z`, then to `w`.
2294        let ks = |stem: &str, name: &str| {
2295            doc(
2296                ResourceKind::KnowledgeSource,
2297                stem,
2298                json!({"name": name, "kind": "searchIndex"}),
2299            )
2300        };
2301        let src = EnvDocs {
2302            env: "dev".into(),
2303            bindings: env("dev", "s-dev", "f-dev", &[]),
2304            docs: vec![
2305                ks("a", "x"),
2306                ks("b", "y"),
2307                ks("c", "z"),
2308                doc(
2309                    ResourceKind::KnowledgeBase,
2310                    "kb",
2311                    json!({
2312                        "name": "kb",
2313                        "knowledgeSources": [{"name": "x"}, {"name": "y"}, {"name": "z"}]
2314                    }),
2315                ),
2316            ],
2317        };
2318        let tgt = EnvDocs {
2319            env: "prod".into(),
2320            bindings: env("prod", "s-prod", "f-prod", &[]),
2321            docs: vec![ks("a", "y"), ks("b", "z"), ks("c", "w")],
2322        };
2323
2324        let plan = translate(&src, &tgt);
2325        let kb = item(&plan, ResourceKind::KnowledgeBase, "kb");
2326        assert_eq!(
2327            kb.merged["knowledgeSources"],
2328            json!([{"name": "y"}, {"name": "z"}, {"name": "w"}])
2329        );
2330    }
2331
2332    #[test]
2333    fn a_kept_function_key_carrier_leaves_a_redacted_code_placeholder() {
2334        // The target authenticates with a key in the URI: promote keeps the
2335        // annotation, and the translated URI must carry the placeholder the
2336        // auth gate looks for — not read as an anonymous endpoint.
2337        let src = EnvDocs {
2338            env: "dev".into(),
2339            bindings: env(
2340                "dev",
2341                "s-dev",
2342                "f-dev",
2343                &[("enrich-fn", BindingType::FunctionApp, "fn-dev")],
2344            ),
2345            docs: vec![doc(
2346                ResourceKind::Skillset,
2347                "sk",
2348                json!({
2349                    "name": "sk",
2350                    "skills": [{
2351                        "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
2352                        "name": "enrich",
2353                        "uri": "https://fn-dev.azurewebsites.net/api/enrich?code=<redacted>",
2354                        "x-rigg-auth": "function-key",
2355                        "inputs": [],
2356                        "outputs": []
2357                    }]
2358                }),
2359            )],
2360        };
2361        let tgt = EnvDocs {
2362            env: "prod".into(),
2363            bindings: env(
2364                "prod",
2365                "s-prod",
2366                "f-prod",
2367                &[("enrich-fn", BindingType::FunctionApp, "fn-prod")],
2368            ),
2369            docs: vec![doc(
2370                ResourceKind::Skillset,
2371                "sk",
2372                json!({
2373                    "name": "sk",
2374                    "skills": [{
2375                        "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
2376                        "name": "enrich",
2377                        "uri": "https://fn-prod.azurewebsites.net/api/enrich?code=<redacted>",
2378                        "x-rigg-auth": "function-key",
2379                        "inputs": [],
2380                        "outputs": []
2381                    }]
2382                }),
2383            )],
2384        };
2385
2386        let plan = translate(&src, &tgt);
2387        let item = &plan.items[0];
2388        assert_eq!(
2389            item.merged["skills"][0]["x-rigg-auth"],
2390            json!("function-key")
2391        );
2392        assert_eq!(
2393            item.merged["skills"][0]["uri"],
2394            json!("https://fn-prod.azurewebsites.net/api/enrich?code=<redacted>"),
2395            "the kept key carrier leaves the placeholder the auth gate reads"
2396        );
2397        assert_eq!(item.change(), Change::Unchanged, "{:?}", item.merged);
2398    }
2399
2400    #[test]
2401    fn connection_targets_that_are_not_kb_mcp_urls_are_rewired_too() {
2402        let src = EnvDocs {
2403            env: "dev".into(),
2404            bindings: env("dev", "s-dev", "f-dev", &[]),
2405            docs: vec![
2406                doc(
2407                    ResourceKind::Connection,
2408                    "search",
2409                    json!({
2410                        "name": "search",
2411                        "properties": {
2412                            "category": "CognitiveSearch",
2413                            "authType": "AAD",
2414                            "target": "https://s-dev.search.windows.net"
2415                        }
2416                    }),
2417                ),
2418                doc(
2419                    ResourceKind::Connection,
2420                    "aoai",
2421                    json!({
2422                        "name": "aoai",
2423                        "properties": {
2424                            "category": "AzureOpenAI",
2425                            "authType": "AAD",
2426                            "target": "https://f-dev.openai.azure.com/"
2427                        }
2428                    }),
2429                ),
2430            ],
2431        };
2432        let tgt = EnvDocs {
2433            env: "prod".into(),
2434            bindings: env("prod", "s-prod", "f-prod", &[]),
2435            docs: vec![],
2436        };
2437
2438        let plan = translate(&src, &tgt);
2439        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2440
2441        let search = item(&plan, ResourceKind::Connection, "search");
2442        assert_eq!(
2443            search.merged["properties"]["target"],
2444            json!("https://s-prod.search.windows.net")
2445        );
2446        assert_eq!(search.rewired[0].binding, "search");
2447        assert_eq!(search.rewired[0].target, Target::SearchService);
2448        assert_eq!(search.rewired[0].to, "s-prod");
2449
2450        let aoai = item(&plan, ResourceKind::Connection, "aoai");
2451        assert_eq!(
2452            aoai.merged["properties"]["target"],
2453            json!("https://f-prod.openai.azure.com/"),
2454            "a model-host target rewires through the implicit foundry binding"
2455        );
2456        assert_eq!(aoai.rewired[0].binding, "foundry");
2457        assert_eq!(aoai.rewired[0].target, Target::ModelHost);
2458    }
2459
2460    #[test]
2461    fn an_agent_tool_server_url_that_is_a_function_endpoint_is_rewired() {
2462        let src = EnvDocs {
2463            env: "dev".into(),
2464            bindings: env(
2465                "dev",
2466                "s-dev",
2467                "f-dev",
2468                &[("tools-fn", BindingType::FunctionApp, "fn-dev")],
2469            ),
2470            docs: vec![doc(
2471                ResourceKind::Agent,
2472                "regulus",
2473                json!({
2474                    "name": "Regulus",
2475                    "model": "gpt-5-mini",
2476                    "tools": [{"type": "mcp", "server_url": "https://fn-dev.azurewebsites.net/runtime/webhooks/mcp"}]
2477                }),
2478            )],
2479        };
2480        let tgt = EnvDocs {
2481            env: "prod".into(),
2482            bindings: env(
2483                "prod",
2484                "s-prod",
2485                "f-prod",
2486                &[("tools-fn", BindingType::FunctionApp, "fn-prod")],
2487            ),
2488            docs: vec![],
2489        };
2490
2491        let plan = translate(&src, &tgt);
2492        assert!(plan.pending.is_empty(), "{:?}", plan.pending);
2493        let agent = item(&plan, ResourceKind::Agent, "regulus");
2494        assert_eq!(
2495            agent.merged["tools"][0]["server_url"],
2496            json!("https://fn-prod.azurewebsites.net/runtime/webhooks/mcp")
2497        );
2498        assert_eq!(agent.rewired[0].binding, "tools-fn");
2499        assert_eq!(agent.rewired[0].target, Target::FunctionApp);
2500    }
2501}