Skip to main content

rigg_core/
workspace.rs

1//! Workspace and project model.
2//!
3//! A workspace is a directory containing `rigg.yaml` (environments, each with
4//! a single search/foundry target, tenant/subscription, policy and
5//! dependency bindings), a `projects/` directory where each subdirectory
6//! with a `project.yaml` is a project, and an `apis/` directory for shared
7//! OpenAPI specifications. Resource definitions live inside project
8//! directories; a resource belongs to exactly one project.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16pub const WORKSPACE_FILE: &str = "rigg.yaml";
17pub const PROJECT_FILE: &str = "project.yaml";
18pub const PROJECTS_DIR: &str = "projects";
19pub const APIS_DIR: &str = "apis";
20pub const STATE_DIR: &str = ".rigg";
21/// Subdirectory of a project holding one tree per environment:
22/// `projects/<project>/envs/<env>/{search,foundry}/...`.
23pub const ENVS_DIR: &str = "envs";
24
25#[derive(Debug, Error)]
26pub enum WorkspaceError {
27    #[error("no {WORKSPACE_FILE} found in {0} or any parent directory")]
28    NotFound(PathBuf),
29    #[error("failed to read {path}: {source}")]
30    Io {
31        path: PathBuf,
32        source: std::io::Error,
33    },
34    #[error("failed to parse {path}: {source}")]
35    Parse {
36        path: PathBuf,
37        source: serde_yaml::Error,
38    },
39    #[error("unknown project '{0}' (available: {1})")]
40    UnknownProject(String, String),
41    #[error("unknown environment '{0}' (available: {1})")]
42    UnknownEnvironment(String, String),
43    #[error(
44        "no default environment configured; pass --env or set `default: true` on one environment"
45    )]
46    NoDefaultEnvironment,
47    #[error("environment '{env}' has an invalid dependency binding name '{name}': {reason}")]
48    InvalidBindingName {
49        path: PathBuf,
50        env: String,
51        name: String,
52        reason: String,
53    },
54}
55
56type Result<T> = std::result::Result<T, WorkspaceError>;
57
58/// Top-level `rigg.yaml`.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct WorkspaceConfig {
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    /// Directory (relative to rigg.yaml) holding rigg's file trees —
65    /// `projects/`, `apis/`, `.rigg/`. Default: alongside rigg.yaml.
66    /// Set by `rigg init <folder>`.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub root: Option<String>,
69    #[serde(default)]
70    pub environments: BTreeMap<String, Environment>,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct Environment {
76    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
77    pub default: bool,
78    /// Azure AD tenant this environment's resources live in.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub tenant: Option<String>,
81    /// Azure subscription this environment's resources live in.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub subscription: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub search: Option<SearchConnection>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub foundry: Option<FoundryConnection>,
88    #[serde(default, skip_serializing_if = "Policy::is_default")]
89    pub policy: Policy,
90    /// Named references to supporting resources outside rigg's own kinds
91    /// (storage accounts, function apps, key vaults, ...).
92    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93    pub dependencies: BTreeMap<String, Binding>,
94}
95
96/// Per-environment policy gates. `protected: true` requires an explicit,
97/// typed confirmation for every cloud-mutating operation against this
98/// environment (`push` apply/`--prune`, `delete --remote`).
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct Policy {
102    #[serde(default)]
103    pub protected: bool,
104    /// Require every `dependencies` binding to be resolvable before push.
105    /// Defaults to `protected` when unset.
106    #[serde(
107        default,
108        rename = "strict-bindings",
109        skip_serializing_if = "Option::is_none"
110    )]
111    pub strict_bindings: Option<bool>,
112}
113
114impl Policy {
115    fn is_default(&self) -> bool {
116        self == &Policy::default()
117    }
118
119    pub fn strict_bindings(&self) -> bool {
120        self.strict_bindings.unwrap_or(self.protected)
121    }
122}
123
124/// `Binding`/`BindingType` moved to [`crate::binding`]; re-exported here so
125/// existing `workspace::Binding` paths keep compiling.
126pub use crate::binding::{Binding, BindingType, validate_binding_name};
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SearchConnection {
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub name: Option<String>,
133    /// Azure AI Search service name (e.g. `mklabsrch`).
134    pub service: String,
135    /// Full endpoint override (sovereign clouds, testing). Default:
136    /// `https://{service}.search.windows.net`.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub endpoint: Option<String>,
139    /// Override for the stable data-plane api-version.
140    #[serde(
141        default,
142        rename = "api-version",
143        skip_serializing_if = "Option::is_none"
144    )]
145    pub api_version: Option<String>,
146    /// Override for the preview data-plane api-version.
147    #[serde(
148        default,
149        rename = "preview-api-version",
150        skip_serializing_if = "Option::is_none"
151    )]
152    pub preview_api_version: Option<String>,
153}
154
155impl SearchConnection {
156    /// Base URL requests go to: the `endpoint` override, or the public-cloud
157    /// default derived from the service name.
158    pub fn url(&self) -> String {
159        match &self.endpoint {
160            Some(e) => e.trim_end_matches('/').to_string(),
161            None => format!("https://{}.search.windows.net", self.service),
162        }
163    }
164}
165
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct FoundryConnection {
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub name: Option<String>,
171    /// Foundry account name (e.g. `mklabaifndr`).
172    pub account: String,
173    /// Full endpoint override (sovereign clouds, testing). Default:
174    /// `https://{account}.services.ai.azure.com`.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub endpoint: Option<String>,
177    /// Foundry project name (e.g. `proj-default`).
178    pub project: String,
179    /// Override for the data-plane api-version (default `v1`).
180    #[serde(
181        default,
182        rename = "api-version",
183        skip_serializing_if = "Option::is_none"
184    )]
185    pub api_version: Option<String>,
186}
187
188impl FoundryConnection {
189    /// Base URL requests go to: the `endpoint` override, or the public-cloud
190    /// default derived from the account name.
191    pub fn url(&self) -> String {
192        match &self.endpoint {
193            Some(e) => e.trim_end_matches('/').to_string(),
194            None => format!("https://{}.services.ai.azure.com", self.account),
195        }
196    }
197}
198
199/// `project.yaml` — metadata only; the directory contents are the membership.
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ProjectManifest {
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub description: Option<String>,
205}
206
207#[derive(Debug, Clone)]
208pub struct Project {
209    pub name: String,
210    pub dir: PathBuf,
211    pub manifest: ProjectManifest,
212}
213
214#[derive(Debug, Clone)]
215pub struct Workspace {
216    pub root: PathBuf,
217    pub config: WorkspaceConfig,
218    pub projects: Vec<Project>,
219}
220
221#[derive(Debug, Clone)]
222pub struct ResolvedEnv {
223    pub name: String,
224    pub env: Environment,
225}
226
227impl Workspace {
228    /// Walk up from `start` to the directory containing `rigg.yaml`, then load
229    /// the workspace config and scan `projects/*/project.yaml`.
230    pub fn discover(start: &Path) -> Result<Workspace> {
231        let start = if start.as_os_str().is_empty() {
232            Path::new(".")
233        } else {
234            start
235        };
236        let mut dir = start.canonicalize().map_err(|source| WorkspaceError::Io {
237            path: start.to_path_buf(),
238            source,
239        })?;
240        loop {
241            if dir.join(WORKSPACE_FILE).is_file() {
242                return Workspace::load(&dir);
243            }
244            if !dir.pop() {
245                return Err(WorkspaceError::NotFound(start.to_path_buf()));
246            }
247        }
248    }
249
250    /// Load a workspace whose root is known to contain `rigg.yaml`.
251    pub fn load(root: &Path) -> Result<Workspace> {
252        let path = root.join(WORKSPACE_FILE);
253        let text = std::fs::read_to_string(&path).map_err(|source| WorkspaceError::Io {
254            path: path.clone(),
255            source,
256        })?;
257        let config: WorkspaceConfig =
258            serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
259                path: path.clone(),
260                source,
261            })?;
262
263        for (env_name, env) in &config.environments {
264            for name in env.dependencies.keys() {
265                if let Err(reason) = validate_binding_name(name) {
266                    return Err(WorkspaceError::InvalidBindingName {
267                        path: path.clone(),
268                        env: env_name.clone(),
269                        name: name.clone(),
270                        reason,
271                    });
272                }
273            }
274        }
275
276        let files_root = match &config.root {
277            Some(sub) => root.join(sub),
278            None => root.to_path_buf(),
279        };
280        let mut projects = Vec::new();
281        let projects_dir = files_root.join(PROJECTS_DIR);
282        if projects_dir.is_dir() {
283            let mut entries: Vec<_> = std::fs::read_dir(&projects_dir)
284                .map_err(|source| WorkspaceError::Io {
285                    path: projects_dir.clone(),
286                    source,
287                })?
288                .filter_map(|e| e.ok())
289                .map(|e| e.path())
290                .filter(|p| p.is_dir() && p.join(PROJECT_FILE).is_file())
291                .collect();
292            entries.sort();
293            for dir in entries {
294                let manifest_path = dir.join(PROJECT_FILE);
295                let text = std::fs::read_to_string(&manifest_path).map_err(|source| {
296                    WorkspaceError::Io {
297                        path: manifest_path.clone(),
298                        source,
299                    }
300                })?;
301                let manifest: ProjectManifest =
302                    serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
303                        path: manifest_path,
304                        source,
305                    })?;
306                let name = dir
307                    .file_name()
308                    .expect("project dir has a name")
309                    .to_string_lossy()
310                    .into_owned();
311                projects.push(Project {
312                    name,
313                    dir,
314                    manifest,
315                });
316            }
317        }
318
319        Ok(Workspace {
320            root: root.to_path_buf(),
321            config,
322            projects,
323        })
324    }
325
326    pub fn project(&self, name: &str) -> Result<&Project> {
327        self.projects
328            .iter()
329            .find(|p| p.name == name)
330            .ok_or_else(|| {
331                WorkspaceError::UnknownProject(
332                    name.to_string(),
333                    self.projects
334                        .iter()
335                        .map(|p| p.name.as_str())
336                        .collect::<Vec<_>>()
337                        .join(", "),
338                )
339            })
340    }
341
342    /// Resolve an environment: explicit selection > `RIGG_ENV` > `default: true`.
343    pub fn resolve_env(&self, selected: Option<&str>) -> Result<ResolvedEnv> {
344        let from_env = std::env::var("RIGG_ENV").ok();
345        let name = selected
346            .map(str::to_string)
347            .or(from_env)
348            .or_else(|| self.default_env_name().map(str::to_string))
349            .ok_or(WorkspaceError::NoDefaultEnvironment)?;
350        let env = self.config.environments.get(&name).ok_or_else(|| {
351            WorkspaceError::UnknownEnvironment(
352                name.clone(),
353                self.config
354                    .environments
355                    .keys()
356                    .map(String::as_str)
357                    .collect::<Vec<_>>()
358                    .join(", "),
359            )
360        })?;
361        Ok(ResolvedEnv {
362            name,
363            env: env.clone(),
364        })
365    }
366
367    pub fn default_env_name(&self) -> Option<&str> {
368        self.config
369            .environments
370            .iter()
371            .find(|(_, e)| e.default)
372            .map(|(n, _)| n.as_str())
373    }
374
375    /// Directory holding rigg's file trees (`projects/`, `apis/`, `.rigg/`) —
376    /// the workspace root unless `root:` in rigg.yaml relocates them.
377    pub fn files_root(&self) -> PathBuf {
378        match &self.config.root {
379            Some(sub) => self.root.join(sub),
380            None => self.root.clone(),
381        }
382    }
383
384    pub fn apis_dir(&self) -> PathBuf {
385        self.files_root().join(APIS_DIR)
386    }
387
388    pub fn state_dir(&self, env: &str, project: &str) -> PathBuf {
389        self.files_root().join(STATE_DIR).join(env).join(project)
390    }
391}
392
393impl ResolvedEnv {
394    pub fn search(&self) -> Option<&SearchConnection> {
395        self.env.search.as_ref()
396    }
397
398    pub fn foundry(&self) -> Option<&FoundryConnection> {
399        self.env.foundry.as_ref()
400    }
401
402    /// Whether this environment's policy gates cloud-mutating operations.
403    pub fn protected(&self) -> bool {
404        self.env.policy.protected
405    }
406
407    /// Whether every `dependencies` binding must resolve before push.
408    pub fn strict_bindings(&self) -> bool {
409        self.env.policy.strict_bindings()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn ws_yaml_single() -> &'static str {
418        r#"
419name: demo
420environments:
421  dev:
422    default: true
423    search: { service: mklabsrch }
424    foundry: { account: mklabaifndr, project: proj-default }
425  prod:
426    search: { service: mklabsrch-prod, api-version: 2026-04-01 }
427"#
428    }
429
430    fn ws_yaml_bindings() -> &'static str {
431        r#"
432environments:
433  dev:
434    default: true
435    tenant: 11111111-1111-1111-1111-111111111111
436    subscription: 00000000-0000-0000-0000-000000000000
437    search: { service: mklabsrch }
438    foundry: { account: mklabaifndr, project: proj-default }
439    dependencies:
440      docs-storage: { storage: mklabstorageacc }
441      enrich-fn: { function-app: mklab }
442      partner: { api: https://api.partner.example/v1 }
443  prod:
444    policy: { protected: true }
445    search: { service: mklabsrch-prod }
446    dependencies:
447      docs-storage: { storage: /subscriptions/0b1d/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/mklabstorageprod }
448"#
449    }
450
451    fn make_ws(dir: &Path, yaml: &str, projects: &[(&str, &str)]) -> Workspace {
452        std::fs::write(dir.join(WORKSPACE_FILE), yaml).unwrap();
453        for (name, manifest) in projects {
454            let pdir = dir.join(PROJECTS_DIR).join(name);
455            std::fs::create_dir_all(&pdir).unwrap();
456            std::fs::write(pdir.join(PROJECT_FILE), manifest).unwrap();
457        }
458        Workspace::load(dir).unwrap()
459    }
460
461    #[test]
462    fn parses_single_connection_env() {
463        let tmp = tempfile::tempdir().unwrap();
464        let ws = make_ws(
465            tmp.path(),
466            ws_yaml_single(),
467            &[("p1", "description: test\n")],
468        );
469        let dev = ws.resolve_env(Some("dev")).unwrap();
470        assert_eq!(dev.search().unwrap().service, "mklabsrch");
471        let f = dev.foundry().unwrap();
472        assert_eq!(
473            (f.account.as_str(), f.project.as_str()),
474            ("mklabaifndr", "proj-default")
475        );
476    }
477
478    #[test]
479    fn parses_targets_tenant_subscription_and_dependencies() {
480        let tmp = tempfile::tempdir().unwrap();
481        let ws = make_ws(tmp.path(), ws_yaml_bindings(), &[("p", "{}\n")]);
482        let dev = ws.resolve_env(Some("dev")).unwrap();
483        assert_eq!(dev.search().unwrap().service, "mklabsrch");
484        assert_eq!(dev.foundry().unwrap().project, "proj-default");
485        assert_eq!(
486            dev.env.subscription.as_deref(),
487            Some("00000000-0000-0000-0000-000000000000")
488        );
489        let b = &dev.env.dependencies["docs-storage"];
490        assert_eq!(b.kind, BindingType::Storage);
491        assert_eq!(b.value, "mklabstorageacc");
492        assert_eq!(dev.env.dependencies["partner"].kind, BindingType::Api);
493        let prod = ws.resolve_env(Some("prod")).unwrap();
494        assert!(prod.foundry().is_none());
495        assert!(
496            prod.protected() && prod.strict_bindings(),
497            "strict-bindings defaults to protected"
498        );
499        assert!(!dev.strict_bindings());
500    }
501
502    #[test]
503    fn binding_round_trips_as_a_one_key_map() {
504        let b: Binding = serde_yaml::from_str("key-vault: mklabkv").unwrap();
505        assert_eq!(b.kind, BindingType::KeyVault);
506        assert_eq!(
507            serde_yaml::to_string(&b).unwrap().trim(),
508            "key-vault: mklabkv"
509        );
510        assert!(
511            serde_yaml::from_str::<Binding>("cosmos: x").is_err(),
512            "unknown type rejected"
513        );
514        assert!(
515            serde_yaml::from_str::<Binding>("storage: a\nidentity: b").is_err(),
516            "exactly one key"
517        );
518    }
519
520    #[test]
521    fn binding_names_are_validated_and_reserved() {
522        assert!(validate_binding_name("docs-storage").is_ok());
523        assert!(validate_binding_name("Docs").is_err());
524        assert!(validate_binding_name("search").is_err());
525        assert!(validate_binding_name("foundry").is_err());
526    }
527
528    #[test]
529    fn list_form_targets_are_rejected() {
530        let tmp = tempfile::tempdir().unwrap();
531        std::fs::write(
532            tmp.path().join(WORKSPACE_FILE),
533            "environments:\n  dev:\n    search:\n      - service: a\n",
534        )
535        .unwrap();
536        assert!(matches!(
537            Workspace::load(tmp.path()),
538            Err(WorkspaceError::Parse { .. })
539        ));
540    }
541
542    #[test]
543    fn env_resolution_precedence() {
544        let tmp = tempfile::tempdir().unwrap();
545        let ws = make_ws(tmp.path(), ws_yaml_single(), &[]);
546        // explicit wins
547        assert_eq!(ws.resolve_env(Some("prod")).unwrap().name, "prod");
548        // default used when nothing selected (RIGG_ENV not set in tests)
549        assert_eq!(ws.resolve_env(None).unwrap().name, "dev");
550        // unknown errors
551        assert!(matches!(
552            ws.resolve_env(Some("staging")),
553            Err(WorkspaceError::UnknownEnvironment(..))
554        ));
555    }
556
557    #[test]
558    fn no_default_env_errors() {
559        let tmp = tempfile::tempdir().unwrap();
560        let ws = make_ws(
561            tmp.path(),
562            "environments:\n  a:\n    search: { service: s }\n",
563            &[],
564        );
565        assert!(matches!(
566            ws.resolve_env(None),
567            Err(WorkspaceError::NoDefaultEnvironment)
568        ));
569    }
570
571    #[test]
572    fn root_setting_relocates_file_trees() {
573        let tmp = tempfile::tempdir().unwrap();
574        let yaml =
575            "root: rag\nenvironments:\n  dev:\n    default: true\n    search: { service: s }\n";
576        std::fs::write(tmp.path().join(WORKSPACE_FILE), yaml).unwrap();
577        let pdir = tmp.path().join("rag").join(PROJECTS_DIR).join("alpha");
578        std::fs::create_dir_all(&pdir).unwrap();
579        std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
580        let ws = Workspace::load(tmp.path()).unwrap();
581        assert_eq!(ws.root, tmp.path());
582        assert_eq!(ws.files_root(), tmp.path().join("rag"));
583        assert_eq!(ws.project("alpha").unwrap().dir, pdir);
584        assert_eq!(ws.apis_dir(), tmp.path().join("rag").join(APIS_DIR));
585        assert_eq!(
586            ws.state_dir("dev", "alpha"),
587            tmp.path()
588                .join("rag")
589                .join(STATE_DIR)
590                .join("dev")
591                .join("alpha")
592        );
593    }
594
595    #[test]
596    fn discover_walks_up_and_finds_projects() {
597        let tmp = tempfile::tempdir().unwrap();
598        make_ws(
599            tmp.path(),
600            ws_yaml_single(),
601            &[("alpha", "{}\n"), ("beta", "description: b\n")],
602        );
603        let nested = tmp.path().join(PROJECTS_DIR).join("alpha").join("search");
604        std::fs::create_dir_all(&nested).unwrap();
605        let ws = Workspace::discover(&nested).unwrap();
606        assert_eq!(
607            ws.projects
608                .iter()
609                .map(|p| p.name.as_str())
610                .collect::<Vec<_>>(),
611            vec!["alpha", "beta"]
612        );
613        assert!(matches!(
614            ws.project("gamma"),
615            Err(WorkspaceError::UnknownProject(..))
616        ));
617    }
618
619    #[test]
620    fn policy_protected_parses_and_defaults_unprotected() {
621        let tmp = tempfile::tempdir().unwrap();
622        let ws = make_ws(
623            tmp.path(),
624            "environments:\n  dev:\n    default: true\n    search: { service: s }\n  prod:\n    policy: { protected: true }\n    search: { service: p }\n",
625            &[],
626        );
627        assert!(!ws.resolve_env(Some("dev")).unwrap().protected());
628        assert!(ws.resolve_env(Some("prod")).unwrap().protected());
629    }
630}