use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const DEFAULT_API_URL: &str = "https://api.cinchdb.dev";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub context: ContextConfig,
#[serde(default)]
pub api: ApiConfig,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AuthConfig {
pub token: Option<String>,
pub api_key: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ContextConfig {
pub org: Option<String>,
pub project: Option<String>,
pub environment: Option<String>,
pub scope: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ApiConfig {
pub url: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ProjectConfig {
pub project: Option<String>,
pub environment: Option<String>,
pub scope: Option<String>,
}
impl ConfigFile {
pub fn load() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let config: ConfigFile = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let content = toml::to_string_pretty(self)
.context("failed to serialize config")?;
std::fs::write(&path, content)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
pub fn auth_header_value(&self) -> Option<String> {
if let Ok(key) = std::env::var("CINCH_API_KEY") {
return Some(format!("Bearer {key}"));
}
if let Some(ref key) = self.auth.api_key {
return Some(format!("Bearer {key}"));
}
if let Some(ref token) = self.auth.token {
return Some(format!("Bearer {token}"));
}
None
}
}
pub fn resolve_context(config: &ConfigFile) -> ContextConfig {
let project_config = load_project_config();
ContextConfig {
org: config.context.org.clone(),
project: project_config
.as_ref()
.and_then(|p| p.project.clone())
.or_else(|| config.context.project.clone()),
environment: project_config
.as_ref()
.and_then(|p| p.environment.clone())
.or_else(|| config.context.environment.clone()),
scope: project_config
.as_ref()
.and_then(|p| p.scope.clone())
.or_else(|| config.context.scope.clone()),
}
}
pub fn resolve_api_url() -> Result<String> {
let config = ConfigFile::load()?;
Ok(config
.api
.url
.unwrap_or_else(|| DEFAULT_API_URL.to_string()))
}
pub fn config_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".cinch").join("config.toml"))
}
fn load_project_config() -> Option<ProjectConfig> {
let cwd = std::env::current_dir().ok()?;
find_project_config(&cwd)
}
fn find_project_config(start: &Path) -> Option<ProjectConfig> {
let mut dir = start;
loop {
let candidate = dir.join(".cinch");
if candidate.is_file() {
let content = match std::fs::read_to_string(&candidate) {
Ok(c) => c,
Err(e) => {
eprintln!(
"warning: could not read {}: {e}",
candidate.display()
);
return None;
}
};
match toml::from_str(&content) {
Ok(cfg) => return Some(cfg),
Err(e) => {
eprintln!(
"warning: could not parse {}: {e}",
candidate.display()
);
return None;
}
}
}
dir = dir.parent()?;
}
}
pub const SETTABLE_KEYS: &[&str] = &["org", "project", "environment", "scope", "api.url"];
pub fn set_config_value(key: &str, value: &str) -> Result<()> {
let mut config = ConfigFile::load()?;
match key {
"org" => config.context.org = Some(value.to_string()),
"project" => config.context.project = Some(value.to_string()),
"environment" => config.context.environment = Some(value.to_string()),
"scope" => config.context.scope = Some(value.to_string()),
"api.url" => config.api.url = Some(value.to_string()),
_ => anyhow::bail!(
"unknown config key '{key}'. Valid keys: {}",
SETTABLE_KEYS.join(", ")
),
}
config.save()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_roundtrip() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("config.toml");
let config = ConfigFile {
auth: AuthConfig {
token: Some("test-jwt".to_string()),
api_key: None,
},
context: ContextConfig {
org: Some("acme".to_string()),
project: Some("api".to_string()),
environment: Some("production".to_string()),
scope: Some("default".to_string()),
},
api: ApiConfig {
url: Some("https://custom.api.dev".to_string()),
},
};
let content = toml::to_string_pretty(&config).expect("serialize");
std::fs::write(&path, &content).expect("write");
let loaded: ConfigFile =
toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
assert_eq!(loaded.auth.token.as_deref(), Some("test-jwt"));
assert_eq!(loaded.context.org.as_deref(), Some("acme"));
assert_eq!(loaded.context.project.as_deref(), Some("api"));
assert_eq!(loaded.context.environment.as_deref(), Some("production"));
assert_eq!(loaded.context.scope.as_deref(), Some("default"));
assert_eq!(
loaded.api.url.as_deref(),
Some("https://custom.api.dev")
);
}
#[test]
fn test_default_config() {
let config = ConfigFile::default();
assert!(config.auth.token.is_none());
assert!(config.auth.api_key.is_none());
assert!(config.context.org.is_none());
assert!(config.api.url.is_none());
}
#[test]
fn test_auth_header_api_key_precedence() {
std::env::remove_var("CINCH_API_KEY");
let config = ConfigFile {
auth: AuthConfig {
token: Some("jwt-token".to_string()),
api_key: Some("ck_live_abc123".to_string()),
},
..Default::default()
};
assert_eq!(
config.auth_header_value().as_deref(),
Some("Bearer ck_live_abc123")
);
}
#[test]
fn test_auth_header_jwt_fallback() {
std::env::remove_var("CINCH_API_KEY");
let config = ConfigFile {
auth: AuthConfig {
token: Some("jwt-token".to_string()),
api_key: None,
},
..Default::default()
};
assert_eq!(
config.auth_header_value().as_deref(),
Some("Bearer jwt-token")
);
}
#[test]
fn test_auth_header_none() {
std::env::remove_var("CINCH_API_KEY");
let config = ConfigFile::default();
assert!(config.auth_header_value().is_none());
}
#[test]
fn test_project_config_parse() {
let toml_str = r#"
project = "api"
environment = "staging"
"#;
let config: ProjectConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(config.project.as_deref(), Some("api"));
assert_eq!(config.environment.as_deref(), Some("staging"));
assert!(config.scope.is_none());
}
#[test]
fn test_context_merge_project_overrides_global() {
let global = ConfigFile {
context: ContextConfig {
org: Some("acme".to_string()),
project: Some("global-proj".to_string()),
environment: Some("global-env".to_string()),
scope: Some("default".to_string()),
},
..Default::default()
};
let project = ProjectConfig {
project: Some("local-proj".to_string()),
environment: Some("staging".to_string()),
scope: None,
};
let resolved = ContextConfig {
org: global.context.org.clone(),
project: project
.project
.or(global.context.project.clone()),
environment: project
.environment
.or(global.context.environment.clone()),
scope: project.scope.or(global.context.scope.clone()),
};
assert_eq!(resolved.org.as_deref(), Some("acme"));
assert_eq!(resolved.project.as_deref(), Some("local-proj"));
assert_eq!(resolved.environment.as_deref(), Some("staging"));
assert_eq!(resolved.scope.as_deref(), Some("default"));
}
#[test]
fn test_find_project_config_walks_up() {
let dir = TempDir::new().expect("tempdir");
let nested = dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&nested).expect("mkdir");
let cinch_file = dir.path().join(".cinch");
std::fs::write(
&cinch_file,
"project = \"found\"\nenvironment = \"dev\"\n",
)
.expect("write");
let found = find_project_config(&nested);
assert!(found.is_some());
let found = found.expect("should find");
assert_eq!(found.project.as_deref(), Some("found"));
assert_eq!(found.environment.as_deref(), Some("dev"));
}
#[test]
fn test_settable_keys_list() {
assert!(SETTABLE_KEYS.contains(&"org"));
assert!(SETTABLE_KEYS.contains(&"project"));
assert!(SETTABLE_KEYS.contains(&"environment"));
assert!(SETTABLE_KEYS.contains(&"scope"));
assert!(SETTABLE_KEYS.contains(&"api.url"));
}
}