use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Configuration file not found: {0}")]
NotFound(PathBuf),
#[error("Failed to read configuration: {0}")]
ReadError(#[from] std::io::Error),
#[error("Failed to parse configuration: {0}")]
ParseError(#[from] serde_yaml::Error),
#[error("Invalid configuration: {0}")]
Invalid(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub project: ProjectConfig,
#[serde(default)]
pub sync: SyncConfig,
pub environments: BTreeMap<String, EnvironmentConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentConfig {
#[serde(default)]
pub default: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub search: Vec<SearchServiceConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub foundry: Vec<FoundryServiceConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchServiceConfig {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_group: Option<String>,
#[serde(default = "default_api_version")]
pub api_version: String,
#[serde(default = "default_preview_api_version")]
pub preview_api_version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FoundryServiceConfig {
pub name: String,
pub project: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default = "default_foundry_api_version")]
pub api_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_group: Option<String>,
}
fn default_api_version() -> String {
"2024-07-01".to_string()
}
fn default_preview_api_version() -> String {
"2025-11-01-preview".to_string()
}
fn default_foundry_api_version() -> String {
"2025-05-15-preview".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub files_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncConfig {
#[serde(default = "default_true")]
pub include_preview: bool,
#[serde(default)]
pub resources: Vec<String>,
}
fn default_true() -> bool {
true
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
include_preview: true,
resources: Vec::new(),
}
}
}
pub struct ResolvedEnvironment {
pub name: String,
pub search: Vec<SearchServiceConfig>,
pub foundry: Vec<FoundryServiceConfig>,
pub sync: SyncConfig,
}
impl ResolvedEnvironment {
pub fn search_service_dir(&self, root: &Path, service: &SearchServiceConfig) -> PathBuf {
let base = root.join("search");
if self.search.len() <= 1 {
base
} else {
base.join(service.label.as_deref().unwrap_or(&service.name))
}
}
pub fn foundry_service_dir(&self, root: &Path, service: &FoundryServiceConfig) -> PathBuf {
let base = root.join("foundry");
if self.foundry.len() <= 1 {
base
} else {
base.join(service.label.as_deref().unwrap_or(&service.name))
}
}
pub fn primary_search_service(&self) -> Option<&SearchServiceConfig> {
self.search.first()
}
pub fn has_foundry(&self) -> bool {
!self.foundry.is_empty()
}
pub fn has_search(&self) -> bool {
!self.search.is_empty()
}
}
impl Config {
pub const FILENAME: &'static str = "rigg.yaml";
pub fn files_root(&self, project_root: &Path) -> PathBuf {
match &self.project.files_path {
Some(p) => project_root.join(p),
None => project_root.to_path_buf(),
}
}
pub fn load(dir: &Path) -> Result<Self, ConfigError> {
let path = dir.join(Self::FILENAME);
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn save(&self, dir: &Path) -> Result<(), ConfigError> {
let path = dir.join(Self::FILENAME);
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
let yaml = serde_yaml::to_string(self)?;
let content = format!(
"# Rigg configuration — https://github.com/mklab-se/rigg\n{}",
yaml
);
std::fs::write(path, content)?;
Ok(())
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.environments.is_empty() {
return Err(ConfigError::Invalid(
"At least one environment must be configured".to_string(),
));
}
for (name, env) in &self.environments {
if env.search.is_empty() && env.foundry.is_empty() {
return Err(ConfigError::Invalid(format!(
"Environment '{}' has no services configured",
name
)));
}
if env.search.len() > 1 && env.search.iter().any(|s| s.label.is_none()) {
return Err(ConfigError::Invalid(format!(
"Environment '{}' has multiple search services — all must have a 'label'",
name
)));
}
if env.foundry.len() > 1 && env.foundry.iter().any(|s| s.label.is_none()) {
return Err(ConfigError::Invalid(format!(
"Environment '{}' has multiple foundry services — all must have a 'label'",
name
)));
}
for (i, svc) in env.search.iter().enumerate() {
if svc.name.is_empty() {
return Err(ConfigError::Invalid(format!(
"environments.{}.search[{}].name is required",
name, i
)));
}
}
for (i, svc) in env.foundry.iter().enumerate() {
if svc.name.is_empty() || svc.project.is_empty() {
return Err(ConfigError::Invalid(format!(
"environments.{}.foundry[{}] requires name and project",
name, i
)));
}
}
}
let defaults = self.environments.values().filter(|e| e.default).count();
if defaults > 1 {
return Err(ConfigError::Invalid(
"Only one environment can be set as default".to_string(),
));
}
Ok(())
}
pub fn resolve_env(&self, name: Option<&str>) -> Result<ResolvedEnvironment, ConfigError> {
let env_name = name.or_else(|| self.default_env_name()).ok_or_else(|| {
ConfigError::Invalid("No environment specified and no default set".to_string())
})?;
let env_config = self
.environments
.get(env_name)
.ok_or_else(|| ConfigError::Invalid(format!("Environment '{}' not found", env_name)))?;
Ok(ResolvedEnvironment {
name: env_name.to_string(),
search: env_config.search.clone(),
foundry: env_config.foundry.clone(),
sync: self.sync.clone(),
})
}
pub fn default_env_name(&self) -> Option<&str> {
self.environments
.iter()
.find(|(_, e)| e.default)
.map(|(n, _)| n.as_str())
.or_else(|| {
if self.environments.len() == 1 {
self.environments.keys().next().map(|s| s.as_str())
} else {
None
}
})
}
pub fn environment_names(&self) -> Vec<&str> {
self.environments.keys().map(|s| s.as_str()).collect()
}
}
impl SearchServiceConfig {
pub fn service_url(&self) -> String {
format!("https://{}.search.windows.net", self.name)
}
}
impl FoundryServiceConfig {
pub fn service_url(&self) -> String {
if let Some(ref ep) = self.endpoint {
return ep.trim_end_matches('/').to_string();
}
format!("https://{}.services.ai.azure.com", self.name)
}
}
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(Config::FILENAME).exists() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_search_service(name: &str) -> SearchServiceConfig {
SearchServiceConfig {
name: name.to_string(),
label: None,
subscription: None,
resource_group: None,
api_version: default_api_version(),
preview_api_version: default_preview_api_version(),
}
}
fn make_foundry_service(name: &str, project: &str) -> FoundryServiceConfig {
FoundryServiceConfig {
name: name.to_string(),
project: project.to_string(),
label: None,
api_version: default_foundry_api_version(),
endpoint: None,
subscription: None,
resource_group: None,
}
}
fn make_config_search(name: &str) -> Config {
Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"prod".to_string(),
EnvironmentConfig {
default: true,
description: None,
search: vec![make_search_service(name)],
foundry: vec![],
},
)]),
}
}
fn make_config_both() -> Config {
Config {
project: ProjectConfig {
name: Some("Test".to_string()),
description: None,
files_path: None,
},
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"prod".to_string(),
EnvironmentConfig {
default: true,
description: None,
search: vec![make_search_service("test-search")],
foundry: vec![make_foundry_service("test-ai", "test-proj")],
},
)]),
}
}
#[test]
fn test_validate_no_environments() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::new(),
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_empty_env() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"prod".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![],
foundry: vec![],
},
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_valid_search_only() {
let config = make_config_search("my-search");
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_empty_search_name() {
let mut config = make_config_search("");
let result = config.validate();
assert!(result.is_err());
config.environments.get_mut("prod").unwrap().search[0].name = "valid".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_foundry_requires_project() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"prod".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![],
foundry: vec![FoundryServiceConfig {
name: "my-ai".to_string(),
project: "".to_string(),
label: None,
api_version: default_foundry_api_version(),
endpoint: None,
subscription: None,
resource_group: None,
}],
},
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_multi_search_requires_labels() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"prod".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![make_search_service("svc-1"), make_search_service("svc-2")],
foundry: vec![],
},
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_multi_search_with_labels() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"prod".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![
SearchServiceConfig {
label: Some("primary".to_string()),
..make_search_service("svc-1")
},
SearchServiceConfig {
label: Some("analytics".to_string()),
..make_search_service("svc-2")
},
],
foundry: vec![],
},
)]),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_multiple_defaults() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([
(
"prod".to_string(),
EnvironmentConfig {
default: true,
description: None,
search: vec![make_search_service("svc-1")],
foundry: vec![],
},
),
(
"test".to_string(),
EnvironmentConfig {
default: true,
description: None,
search: vec![make_search_service("svc-2")],
foundry: vec![],
},
),
]),
};
assert!(config.validate().is_err());
}
#[test]
fn test_search_service_url() {
let svc = make_search_service("my-search");
assert_eq!(svc.service_url(), "https://my-search.search.windows.net");
}
#[test]
fn test_foundry_service_url() {
let svc = make_foundry_service("my-ai-service", "proj-1");
assert_eq!(
svc.service_url(),
"https://my-ai-service.services.ai.azure.com"
);
}
#[test]
fn test_foundry_service_url_with_endpoint() {
let mut svc = make_foundry_service("my-ai-service", "proj-1");
svc.endpoint = Some("https://custom-subdomain.services.ai.azure.com".to_string());
assert_eq!(
svc.service_url(),
"https://custom-subdomain.services.ai.azure.com"
);
}
#[test]
fn test_foundry_service_url_strips_trailing_slash() {
let mut svc = make_foundry_service("my-ai-service", "proj-1");
svc.endpoint = Some("https://custom-subdomain.services.ai.azure.com/".to_string());
assert_eq!(
svc.service_url(),
"https://custom-subdomain.services.ai.azure.com"
);
}
#[test]
fn test_sync_config_defaults() {
let sync = SyncConfig::default();
assert!(sync.include_preview);
assert!(sync.resources.is_empty());
}
#[test]
fn test_resolve_env_default() {
let config = make_config_search("my-search");
let env = config.resolve_env(None).unwrap();
assert_eq!(env.name, "prod");
assert_eq!(env.search.len(), 1);
assert_eq!(env.search[0].name, "my-search");
}
#[test]
fn test_resolve_env_by_name() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([
(
"prod".to_string(),
EnvironmentConfig {
default: true,
description: None,
search: vec![make_search_service("search-prod")],
foundry: vec![],
},
),
(
"test".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![make_search_service("search-test")],
foundry: vec![],
},
),
]),
};
let env = config.resolve_env(Some("test")).unwrap();
assert_eq!(env.name, "test");
assert_eq!(env.search[0].name, "search-test");
}
#[test]
fn test_resolve_env_not_found() {
let config = make_config_search("svc");
assert!(config.resolve_env(Some("missing")).is_err());
}
#[test]
fn test_resolve_env_single_env_is_default() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([(
"staging".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![make_search_service("svc")],
foundry: vec![],
},
)]),
};
let env = config.resolve_env(None).unwrap();
assert_eq!(env.name, "staging");
}
#[test]
fn test_resolve_env_multiple_envs_no_default() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([
(
"prod".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![make_search_service("svc-1")],
foundry: vec![],
},
),
(
"test".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![make_search_service("svc-2")],
foundry: vec![],
},
),
]),
};
assert!(config.resolve_env(None).is_err());
}
#[test]
fn test_default_env_name_with_default() {
let config = make_config_search("svc");
assert_eq!(config.default_env_name(), Some("prod"));
}
#[test]
fn test_environment_names() {
let config = Config {
project: ProjectConfig::default(),
sync: SyncConfig::default(),
environments: BTreeMap::from([
(
"prod".to_string(),
EnvironmentConfig {
default: true,
description: None,
search: vec![make_search_service("svc-1")],
foundry: vec![],
},
),
(
"test".to_string(),
EnvironmentConfig {
default: false,
description: None,
search: vec![make_search_service("svc-2")],
foundry: vec![],
},
),
]),
};
let names = config.environment_names();
assert_eq!(names, vec!["prod", "test"]);
}
#[test]
fn test_resolved_env_search_service_dir_single() {
let env = ResolvedEnvironment {
name: "prod".to_string(),
search: vec![make_search_service("my-search")],
foundry: vec![],
sync: SyncConfig::default(),
};
let root = Path::new("/projects/myapp");
assert_eq!(
env.search_service_dir(root, &env.search[0]),
PathBuf::from("/projects/myapp/search")
);
}
#[test]
fn test_resolved_env_search_service_dir_multi_with_labels() {
let env = ResolvedEnvironment {
name: "prod".to_string(),
search: vec![
SearchServiceConfig {
label: Some("primary".to_string()),
..make_search_service("svc-1")
},
SearchServiceConfig {
label: Some("analytics".to_string()),
..make_search_service("svc-2")
},
],
foundry: vec![],
sync: SyncConfig::default(),
};
let root = Path::new("/projects/myapp");
assert_eq!(
env.search_service_dir(root, &env.search[0]),
PathBuf::from("/projects/myapp/search/primary")
);
assert_eq!(
env.search_service_dir(root, &env.search[1]),
PathBuf::from("/projects/myapp/search/analytics")
);
}
#[test]
fn test_resolved_env_foundry_service_dir_single() {
let env = ResolvedEnvironment {
name: "prod".to_string(),
search: vec![],
foundry: vec![make_foundry_service("my-ai", "proj-1")],
sync: SyncConfig::default(),
};
let root = Path::new("/projects/myapp");
assert_eq!(
env.foundry_service_dir(root, &env.foundry[0]),
PathBuf::from("/projects/myapp/foundry")
);
}
#[test]
fn test_resolved_env_foundry_service_dir_multi() {
let env = ResolvedEnvironment {
name: "prod".to_string(),
search: vec![],
foundry: vec![
FoundryServiceConfig {
label: Some("rag".to_string()),
..make_foundry_service("ai-svc", "rag-proj")
},
FoundryServiceConfig {
label: Some("chat".to_string()),
..make_foundry_service("ai-svc", "chat-proj")
},
],
sync: SyncConfig::default(),
};
let root = Path::new("/projects/myapp");
assert_eq!(
env.foundry_service_dir(root, &env.foundry[0]),
PathBuf::from("/projects/myapp/foundry/rag")
);
assert_eq!(
env.foundry_service_dir(root, &env.foundry[1]),
PathBuf::from("/projects/myapp/foundry/chat")
);
}
#[test]
fn test_resolved_env_has_foundry() {
let env = ResolvedEnvironment {
name: "prod".to_string(),
search: vec![],
foundry: vec![make_foundry_service("ai", "proj")],
sync: SyncConfig::default(),
};
assert!(env.has_foundry());
assert!(!env.has_search());
}
#[test]
fn test_resolved_env_primary_search_service() {
let env = ResolvedEnvironment {
name: "prod".to_string(),
search: vec![make_search_service("svc")],
foundry: vec![],
sync: SyncConfig::default(),
};
assert_eq!(env.primary_search_service().unwrap().name, "svc");
}
#[test]
fn test_save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = make_config_both();
config.save(dir.path()).unwrap();
let loaded = Config::load(dir.path()).unwrap();
let env = loaded.resolve_env(None).unwrap();
assert_eq!(env.search[0].name, "test-search");
assert_eq!(env.foundry[0].name, "test-ai");
assert_eq!(env.foundry[0].project, "test-proj");
}
#[test]
fn test_load_missing_file() {
let dir = tempfile::tempdir().unwrap();
let result = Config::load(dir.path());
assert!(result.is_err());
}
#[test]
fn test_load_from_yaml_string() {
let yaml = r#"
environments:
prod:
default: true
search:
- name: my-search-service
api_version: "2024-07-01"
project:
name: My Project
sync:
include_preview: false
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate().is_ok());
assert_eq!(config.environments.len(), 1);
let env = config.resolve_env(None).unwrap();
assert_eq!(env.search[0].name, "my-search-service");
assert!(!env.sync.include_preview);
}
#[test]
fn test_load_foundry_only() {
let yaml = r#"
environments:
prod:
foundry:
- name: my-ai-service
project: my-project
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate().is_ok());
let env = config.resolve_env(None).unwrap();
assert!(env.has_foundry());
assert_eq!(env.foundry[0].name, "my-ai-service");
assert_eq!(env.foundry[0].project, "my-project");
assert_eq!(env.foundry[0].api_version, "2025-05-15-preview");
}
#[test]
fn test_load_both_services() {
let yaml = r#"
environments:
prod:
default: true
search:
- name: my-search
foundry:
- name: my-ai
project: proj-1
project:
name: RAG System
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate().is_ok());
let env = config.resolve_env(None).unwrap();
assert_eq!(env.search.len(), 1);
assert!(env.has_foundry());
}
#[test]
fn test_load_multi_environment() {
let yaml = r#"
environments:
prod:
default: true
search:
- name: search-prod
test:
search:
- name: search-test
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate().is_ok());
assert_eq!(config.environment_names(), vec!["prod", "test"]);
let prod = config.resolve_env(Some("prod")).unwrap();
assert_eq!(prod.search[0].name, "search-prod");
let test = config.resolve_env(Some("test")).unwrap();
assert_eq!(test.search[0].name, "search-test");
}
#[test]
fn test_find_project_root_found() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("a/b/c");
fs::create_dir_all(&sub).unwrap();
fs::write(
dir.path().join(Config::FILENAME),
"environments:\n prod:\n search:\n - name: x\n",
)
.unwrap();
let found = find_project_root(&sub);
assert_eq!(found, Some(dir.path().to_path_buf()));
}
#[test]
fn test_find_project_root_not_found() {
let dir = tempfile::tempdir().unwrap();
let found = find_project_root(dir.path());
assert!(found.is_none());
}
#[test]
fn test_foundry_default_api_version() {
let yaml = r#"
environments:
prod:
foundry:
- name: my-ai
project: proj-1
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
let env = config.resolve_env(None).unwrap();
assert_eq!(env.foundry[0].api_version, "2025-05-15-preview");
}
#[test]
fn test_config_filename_is_yaml() {
assert_eq!(Config::FILENAME, "rigg.yaml");
}
#[test]
fn test_files_root_without_files_path() {
let config = make_config_search("svc");
let root = Path::new("/projects/myapp");
assert_eq!(config.files_root(root), PathBuf::from("/projects/myapp"));
}
#[test]
fn test_files_root_with_files_path() {
let mut config = make_config_search("svc");
config.project.files_path = Some("rigg".to_string());
let root = Path::new("/projects/myapp");
assert_eq!(
config.files_root(root),
PathBuf::from("/projects/myapp/rigg")
);
}
#[test]
fn test_files_root_nested_path() {
let mut config = make_config_search("svc");
config.project.files_path = Some("infra/rigg".to_string());
let root = Path::new("/projects/myapp");
assert_eq!(
config.files_root(root),
PathBuf::from("/projects/myapp/infra/rigg")
);
}
#[test]
fn test_files_path_yaml_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut config = make_config_search("svc");
config.project.files_path = Some("rigg".to_string());
config.save(dir.path()).unwrap();
let loaded = Config::load(dir.path()).unwrap();
assert_eq!(loaded.project.files_path, Some("rigg".to_string()));
}
#[test]
fn test_files_path_omitted_when_none() {
let config = make_config_search("svc");
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(!yaml.contains("files_path"));
}
}