use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub struct ProjectConfig {
#[serde(default)]
pub service: ServiceConfig,
#[serde(default)]
pub environments: HashMap<String, EnvironmentConfig>,
#[serde(skip)]
pub project_dir: PathBuf,
}
#[derive(Debug, Deserialize, Default)]
pub struct ServiceConfig {
pub name: Option<String>,
pub mode: Option<String>,
pub default_env: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct EnvironmentConfig {
pub api_url: String,
pub tenant: String,
pub api_key_env: Option<String>,
pub api_key: Option<String>,
pub keycloak_url: Option<String>,
pub keycloak_realm: Option<String>,
pub keycloak_client_id: Option<String>,
#[serde(default)]
pub config: HashMap<String, String>,
#[serde(default)]
pub secrets: HashMap<String, String>,
}
impl ProjectConfig {
pub(crate) fn parsed(content: &str, dir: &std::path::Path) -> Result<Self, toml::de::Error> {
let mut config: ProjectConfig = toml::from_str(content)?;
config.project_dir = dir.to_path_buf();
config
.environments
.entry(crate::commands::dev::ENV.to_string())
.or_insert_with(crate::commands::dev::environment);
Ok(config)
}
pub fn find_and_load() -> eyre::Result<Option<Self>> {
let cwd = std::env::current_dir()?;
let mut dir = cwd.as_path();
loop {
let candidate = dir.join("Cufflink.toml");
if candidate.exists() {
let content = std::fs::read_to_string(&candidate)?;
return Self::parsed(&content, dir)
.map(Some)
.map_err(|e| eyre::eyre!("Failed to parse {}: {}", candidate.display(), e));
}
match dir.parent() {
Some(parent) => dir = parent,
None => return Ok(None),
}
}
}
pub fn get_env(&self, name: &str) -> eyre::Result<&EnvironmentConfig> {
self.environments.get(name).ok_or_else(|| {
let available: Vec<&str> = self.environments.keys().map(|k| k.as_str()).collect();
eyre::eyre!(
"Environment '{}' not found in Cufflink.toml. Available: {:?}",
name,
available
)
})
}
pub fn resolve_env(&self, env_arg: Option<&str>) -> eyre::Result<Option<&EnvironmentConfig>> {
let env_name = env_arg
.map(|s| s.to_string())
.or_else(|| self.service.default_env.clone());
match env_name {
Some(name) => Ok(Some(self.get_env(&name)?)),
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn a_project_has_the_development_stack_as_dev_unless_it_declares_one() {
let undeclared = ProjectConfig::parsed(
"[service]\nname = \"notes\"\ndefault_env = \"staging\"\n\n\
[environments.staging]\napi_url = \"https://api.staging.test\"\ntenant = \"acme\"\n",
Path::new("/work/notes"),
)
.expect("a project");
assert_eq!(undeclared.project_dir, Path::new("/work/notes"));
let dev = undeclared.get_env("dev").expect("dev");
assert_eq!(
(dev.api_url.as_str(), dev.tenant.as_str()),
("http://localhost:8080", "dev")
);
let default = undeclared
.resolve_env(None)
.expect("resolved")
.expect("staging");
assert_eq!(default.api_url, "https://api.staging.test");
let declared = ProjectConfig::parsed(
"[environments.dev]\napi_url = \"http://localhost:9999\"\ntenant = \"mine\"\n",
Path::new("/work/notes"),
)
.expect("a project");
let dev = declared.get_env("dev").expect("dev");
assert_eq!(
(
dev.api_url.as_str(),
dev.tenant.as_str(),
dev.keycloak_realm.as_deref()
),
("http://localhost:9999", "mine", None)
);
assert!(ProjectConfig::parsed("", Path::new("/"))
.expect("empty")
.resolve_env(None)
.expect("none")
.is_none());
}
}