use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const WORKSPACE_FILE: &str = "rigg.yaml";
pub const PROJECT_FILE: &str = "project.yaml";
pub const PROJECTS_DIR: &str = "projects";
pub const APIS_DIR: &str = "apis";
pub const STATE_DIR: &str = ".rigg";
#[derive(Debug, Error)]
pub enum WorkspaceError {
#[error("no {WORKSPACE_FILE} found in {0} or any parent directory")]
NotFound(PathBuf),
#[error("failed to read {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse {path}: {source}")]
Parse {
path: PathBuf,
source: serde_yaml::Error,
},
#[error("unknown project '{0}' (available: {1})")]
UnknownProject(String, String),
#[error("unknown environment '{0}' (available: {1})")]
UnknownEnvironment(String, String),
#[error(
"no default environment configured; pass --env or set `default: true` on one environment"
)]
NoDefaultEnvironment,
#[error(
"environment '{env}' has {count} {kind} connections; set `{kind}-connection` in project.yaml for project '{project}'"
)]
AmbiguousConnection {
env: String,
kind: &'static str,
count: usize,
project: String,
},
#[error("environment '{env}' has no {kind} connection (required by project '{project}')")]
MissingConnection {
env: String,
kind: &'static str,
project: String,
},
#[error(
"project '{project}' pins {kind} connection '{name}' which does not exist in environment '{env}'"
)]
UnknownConnection {
project: String,
kind: &'static str,
name: String,
env: String,
},
}
type Result<T> = std::result::Result<T, WorkspaceError>;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub environments: BTreeMap<String, Environment>,
#[serde(default, skip_serializing_if = "Defaults::is_empty")]
pub defaults: Defaults,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Defaults {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
}
impl Defaults {
fn is_empty(&self) -> bool {
self.identity.is_none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Environment {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
#[serde(default, skip_serializing_if = "ConnectionList::is_empty")]
pub search: ConnectionList<SearchConnection>,
#[serde(default, skip_serializing_if = "ConnectionList::is_empty")]
pub foundry: ConnectionList<FoundryConnection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConnectionList<T> {
One(T),
Many(Vec<T>),
}
impl<T> Default for ConnectionList<T> {
fn default() -> Self {
ConnectionList::Many(Vec::new())
}
}
impl<T> ConnectionList<T> {
pub fn as_slice(&self) -> &[T] {
match self {
ConnectionList::One(one) => std::slice::from_ref(one),
ConnectionList::Many(many) => many.as_slice(),
}
}
pub fn is_empty(&self) -> bool {
self.as_slice().is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchConnection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub service: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(
default,
rename = "api-version",
skip_serializing_if = "Option::is_none"
)]
pub api_version: Option<String>,
#[serde(
default,
rename = "preview-api-version",
skip_serializing_if = "Option::is_none"
)]
pub preview_api_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FoundryConnection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub account: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
pub project: String,
#[serde(
default,
rename = "api-version",
skip_serializing_if = "Option::is_none"
)]
pub api_version: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(
default,
rename = "search-connection",
skip_serializing_if = "Option::is_none"
)]
pub search_connection: Option<String>,
#[serde(
default,
rename = "foundry-connection",
skip_serializing_if = "Option::is_none"
)]
pub foundry_connection: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Project {
pub name: String,
pub dir: PathBuf,
pub manifest: ProjectManifest,
}
#[derive(Debug, Clone)]
pub struct Workspace {
pub root: PathBuf,
pub config: WorkspaceConfig,
pub projects: Vec<Project>,
}
#[derive(Debug, Clone)]
pub struct ResolvedEnv {
pub name: String,
pub env: Environment,
}
impl Workspace {
pub fn discover(start: &Path) -> Result<Workspace> {
let start = if start.as_os_str().is_empty() {
Path::new(".")
} else {
start
};
let mut dir = start.canonicalize().map_err(|source| WorkspaceError::Io {
path: start.to_path_buf(),
source,
})?;
loop {
if dir.join(WORKSPACE_FILE).is_file() {
return Workspace::load(&dir);
}
if !dir.pop() {
return Err(WorkspaceError::NotFound(start.to_path_buf()));
}
}
}
pub fn load(root: &Path) -> Result<Workspace> {
let path = root.join(WORKSPACE_FILE);
let text = std::fs::read_to_string(&path).map_err(|source| WorkspaceError::Io {
path: path.clone(),
source,
})?;
let config: WorkspaceConfig =
serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse { path, source })?;
let mut projects = Vec::new();
let projects_dir = root.join(PROJECTS_DIR);
if projects_dir.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(&projects_dir)
.map_err(|source| WorkspaceError::Io {
path: projects_dir.clone(),
source,
})?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join(PROJECT_FILE).is_file())
.collect();
entries.sort();
for dir in entries {
let manifest_path = dir.join(PROJECT_FILE);
let text = std::fs::read_to_string(&manifest_path).map_err(|source| {
WorkspaceError::Io {
path: manifest_path.clone(),
source,
}
})?;
let manifest: ProjectManifest =
serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
path: manifest_path,
source,
})?;
let name = dir
.file_name()
.expect("project dir has a name")
.to_string_lossy()
.into_owned();
projects.push(Project {
name,
dir,
manifest,
});
}
}
Ok(Workspace {
root: root.to_path_buf(),
config,
projects,
})
}
pub fn project(&self, name: &str) -> Result<&Project> {
self.projects
.iter()
.find(|p| p.name == name)
.ok_or_else(|| {
WorkspaceError::UnknownProject(
name.to_string(),
self.projects
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", "),
)
})
}
pub fn resolve_env(&self, selected: Option<&str>) -> Result<ResolvedEnv> {
let from_env = std::env::var("RIGG_ENV").ok();
let name = selected
.map(str::to_string)
.or(from_env)
.or_else(|| self.default_env_name().map(str::to_string))
.ok_or(WorkspaceError::NoDefaultEnvironment)?;
let env = self.config.environments.get(&name).ok_or_else(|| {
WorkspaceError::UnknownEnvironment(
name.clone(),
self.config
.environments
.keys()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", "),
)
})?;
Ok(ResolvedEnv {
name,
env: env.clone(),
})
}
pub fn default_env_name(&self) -> Option<&str> {
self.config
.environments
.iter()
.find(|(_, e)| e.default)
.map(|(n, _)| n.as_str())
}
pub fn apis_dir(&self) -> PathBuf {
self.root.join(APIS_DIR)
}
pub fn state_dir(&self, env: &str, project: &str) -> PathBuf {
self.root.join(STATE_DIR).join(env).join(project)
}
}
impl ResolvedEnv {
pub fn search_for(&self, project: &Project) -> Result<&SearchConnection> {
pick_connection(
&self.name,
"search",
self.env.search.as_slice(),
project,
project.manifest.search_connection.as_deref(),
|c| c.name.as_deref(),
)
}
pub fn foundry_for(&self, project: &Project) -> Result<&FoundryConnection> {
pick_connection(
&self.name,
"foundry",
self.env.foundry.as_slice(),
project,
project.manifest.foundry_connection.as_deref(),
|c| c.name.as_deref(),
)
}
pub fn has_search(&self) -> bool {
!self.env.search.is_empty()
}
pub fn has_foundry(&self) -> bool {
!self.env.foundry.is_empty()
}
}
fn pick_connection<'a, T>(
env_name: &str,
kind: &'static str,
conns: &'a [T],
project: &Project,
pinned: Option<&str>,
name_of: impl Fn(&T) -> Option<&str>,
) -> Result<&'a T> {
match (pinned, conns.len()) {
(_, 0) => Err(WorkspaceError::MissingConnection {
env: env_name.to_string(),
kind,
project: project.name.clone(),
}),
(Some(pin), _) => conns
.iter()
.find(|c| name_of(c) == Some(pin))
.ok_or_else(|| WorkspaceError::UnknownConnection {
project: project.name.clone(),
kind,
name: pin.to_string(),
env: env_name.to_string(),
}),
(None, 1) => Ok(&conns[0]),
(None, n) => Err(WorkspaceError::AmbiguousConnection {
env: env_name.to_string(),
kind,
count: n,
project: project.name.clone(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ws_yaml_single() -> &'static str {
r#"
name: demo
environments:
dev:
default: true
search: { service: mklabsrch }
foundry: { account: mklabaifndr, project: proj-default }
prod:
search: { service: mklabsrch-prod, api-version: 2026-04-01 }
"#
}
fn ws_yaml_multi() -> &'static str {
r#"
environments:
dev:
default: true
search:
- name: primary
service: srch-a
- name: secondary
service: srch-b
"#
}
fn make_ws(dir: &Path, yaml: &str, projects: &[(&str, &str)]) -> Workspace {
std::fs::write(dir.join(WORKSPACE_FILE), yaml).unwrap();
for (name, manifest) in projects {
let pdir = dir.join(PROJECTS_DIR).join(name);
std::fs::create_dir_all(&pdir).unwrap();
std::fs::write(pdir.join(PROJECT_FILE), manifest).unwrap();
}
Workspace::load(dir).unwrap()
}
#[test]
fn parses_single_connection_env() {
let tmp = tempfile::tempdir().unwrap();
let ws = make_ws(
tmp.path(),
ws_yaml_single(),
&[("p1", "description: test\n")],
);
let dev = ws.resolve_env(Some("dev")).unwrap();
let p1 = ws.project("p1").unwrap();
assert_eq!(dev.search_for(p1).unwrap().service, "mklabsrch");
let f = dev.foundry_for(p1).unwrap();
assert_eq!(
(f.account.as_str(), f.project.as_str()),
("mklabaifndr", "proj-default")
);
}
#[test]
fn parses_multi_connection_env_and_requires_pin() {
let tmp = tempfile::tempdir().unwrap();
let ws = make_ws(
tmp.path(),
ws_yaml_multi(),
&[
("pinned", "search-connection: secondary\n"),
("unpinned", "{}\n"),
],
);
let dev = ws.resolve_env(None).unwrap();
let pinned = ws.project("pinned").unwrap();
assert_eq!(dev.search_for(pinned).unwrap().service, "srch-b");
let unpinned = ws.project("unpinned").unwrap();
assert!(matches!(
dev.search_for(unpinned),
Err(WorkspaceError::AmbiguousConnection { count: 2, .. })
));
}
#[test]
fn env_resolution_precedence() {
let tmp = tempfile::tempdir().unwrap();
let ws = make_ws(tmp.path(), ws_yaml_single(), &[]);
assert_eq!(ws.resolve_env(Some("prod")).unwrap().name, "prod");
assert_eq!(ws.resolve_env(None).unwrap().name, "dev");
assert!(matches!(
ws.resolve_env(Some("staging")),
Err(WorkspaceError::UnknownEnvironment(..))
));
}
#[test]
fn no_default_env_errors() {
let tmp = tempfile::tempdir().unwrap();
let ws = make_ws(
tmp.path(),
"environments:\n a:\n search: { service: s }\n",
&[],
);
assert!(matches!(
ws.resolve_env(None),
Err(WorkspaceError::NoDefaultEnvironment)
));
}
#[test]
fn discover_walks_up_and_finds_projects() {
let tmp = tempfile::tempdir().unwrap();
make_ws(
tmp.path(),
ws_yaml_single(),
&[("alpha", "{}\n"), ("beta", "description: b\n")],
);
let nested = tmp.path().join(PROJECTS_DIR).join("alpha").join("search");
std::fs::create_dir_all(&nested).unwrap();
let ws = Workspace::discover(&nested).unwrap();
assert_eq!(
ws.projects
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "beta"]
);
assert!(matches!(
ws.project("gamma"),
Err(WorkspaceError::UnknownProject(..))
));
}
#[test]
fn missing_connection_errors() {
let tmp = tempfile::tempdir().unwrap();
let ws = make_ws(
tmp.path(),
"environments:\n dev:\n default: true\n search: { service: s }\n",
&[("p", "{}\n")],
);
let dev = ws.resolve_env(None).unwrap();
let p = ws.project("p").unwrap();
assert!(matches!(
dev.foundry_for(p),
Err(WorkspaceError::MissingConnection {
kind: "foundry",
..
})
));
}
}