1use std::collections::BTreeMap;
11use std::io;
12use std::path::PathBuf;
13
14use serde::{Deserialize, Serialize};
15
16use crate::workspace::{Environment, STATE_DIR, Workspace};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Binding {
23 pub kind: BindingType,
24 pub value: String,
25}
26
27impl<'de> Deserialize<'de> for Binding {
28 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
29 let map = BTreeMap::<String, String>::deserialize(d)?;
30 if map.len() != 1 {
31 return Err(serde::de::Error::custom(
32 "a binding is exactly one `<type>: <value>` pair",
33 ));
34 }
35 let (k, value) = map.into_iter().next().expect("one entry");
36 let kind = k.parse::<BindingType>().map_err(serde::de::Error::custom)?;
37 if value.trim().is_empty() {
38 return Err(serde::de::Error::custom(format!(
39 "binding `{k}` has an empty value"
40 )));
41 }
42 Ok(Binding { kind, value })
43 }
44}
45
46impl Serialize for Binding {
47 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
48 use serde::ser::SerializeMap;
49 let mut map = s.serialize_map(Some(1))?;
50 map.serialize_entry(&self.kind.to_string(), &self.value)?;
51 map.end()
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
57pub enum BindingType {
58 Storage,
59 AiServices,
60 FunctionApp,
61 Identity,
62 KeyVault,
63 Api,
64}
65
66impl BindingType {
67 const ALL: [(&'static str, BindingType); 6] = [
68 ("storage", BindingType::Storage),
69 ("ai-services", BindingType::AiServices),
70 ("function-app", BindingType::FunctionApp),
71 ("identity", BindingType::Identity),
72 ("key-vault", BindingType::KeyVault),
73 ("api", BindingType::Api),
74 ];
75}
76
77impl std::fmt::Display for BindingType {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 let name = Self::ALL
80 .iter()
81 .find(|(_, t)| *t == *self)
82 .map(|(name, _)| *name)
83 .expect("all variants covered");
84 write!(f, "{name}")
85 }
86}
87
88impl std::str::FromStr for BindingType {
89 type Err = String;
90
91 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
92 Self::ALL
93 .iter()
94 .find(|(name, _)| *name == s)
95 .map(|(_, t)| *t)
96 .ok_or_else(|| {
97 format!(
98 "unknown binding type '{s}' (expected one of: storage, ai-services, \
99 function-app, identity, key-vault, api)"
100 )
101 })
102 }
103}
104
105impl Serialize for BindingType {
109 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
110 s.serialize_str(&self.to_string())
111 }
112}
113
114impl<'de> Deserialize<'de> for BindingType {
115 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
116 let s = String::deserialize(d)?;
117 s.parse().map_err(serde::de::Error::custom)
118 }
119}
120
121pub const RESERVED_BINDING_NAMES: [&str; 2] = ["search", "foundry"];
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum TargetKind {
131 Binding(BindingType),
133 Search,
135 Foundry,
138}
139
140impl TargetKind {
141 pub fn binding_type(&self) -> Option<BindingType> {
143 match self {
144 TargetKind::Binding(t) => Some(*t),
145 _ => None,
146 }
147 }
148}
149
150impl From<BindingType> for TargetKind {
151 fn from(t: BindingType) -> Self {
152 TargetKind::Binding(t)
153 }
154}
155
156impl std::fmt::Display for TargetKind {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 TargetKind::Binding(t) => write!(f, "{t}"),
160 TargetKind::Search => write!(f, "search"),
161 TargetKind::Foundry => write!(f, "foundry"),
162 }
163 }
164}
165
166impl std::str::FromStr for TargetKind {
167 type Err = String;
168
169 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
170 match s {
171 "search" => Ok(TargetKind::Search),
172 "foundry" => Ok(TargetKind::Foundry),
173 other => other.parse::<BindingType>().map(TargetKind::Binding),
174 }
175 }
176}
177
178impl Serialize for TargetKind {
179 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
180 s.serialize_str(&self.to_string())
181 }
182}
183
184impl<'de> Deserialize<'de> for TargetKind {
185 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
186 let s = String::deserialize(d)?;
187 s.parse().map_err(serde::de::Error::custom)
188 }
189}
190
191pub fn validate_binding_name(name: &str) -> std::result::Result<(), String> {
194 if name.is_empty() {
195 return Err("binding name must not be empty".to_string());
196 }
197 if RESERVED_BINDING_NAMES.contains(&name) {
198 return Err(format!(
199 "'{name}' is a reserved name (used for the search/foundry target) and cannot be a \
200 dependency binding"
201 ));
202 }
203 let valid_chars = name
204 .chars()
205 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
206 if !valid_chars || name.starts_with('-') || name.ends_with('-') || name.contains("--") {
207 return Err(format!(
208 "binding name '{name}' must be lowercase kebab-case (letters, digits, single \
209 hyphens; no leading/trailing hyphen)"
210 ));
211 }
212 Ok(())
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum BindingValue {
218 Name(String),
220 ArmId(String),
222 Url(String),
224}
225
226impl Binding {
227 pub fn value(&self) -> BindingValue {
229 if self.value.starts_with("/subscriptions/") {
230 BindingValue::ArmId(self.value.clone())
231 } else if self.value.starts_with("http://") || self.value.starts_with("https://") {
232 BindingValue::Url(self.value.clone())
233 } else {
234 BindingValue::Name(self.value.clone())
235 }
236 }
237
238 pub fn physical_name(&self) -> String {
242 match self.value() {
243 BindingValue::Name(n) => n.to_lowercase(),
244 BindingValue::ArmId(id) => arm_resource_name(&id).unwrap_or(id.as_str()).to_lowercase(),
245 BindingValue::Url(u) => url_host(&u).to_lowercase(),
246 }
247 }
248
249 pub fn arm_id(&self) -> Option<&str> {
251 match self.value() {
252 BindingValue::ArmId(_) => Some(self.value.as_str()),
253 _ => None,
254 }
255 }
256}
257
258fn url_host(u: &str) -> String {
260 let after_scheme = u.splitn(2, "://").last().unwrap_or(u);
261 let host_and_rest = after_scheme.split('/').next().unwrap_or(after_scheme);
262 let host_port = host_and_rest.rsplit('@').next().unwrap_or(host_and_rest);
263 let host = host_port.split(':').next().unwrap_or(host_port);
264 host.to_string()
265}
266
267pub fn arm_resource_name(id: &str) -> Option<&str> {
269 let trimmed = id.trim_end_matches('/');
270 trimmed.rsplit('/').next().filter(|s| !s.is_empty())
271}
272
273pub fn arm_subscription(id: &str) -> Option<&str> {
275 arm_segment(id, "subscriptions")
276}
277
278pub fn arm_resource_group(id: &str) -> Option<&str> {
280 arm_segment(id, "resourceGroups")
281}
282
283fn arm_segment<'a>(id: &'a str, key: &str) -> Option<&'a str> {
284 let parts: Vec<&str> = id.split('/').collect();
285 parts
286 .iter()
287 .position(|p| p.eq_ignore_ascii_case(key))
288 .and_then(|i| parts.get(i + 1).copied())
289 .filter(|s| !s.is_empty())
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct ResolvedBinding {
297 pub name: String,
302 pub kind: TargetKind,
303 pub physical_name: String,
304 pub arm_id: Option<String>,
305 pub subscription: Option<String>,
306 pub resource_group: Option<String>,
307 pub location: Option<String>,
308 pub endpoint: Option<String>,
309 #[serde(default)]
312 pub principal_id: Option<String>,
313 pub resolved_at: String,
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
320pub struct BindingCache {
321 #[serde(default)]
322 pub bindings: BTreeMap<String, ResolvedBinding>,
323}
324
325impl BindingCache {
326 pub fn path(ws: &Workspace, env: &str) -> PathBuf {
327 ws.files_root()
328 .join(STATE_DIR)
329 .join(env)
330 .join("bindings.json")
331 }
332
333 pub fn load(ws: &Workspace, env: &str) -> BindingCache {
334 let path = Self::path(ws, env);
335 std::fs::read_to_string(&path)
336 .ok()
337 .and_then(|text| serde_json::from_str(&text).ok())
338 .unwrap_or_default()
339 }
340
341 pub fn save(&self, ws: &Workspace, env: &str) -> io::Result<()> {
342 let path = Self::path(ws, env);
343 if let Some(parent) = path.parent() {
344 std::fs::create_dir_all(parent)?;
345 }
346 let json = serde_json::to_string_pretty(self).unwrap_or_default();
347 std::fs::write(&path, json)
348 }
349
350 pub fn get(&self, name: &str) -> Option<&ResolvedBinding> {
351 self.bindings.get(name)
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum BindingKind {
358 Declared(BindingType),
360 ImplicitSearch,
363 ImplicitFoundry,
366}
367
368#[derive(Debug, Clone)]
370pub struct BindingEntry {
371 pub name: String,
372 pub kind: BindingKind,
373 pub physical_name: String,
374 pub declared: Option<Binding>,
376 pub resolved: Option<ResolvedBinding>,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum Wanted {
383 Type(BindingType),
385 SearchService,
387 ModelHost,
390}
391
392impl Wanted {
393 pub fn for_binding(kind: BindingType) -> Wanted {
398 match kind {
399 BindingType::AiServices => Wanted::ModelHost,
400 other => Wanted::Type(other),
401 }
402 }
403
404 fn matches(&self, kind: BindingKind) -> bool {
405 match (self, kind) {
406 (Wanted::Type(t), BindingKind::Declared(k)) => *t == k,
407 (Wanted::SearchService, BindingKind::ImplicitSearch) => true,
408 (Wanted::ModelHost, BindingKind::Declared(BindingType::AiServices)) => true,
409 (Wanted::ModelHost, BindingKind::ImplicitFoundry) => true,
410 _ => false,
411 }
412 }
413}
414
415#[derive(Debug, Clone)]
418pub struct EnvBindings {
419 pub env: String,
420 entries: BTreeMap<String, BindingEntry>,
421}
422
423impl EnvBindings {
424 pub fn of_env(name: &str, env: &Environment, cache: Option<&BindingCache>) -> EnvBindings {
428 let mut entries = BTreeMap::new();
429
430 if let Some(search) = &env.search {
431 let physical_name = search.service.to_lowercase();
432 entries.insert(
433 "search".to_string(),
434 BindingEntry {
435 name: "search".to_string(),
436 kind: BindingKind::ImplicitSearch,
437 physical_name,
438 declared: None,
439 resolved: cache.and_then(|c| c.get("search")).cloned(),
440 },
441 );
442 }
443 if let Some(foundry) = &env.foundry {
444 let physical_name = foundry.account.to_lowercase();
445 entries.insert(
446 "foundry".to_string(),
447 BindingEntry {
448 name: "foundry".to_string(),
449 kind: BindingKind::ImplicitFoundry,
450 physical_name,
451 declared: None,
452 resolved: cache.and_then(|c| c.get("foundry")).cloned(),
453 },
454 );
455 }
456 for (dep_name, binding) in &env.dependencies {
457 entries.insert(
458 dep_name.clone(),
459 BindingEntry {
460 name: dep_name.clone(),
461 kind: BindingKind::Declared(binding.kind),
462 physical_name: binding.physical_name(),
463 declared: Some(binding.clone()),
464 resolved: cache.and_then(|c| c.get(dep_name)).cloned(),
465 },
466 );
467 }
468
469 EnvBindings {
470 env: name.to_string(),
471 entries,
472 }
473 }
474
475 pub fn of(env_name: &str, env: &Environment, cache: Option<&BindingCache>) -> EnvBindings {
478 Self::of_env(env_name, env, cache)
479 }
480
481 pub fn get(&self, name: &str) -> Option<&BindingEntry> {
482 self.entries.get(name)
483 }
484
485 pub fn iter(&self) -> impl Iterator<Item = &BindingEntry> {
486 self.entries.values()
487 }
488
489 pub fn find_physical(&self, wanted: Wanted, physical: &str) -> Option<&BindingEntry> {
492 let physical = physical.to_lowercase();
493 self.entries
494 .values()
495 .find(|e| e.physical_name == physical && wanted.matches(e.kind))
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use crate::workspace::{FoundryConnection, SearchConnection, WORKSPACE_FILE};
503
504 #[test]
505 fn binding_value_forms_and_physical_names() {
506 let name = Binding {
507 kind: BindingType::Storage,
508 value: "MKLabStorage".into(),
509 };
510 assert!(matches!(name.value(), BindingValue::Name(_)));
511 assert_eq!(name.physical_name(), "mklabstorage");
512
513 let id = Binding {
514 kind: BindingType::Storage,
515 value: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct".into(),
516 };
517 assert_eq!(id.physical_name(), "acct");
518 assert_eq!(arm_subscription(id.arm_id().unwrap()), Some("s"));
519 assert_eq!(arm_resource_group(id.arm_id().unwrap()), Some("rg"));
520
521 let api = Binding {
522 kind: BindingType::Api,
523 value: "https://Api.Partner.example/v1/".into(),
524 };
525 assert!(matches!(api.value(), BindingValue::Url(_)));
526 assert_eq!(api.physical_name(), "api.partner.example");
527 }
528
529 #[test]
530 fn env_bindings_include_implicit_search_and_foundry_and_match_model_hosts() {
531 let env = Environment {
532 search: Some(SearchConnection {
533 service: "mklabsrch".into(),
534 ..Default::default()
535 }),
536 foundry: Some(FoundryConnection {
537 account: "mklabaifndr".into(),
538 project: "p".into(),
539 ..Default::default()
540 }),
541 dependencies: [(
542 "enrichment".to_string(),
543 Binding {
544 kind: BindingType::AiServices,
545 value: "mklabaisrvc".into(),
546 },
547 )]
548 .into_iter()
549 .collect(),
550 ..Default::default()
551 };
552 let b = EnvBindings::of_env("dev", &env, None);
553 assert_eq!(b.get("search").unwrap().physical_name, "mklabsrch");
554 assert!(matches!(
555 b.get("foundry").unwrap().kind,
556 BindingKind::ImplicitFoundry
557 ));
558 assert_eq!(
559 b.find_physical(Wanted::ModelHost, "MKLABAIFNDR")
560 .unwrap()
561 .name,
562 "foundry"
563 );
564 assert_eq!(
565 b.find_physical(Wanted::ModelHost, "mklabaisrvc")
566 .unwrap()
567 .name,
568 "enrichment"
569 );
570 assert!(
571 b.find_physical(Wanted::Type(BindingType::Storage), "x")
572 .is_none()
573 );
574 }
575
576 #[test]
577 fn target_kind_serializes_as_a_plain_keyword_and_reads_old_caches() {
578 for (kind, word) in [
579 (TargetKind::Binding(BindingType::Storage), "storage"),
580 (TargetKind::Binding(BindingType::AiServices), "ai-services"),
581 (TargetKind::Search, "search"),
582 (TargetKind::Foundry, "foundry"),
583 ] {
584 assert_eq!(serde_json::to_value(kind).unwrap(), serde_json::json!(word));
585 assert_eq!(
586 serde_json::from_value::<TargetKind>(serde_json::json!(word)).unwrap(),
587 kind
588 );
589 }
590 assert_eq!(
591 TargetKind::Binding(BindingType::KeyVault).binding_type(),
592 Some(BindingType::KeyVault)
593 );
594 assert_eq!(TargetKind::Search.binding_type(), None);
595 assert!(serde_json::from_value::<TargetKind>(serde_json::json!("cosmos")).is_err());
596 }
597
598 #[test]
599 fn wanted_for_binding_matches_classify_and_lets_ai_services_share_foundry() {
600 assert_eq!(
601 Wanted::for_binding(BindingType::AiServices),
602 Wanted::ModelHost
603 );
604 assert_eq!(
605 Wanted::for_binding(BindingType::Storage),
606 Wanted::Type(BindingType::Storage)
607 );
608 }
609
610 #[test]
611 fn cache_round_trips_under_state_dir() {
612 let tmp = tempfile::tempdir().unwrap();
613 std::fs::write(
614 tmp.path().join(WORKSPACE_FILE),
615 "environments:\n dev:\n default: true\n search: { service: s }\n",
616 )
617 .unwrap();
618 let ws = Workspace::load(tmp.path()).unwrap();
619 let mut c = BindingCache::default();
620 c.bindings.insert(
621 "docs".into(),
622 ResolvedBinding {
623 name: "docs".into(),
624 kind: TargetKind::Binding(BindingType::Storage),
625 physical_name: "acct".into(),
626 arm_id: Some(
627 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct"
628 .into(),
629 ),
630 subscription: Some("s".into()),
631 resource_group: Some("rg".into()),
632 location: Some("swedencentral".into()),
633 endpoint: None,
634 principal_id: None,
635 resolved_at: "2026-09-10T00:00:00Z".into(),
636 },
637 );
638 c.save(&ws, "dev").unwrap();
639 assert!(BindingCache::path(&ws, "dev").ends_with(".rigg/dev/bindings.json"));
640 assert_eq!(
641 BindingCache::load(&ws, "dev")
642 .get("docs")
643 .unwrap()
644 .resource_group
645 .as_deref(),
646 Some("rg")
647 );
648 }
649}