Skip to main content

rigg_core/
binding.rs

1//! Binding values, implicit `search`/`foundry` bindings, and the
2//! per-environment resolution cache.
3//!
4//! [`Binding`] (a `dependencies` entry from `rigg.yaml`) and [`BindingType`]
5//! live here; `workspace` re-exports them so existing `workspace::Binding`
6//! paths keep compiling. [`EnvBindings`] is the read-only view combining an
7//! environment's declared `dependencies` with its implicit `search`/
8//! `foundry` targets, optionally enriched with cached resolution results.
9
10use 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/// A named reference to a supporting Azure resource outside rigg's own
19/// kinds, e.g. `docs-storage: { storage: mklabstorageacc }`. Serialized as a
20/// one-key map `{ "<type>": "<value>" }`.
21#[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/// The kind of supporting resource a [`Binding`] points at.
56#[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
105/// Serialized as a plain string (its kebab name) for use as an ordinary JSON
106/// field, e.g. in [`ResolvedBinding::kind`] — distinct from [`Binding`]'s
107/// one-key-map encoding.
108impl 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
121/// Reserved dependency binding names — these name the `search`/`foundry`
122/// targets, not `dependencies` entries.
123pub const RESERVED_BINDING_NAMES: [&str; 2] = ["search", "foundry"];
124
125/// What a [`ResolvedBinding`] resolved against Azure: a declared
126/// `dependencies` entry's [`BindingType`], or one of an environment's
127/// implicit targets (its `search` service / `foundry` account), which are
128/// not dependency types and so deliberately stay out of [`BindingType`].
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum TargetKind {
131    /// A declared `dependencies` binding of this type.
132    Binding(BindingType),
133    /// The environment's `search` target (`Microsoft.Search/searchServices`).
134    Search,
135    /// The environment's `foundry` account
136    /// (`Microsoft.CognitiveServices/accounts`).
137    Foundry,
138}
139
140impl TargetKind {
141    /// The declared binding type, for the `Binding` case only.
142    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
191/// Validate a `dependencies` binding name: lowercase kebab-case, not
192/// reserved, not empty.
193pub 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/// The parsed shape of a [`Binding`]'s value string.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum BindingValue {
218    /// A bare resource name (e.g. `mklabstorageacc`).
219    Name(String),
220    /// A full ARM resource id (`/subscriptions/...`).
221    ArmId(String),
222    /// A URL (`http://` or `https://`).
223    Url(String),
224}
225
226impl Binding {
227    /// Classify this binding's value string.
228    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    /// The physical resource name this binding resolves to, lowercased:
239    /// the bare name as-is, an ARM id's last path segment, or a URL's host
240    /// (no port, no path).
241    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    /// The raw ARM resource id, when this binding's value is one.
250    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
258/// The host portion of a URL: strips scheme, userinfo, port and path.
259fn 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
267/// The last path segment of an ARM resource id — the resource's own name.
268pub 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
273/// The `{subscription}` segment of `/subscriptions/{subscription}/...`.
274pub fn arm_subscription(id: &str) -> Option<&str> {
275    arm_segment(id, "subscriptions")
276}
277
278/// The `{resourceGroup}` segment of `.../resourceGroups/{resourceGroup}/...`.
279pub 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/// A binding resolved against Azure, cached so later runs don't have to
293/// re-discover it. Written by `rigg env resolve` (a later task); read by
294/// anything that needs a physical name, ARM id, or endpoint for a binding.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct ResolvedBinding {
297    /// The binding's name in its environment. ARM resolution has no way to
298    /// know it, so `ArmClient::resolve_binding`/`resolve_target` fill this
299    /// with the *ARM resource* name; the caller that knows which binding it
300    /// asked about overwrites it with the binding name before caching.
301    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    /// Principal id of the resource's system-assigned managed identity, when
310    /// applicable (filled in for identity bindings by a later task).
311    #[serde(default)]
312    pub principal_id: Option<String>,
313    /// RFC3339 timestamp of when this resolution was captured.
314    pub resolved_at: String,
315}
316
317/// Per-environment cache of [`ResolvedBinding`]s, persisted at
318/// `.rigg/<env>/bindings.json`.
319#[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/// How a [`BindingEntry`] came to exist in an [`EnvBindings`] table.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum BindingKind {
358    /// A `dependencies` entry declared in `rigg.yaml`.
359    Declared(BindingType),
360    /// The environment's `search` target, exposed under the reserved name
361    /// `search`.
362    ImplicitSearch,
363    /// The environment's `foundry` target, exposed under the reserved name
364    /// `foundry`.
365    ImplicitFoundry,
366}
367
368/// One entry in an [`EnvBindings`] table.
369#[derive(Debug, Clone)]
370pub struct BindingEntry {
371    pub name: String,
372    pub kind: BindingKind,
373    pub physical_name: String,
374    /// The declared `dependencies` binding, when this entry came from one.
375    pub declared: Option<Binding>,
376    /// The cached resolution for this entry, when one is available.
377    pub resolved: Option<ResolvedBinding>,
378}
379
380/// What [`EnvBindings::find_physical`] is looking for.
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum Wanted {
383    /// A declared binding of this exact type.
384    Type(BindingType),
385    /// The environment's implicit `search` target.
386    SearchService,
387    /// Anything that can host a model deployment: a declared `ai-services`
388    /// binding, or the environment's implicit `foundry` target.
389    ModelHost,
390}
391
392impl Wanted {
393    /// What a declared binding of `kind` competes for — the same mapping
394    /// [`crate::infra::classify`] uses, so "shared" means exactly one thing
395    /// everywhere: an `ai-services` binding and the implicit `foundry`
396    /// target are both model hosts and can therefore share a value.
397    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/// One environment's binding table: declared `dependencies` plus implicit
416/// `search`/`foundry` entries, optionally enriched from a [`BindingCache`].
417#[derive(Debug, Clone)]
418pub struct EnvBindings {
419    pub env: String,
420    entries: BTreeMap<String, BindingEntry>,
421}
422
423impl EnvBindings {
424    /// Build the binding table for one environment. Workspace-free — takes
425    /// the environment directly, so callers that already have it (and
426    /// tests) don't need a full [`Workspace`].
427    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    /// Alias for [`EnvBindings::of_env`]. Loads nothing itself — pass an
476    /// already-loaded [`BindingCache`] as `cache`.
477    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    /// The entry whose kind accepts `wanted` and whose physical name equals
490    /// `physical` (case-insensitive).
491    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}