Skip to main content

rigg_core/
workspace.rs

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