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