use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub auth: AuthConfig,
pub endpoint: EndpointConfig,
pub telemetry: TelemetryConfig,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AuthConfig {
pub api_key: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct EndpointConfig {
pub url: String,
pub api_version: String,
}
impl Default for EndpointConfig {
fn default() -> Self {
Self {
url: "https://cleanapp.clnstrt.dev".to_string(),
api_version: "v1".to_string(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct TelemetryConfig {
pub enabled: bool,
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file {path}: io error: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("config file {path}: parse error: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("config file {path}: serialize error: {source}")]
Serialize {
path: PathBuf,
#[source]
source: toml::ser::Error,
},
}
pub fn default_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
}
pub fn save(config: &Config, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
}
}
let content = toml::to_string(config).map_err(|source| ConfigError::Serialize {
path: path.to_path_buf(),
source,
})?;
std::fs::write(path, content).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
toml::from_str(&content).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})
}
pub fn load_with_env_overrides(path: Option<&Path>) -> Result<Config, ConfigError> {
let mut config = match path {
Some(p) if p.exists() => load(p)?,
_ => Config::default(),
};
if let Ok(v) = std::env::var("CLEANLIBRARY_API_KEY") {
config.auth.api_key = Some(v);
}
if let Ok(v) = std::env::var("CLEANLIBRARY_ENDPOINT") {
config.endpoint.url = v;
}
if let Ok(v) = std::env::var("CLEANLIBRARY_API_VERSION") {
config.endpoint.api_version = v;
}
if let Ok(v) = std::env::var("CLEANLIBRARY_TELEMETRY") {
config.telemetry.enabled = matches!(v.as_str(), "1" | "true" | "TRUE" | "True");
}
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_endpoint_is_app_cleanlibrary_io() {
let c = Config::default();
assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
assert_eq!(c.endpoint.api_version, "v1");
}
#[test]
fn default_telemetry_opt_in_on() {
let c = Config::default();
assert!(c.telemetry.enabled);
}
#[test]
fn parse_full_config() {
let toml_str = r#"
[auth]
api_key = "cs_live_xyz"
[endpoint]
url = "https://staging.cleanlibrary.io"
api_version = "v1"
[telemetry]
enabled = false
"#;
let c: Config = toml::from_str(toml_str).unwrap();
assert_eq!(c.auth.api_key.as_deref(), Some("cs_live_xyz"));
assert_eq!(c.endpoint.url, "https://staging.cleanlibrary.io");
assert!(!c.telemetry.enabled);
}
#[test]
fn save_load_roundtrip() {
let tmp = std::env::temp_dir().join("cleanlib-config-roundtrip-test.toml");
let _ = std::fs::remove_file(&tmp);
let mut cfg = Config::default();
cfg.auth.api_key = Some("cs_live_roundtrip".to_string());
save(&cfg, &tmp).unwrap();
let loaded = load(&tmp).unwrap();
assert_eq!(loaded.auth.api_key.as_deref(), Some("cs_live_roundtrip"));
assert_eq!(loaded.endpoint.url, "https://cleanapp.clnstrt.dev");
assert!(loaded.telemetry.enabled);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn save_creates_parent_dirs() {
let tmp_base = std::env::temp_dir().join("cleanlib-config-parent-test");
let _ = std::fs::remove_dir_all(&tmp_base);
let nested = tmp_base.join("nested").join("dir").join("config.toml");
let cfg = Config::default();
save(&cfg, &nested).unwrap();
assert!(nested.exists());
let _ = std::fs::remove_dir_all(&tmp_base);
}
#[test]
fn partial_config_uses_defaults() {
let toml_str = r#"
[auth]
api_key = "cs_live_xyz"
"#;
let c: Config = toml::from_str(toml_str).unwrap();
assert_eq!(c.auth.api_key.as_deref(), Some("cs_live_xyz"));
assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
assert!(c.telemetry.enabled);
}
}