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
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct FoundryConnection {
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub name: Option<String>,
193    /// Foundry account name (e.g. `mklabaifndr`).
194    pub account: String,
195    /// Full endpoint override (sovereign clouds, testing). Default:
196    /// `https://{account}.services.ai.azure.com`.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub endpoint: Option<String>,
199    /// Foundry project name (e.g. `proj-default`).
200    pub project: String,
201    /// Override for the data-plane api-version (default `v1`).
202    #[serde(
203        default,
204        rename = "api-version",
205        skip_serializing_if = "Option::is_none"
206    )]
207    pub api_version: Option<String>,
208}
209
210/// `project.yaml` — metadata only; the directory contents are the membership.
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct ProjectManifest {
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub description: Option<String>,
216    /// Pin to a named search connection when the environment defines several.
217    #[serde(
218        default,
219        rename = "search-connection",
220        skip_serializing_if = "Option::is_none"
221    )]
222    pub search_connection: Option<String>,
223    #[serde(
224        default,
225        rename = "foundry-connection",
226        skip_serializing_if = "Option::is_none"
227    )]
228    pub foundry_connection: Option<String>,
229}
230
231#[derive(Debug, Clone)]
232pub struct Project {
233    pub name: String,
234    pub dir: PathBuf,
235    pub manifest: ProjectManifest,
236}
237
238#[derive(Debug, Clone)]
239pub struct Workspace {
240    pub root: PathBuf,
241    pub config: WorkspaceConfig,
242    pub projects: Vec<Project>,
243}
244
245#[derive(Debug, Clone)]
246pub struct ResolvedEnv {
247    pub name: String,
248    pub env: Environment,
249}
250
251impl Workspace {
252    /// Walk up from `start` to the directory containing `rigg.yaml`, then load
253    /// the workspace config and scan `projects/*/project.yaml`.
254    pub fn discover(start: &Path) -> Result<Workspace> {
255        let start = if start.as_os_str().is_empty() {
256            Path::new(".")
257        } else {
258            start
259        };
260        let mut dir = start.canonicalize().map_err(|source| WorkspaceError::Io {
261            path: start.to_path_buf(),
262            source,
263        })?;
264        loop {
265            if dir.join(WORKSPACE_FILE).is_file() {
266                return Workspace::load(&dir);
267            }
268            if !dir.pop() {
269                return Err(WorkspaceError::NotFound(start.to_path_buf()));
270            }
271        }
272    }
273
274    /// Load a workspace whose root is known to contain `rigg.yaml`.
275    pub fn load(root: &Path) -> Result<Workspace> {
276        let path = root.join(WORKSPACE_FILE);
277        let text = std::fs::read_to_string(&path).map_err(|source| WorkspaceError::Io {
278            path: path.clone(),
279            source,
280        })?;
281        let config: WorkspaceConfig =
282            serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse { path, source })?;
283
284        let files_root = match &config.root {
285            Some(sub) => root.join(sub),
286            None => root.to_path_buf(),
287        };
288        let mut projects = Vec::new();
289        let projects_dir = files_root.join(PROJECTS_DIR);
290        if projects_dir.is_dir() {
291            let mut entries: Vec<_> = std::fs::read_dir(&projects_dir)
292                .map_err(|source| WorkspaceError::Io {
293                    path: projects_dir.clone(),
294                    source,
295                })?
296                .filter_map(|e| e.ok())
297                .map(|e| e.path())
298                .filter(|p| p.is_dir() && p.join(PROJECT_FILE).is_file())
299                .collect();
300            entries.sort();
301            for dir in entries {
302                let manifest_path = dir.join(PROJECT_FILE);
303                let text = std::fs::read_to_string(&manifest_path).map_err(|source| {
304                    WorkspaceError::Io {
305                        path: manifest_path.clone(),
306                        source,
307                    }
308                })?;
309                let manifest: ProjectManifest =
310                    serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
311                        path: manifest_path,
312                        source,
313                    })?;
314                let name = dir
315                    .file_name()
316                    .expect("project dir has a name")
317                    .to_string_lossy()
318                    .into_owned();
319                projects.push(Project {
320                    name,
321                    dir,
322                    manifest,
323                });
324            }
325        }
326
327        Ok(Workspace {
328            root: root.to_path_buf(),
329            config,
330            projects,
331        })
332    }
333
334    pub fn project(&self, name: &str) -> Result<&Project> {
335        self.projects
336            .iter()
337            .find(|p| p.name == name)
338            .ok_or_else(|| {
339                WorkspaceError::UnknownProject(
340                    name.to_string(),
341                    self.projects
342                        .iter()
343                        .map(|p| p.name.as_str())
344                        .collect::<Vec<_>>()
345                        .join(", "),
346                )
347            })
348    }
349
350    /// Resolve an environment: explicit selection > `RIGG_ENV` > `default: true`.
351    pub fn resolve_env(&self, selected: Option<&str>) -> Result<ResolvedEnv> {
352        let from_env = std::env::var("RIGG_ENV").ok();
353        let name = selected
354            .map(str::to_string)
355            .or(from_env)
356            .or_else(|| self.default_env_name().map(str::to_string))
357            .ok_or(WorkspaceError::NoDefaultEnvironment)?;
358        let env = self.config.environments.get(&name).ok_or_else(|| {
359            WorkspaceError::UnknownEnvironment(
360                name.clone(),
361                self.config
362                    .environments
363                    .keys()
364                    .map(String::as_str)
365                    .collect::<Vec<_>>()
366                    .join(", "),
367            )
368        })?;
369        Ok(ResolvedEnv {
370            name,
371            env: env.clone(),
372        })
373    }
374
375    pub fn default_env_name(&self) -> Option<&str> {
376        self.config
377            .environments
378            .iter()
379            .find(|(_, e)| e.default)
380            .map(|(n, _)| n.as_str())
381    }
382
383    /// Directory holding rigg's file trees (`projects/`, `apis/`, `.rigg/`) —
384    /// the workspace root unless `root:` in rigg.yaml relocates them.
385    pub fn files_root(&self) -> PathBuf {
386        match &self.config.root {
387            Some(sub) => self.root.join(sub),
388            None => self.root.clone(),
389        }
390    }
391
392    pub fn apis_dir(&self) -> PathBuf {
393        self.files_root().join(APIS_DIR)
394    }
395
396    pub fn state_dir(&self, env: &str, project: &str) -> PathBuf {
397        self.files_root().join(STATE_DIR).join(env).join(project)
398    }
399}
400
401impl ResolvedEnv {
402    pub fn search_for(&self, project: &Project) -> Result<&SearchConnection> {
403        pick_connection(
404            &self.name,
405            "search",
406            self.env.search.as_slice(),
407            project,
408            project.manifest.search_connection.as_deref(),
409            |c| c.name.as_deref(),
410        )
411    }
412
413    pub fn foundry_for(&self, project: &Project) -> Result<&FoundryConnection> {
414        pick_connection(
415            &self.name,
416            "foundry",
417            self.env.foundry.as_slice(),
418            project,
419            project.manifest.foundry_connection.as_deref(),
420            |c| c.name.as_deref(),
421        )
422    }
423
424    pub fn has_search(&self) -> bool {
425        !self.env.search.is_empty()
426    }
427
428    pub fn has_foundry(&self) -> bool {
429        !self.env.foundry.is_empty()
430    }
431
432    /// Whether this environment's policy gates cloud-mutating operations.
433    pub fn protected(&self) -> bool {
434        self.env.policy.protected
435    }
436}
437
438fn pick_connection<'a, T>(
439    env_name: &str,
440    kind: &'static str,
441    conns: &'a [T],
442    project: &Project,
443    pinned: Option<&str>,
444    name_of: impl Fn(&T) -> Option<&str>,
445) -> Result<&'a T> {
446    match (pinned, conns.len()) {
447        (_, 0) => Err(WorkspaceError::MissingConnection {
448            env: env_name.to_string(),
449            kind,
450            project: project.name.clone(),
451        }),
452        (Some(pin), _) => conns
453            .iter()
454            .find(|c| name_of(c) == Some(pin))
455            .ok_or_else(|| WorkspaceError::UnknownConnection {
456                project: project.name.clone(),
457                kind,
458                name: pin.to_string(),
459                env: env_name.to_string(),
460            }),
461        (None, 1) => Ok(&conns[0]),
462        (None, n) => Err(WorkspaceError::AmbiguousConnection {
463            env: env_name.to_string(),
464            kind,
465            count: n,
466            project: project.name.clone(),
467        }),
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn ws_yaml_single() -> &'static str {
476        r#"
477name: demo
478environments:
479  dev:
480    default: true
481    search: { service: mklabsrch }
482    foundry: { account: mklabaifndr, project: proj-default }
483  prod:
484    search: { service: mklabsrch-prod, api-version: 2026-04-01 }
485"#
486    }
487
488    fn ws_yaml_multi() -> &'static str {
489        r#"
490environments:
491  dev:
492    default: true
493    search:
494      - name: primary
495        service: srch-a
496      - name: secondary
497        service: srch-b
498"#
499    }
500
501    fn make_ws(dir: &Path, yaml: &str, projects: &[(&str, &str)]) -> Workspace {
502        std::fs::write(dir.join(WORKSPACE_FILE), yaml).unwrap();
503        for (name, manifest) in projects {
504            let pdir = dir.join(PROJECTS_DIR).join(name);
505            std::fs::create_dir_all(&pdir).unwrap();
506            std::fs::write(pdir.join(PROJECT_FILE), manifest).unwrap();
507        }
508        Workspace::load(dir).unwrap()
509    }
510
511    #[test]
512    fn parses_single_connection_env() {
513        let tmp = tempfile::tempdir().unwrap();
514        let ws = make_ws(
515            tmp.path(),
516            ws_yaml_single(),
517            &[("p1", "description: test\n")],
518        );
519        let dev = ws.resolve_env(Some("dev")).unwrap();
520        let p1 = ws.project("p1").unwrap();
521        assert_eq!(dev.search_for(p1).unwrap().service, "mklabsrch");
522        let f = dev.foundry_for(p1).unwrap();
523        assert_eq!(
524            (f.account.as_str(), f.project.as_str()),
525            ("mklabaifndr", "proj-default")
526        );
527    }
528
529    #[test]
530    fn parses_multi_connection_env_and_requires_pin() {
531        let tmp = tempfile::tempdir().unwrap();
532        let ws = make_ws(
533            tmp.path(),
534            ws_yaml_multi(),
535            &[
536                ("pinned", "search-connection: secondary\n"),
537                ("unpinned", "{}\n"),
538            ],
539        );
540        let dev = ws.resolve_env(None).unwrap();
541        let pinned = ws.project("pinned").unwrap();
542        assert_eq!(dev.search_for(pinned).unwrap().service, "srch-b");
543        let unpinned = ws.project("unpinned").unwrap();
544        assert!(matches!(
545            dev.search_for(unpinned),
546            Err(WorkspaceError::AmbiguousConnection { count: 2, .. })
547        ));
548    }
549
550    #[test]
551    fn env_resolution_precedence() {
552        let tmp = tempfile::tempdir().unwrap();
553        let ws = make_ws(tmp.path(), ws_yaml_single(), &[]);
554        // explicit wins
555        assert_eq!(ws.resolve_env(Some("prod")).unwrap().name, "prod");
556        // default used when nothing selected (RIGG_ENV not set in tests)
557        assert_eq!(ws.resolve_env(None).unwrap().name, "dev");
558        // unknown errors
559        assert!(matches!(
560            ws.resolve_env(Some("staging")),
561            Err(WorkspaceError::UnknownEnvironment(..))
562        ));
563    }
564
565    #[test]
566    fn no_default_env_errors() {
567        let tmp = tempfile::tempdir().unwrap();
568        let ws = make_ws(
569            tmp.path(),
570            "environments:\n  a:\n    search: { service: s }\n",
571            &[],
572        );
573        assert!(matches!(
574            ws.resolve_env(None),
575            Err(WorkspaceError::NoDefaultEnvironment)
576        ));
577    }
578
579    #[test]
580    fn root_setting_relocates_file_trees() {
581        let tmp = tempfile::tempdir().unwrap();
582        let yaml =
583            "root: rag\nenvironments:\n  dev:\n    default: true\n    search: { service: s }\n";
584        std::fs::write(tmp.path().join(WORKSPACE_FILE), yaml).unwrap();
585        let pdir = tmp.path().join("rag").join(PROJECTS_DIR).join("alpha");
586        std::fs::create_dir_all(&pdir).unwrap();
587        std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
588        let ws = Workspace::load(tmp.path()).unwrap();
589        assert_eq!(ws.root, tmp.path());
590        assert_eq!(ws.files_root(), tmp.path().join("rag"));
591        assert_eq!(ws.project("alpha").unwrap().dir, pdir);
592        assert_eq!(ws.apis_dir(), tmp.path().join("rag").join(APIS_DIR));
593        assert_eq!(
594            ws.state_dir("dev", "alpha"),
595            tmp.path()
596                .join("rag")
597                .join(STATE_DIR)
598                .join("dev")
599                .join("alpha")
600        );
601    }
602
603    #[test]
604    fn discover_walks_up_and_finds_projects() {
605        let tmp = tempfile::tempdir().unwrap();
606        make_ws(
607            tmp.path(),
608            ws_yaml_single(),
609            &[("alpha", "{}\n"), ("beta", "description: b\n")],
610        );
611        let nested = tmp.path().join(PROJECTS_DIR).join("alpha").join("search");
612        std::fs::create_dir_all(&nested).unwrap();
613        let ws = Workspace::discover(&nested).unwrap();
614        assert_eq!(
615            ws.projects
616                .iter()
617                .map(|p| p.name.as_str())
618                .collect::<Vec<_>>(),
619            vec!["alpha", "beta"]
620        );
621        assert!(matches!(
622            ws.project("gamma"),
623            Err(WorkspaceError::UnknownProject(..))
624        ));
625    }
626
627    #[test]
628    fn policy_protected_parses_and_defaults_unprotected() {
629        let tmp = tempfile::tempdir().unwrap();
630        let ws = make_ws(
631            tmp.path(),
632            "environments:\n  dev:\n    default: true\n    search: { service: s }\n  prod:\n    policy: { protected: true }\n    search: { service: p }\n",
633            &[],
634        );
635        assert!(!ws.resolve_env(Some("dev")).unwrap().protected());
636        assert!(ws.resolve_env(Some("prod")).unwrap().protected());
637    }
638
639    #[test]
640    fn missing_connection_errors() {
641        let tmp = tempfile::tempdir().unwrap();
642        let ws = make_ws(
643            tmp.path(),
644            "environments:\n  dev:\n    default: true\n    search: { service: s }\n",
645            &[("p", "{}\n")],
646        );
647        let dev = ws.resolve_env(None).unwrap();
648        let p = ws.project("p").unwrap();
649        assert!(matches!(
650            dev.foundry_for(p),
651            Err(WorkspaceError::MissingConnection {
652                kind: "foundry",
653                ..
654            })
655        ));
656    }
657}