1use 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
43const FUNCTION_KEY_HEADER: &str = "x-functions-key";
45const REDACTED_KEY: &str = "<redacted>";
48
49pub type Usage = (ResourceKind, String, String);
52
53#[derive(Debug, Clone)]
56pub struct EnvDocs {
57 pub env: String,
58 pub bindings: EnvBindings,
59 pub docs: Vec<Doc>,
60}
61
62#[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#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Rewire {
75 pub path: String,
77 pub binding: String,
79 pub target: Target,
80 pub from: String,
82 pub to: String,
84 pub shared: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Renamed {
92 pub path: String,
95 pub kind: ResourceKind,
97 pub stem: String,
99 pub from: String,
100 pub to: String,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum AuthCarrier {
106 Kept { path: String },
109 Stripped { path: String, used_key: bool },
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Change {
119 New,
120 Changed,
121 Unchanged,
122}
123
124#[derive(Debug, Clone)]
126pub struct Item {
127 pub kind: ResourceKind,
128 pub stem: String,
129 pub target_name: String,
131 pub is_new: bool,
132 pub before: Option<Value>,
134 pub merged: Value,
135 pub rewired: Vec<Rewire>,
136 pub renamed: Vec<Renamed>,
137 pub auth: Vec<AuthCarrier>,
138 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 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#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Pending {
170 UnboundInSource {
173 kind: ResourceKind,
174 stem: String,
175 path: String,
176 target: Target,
177 physical: String,
178 proposed_name: String,
179 },
180 MissingInTarget {
183 binding: String,
184 binding_type: Option<BindingType>,
185 source_physical: String,
186 used_by: Vec<Usage>,
187 },
188 UnresolvedTarget {
193 binding: String,
194 binding_type: Option<BindingType>,
195 used_by: Vec<Usage>,
196 },
197 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#[derive(Debug, Clone)]
216pub struct Plan {
217 pub from: String,
218 pub to: String,
219 pub items: Vec<Item>,
222 pub kept_only_in_to: Vec<(ResourceKind, String)>,
224 pub pending: Vec<Pending>,
225}
226
227pub 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 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 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
311pub 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
336pub 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
357fn 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
415pub 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
450fn 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 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 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 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 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
590fn 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
615fn 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
623struct 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 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 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 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
727fn 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
761fn 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
790fn 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
825fn 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 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 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
908struct 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
923fn 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#[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
1030fn 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 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 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 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 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 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 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 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 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 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 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}