Skip to main content

cleanlib_client/
config.rs

1//! Config-file + env-var loader per Client spec rev1 §5 and SDK config-file
2//! format decision 2026-05-20. TOML on disk at `~/.cleanlibrary/config.toml`;
3//! env vars override file values.
4//!
5//! Precedence (highest wins): explicit constructor args > env vars >
6//! `~/.cleanlibrary/config.toml` > defaults. Constructor-arg precedence is
7//! consumer-side (CLI flags / SDK options); this module exposes file + env
8//! merging.
9
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15/// Top-level config schema matching the canonical TOML structure.
16#[derive(Debug, Default, Deserialize, Serialize)]
17#[serde(default)]
18pub struct Config {
19    pub auth: AuthConfig,
20    pub endpoint: EndpointConfig,
21    pub telemetry: TelemetryConfig,
22}
23
24#[derive(Debug, Default, Deserialize, Serialize)]
25#[serde(default)]
26pub struct AuthConfig {
27    /// Opaque CleanLibrary API key. Sent as `Authorization: Bearer <key>` per
28    /// matrix §5 + App Rev 1 §7 item 1.
29    pub api_key: Option<String>,
30}
31
32#[derive(Debug, Deserialize, Serialize)]
33#[serde(default)]
34pub struct EndpointConfig {
35    pub url: String,
36    pub api_version: String,
37}
38
39impl Default for EndpointConfig {
40    fn default() -> Self {
41        Self {
42            url: "https://cleanapp.clnstrt.dev".to_string(),
43            api_version: "v1".to_string(),
44        }
45    }
46}
47
48#[derive(Debug, Deserialize, Serialize)]
49#[serde(default)]
50pub struct TelemetryConfig {
51    /// Phase 1 design-partner default-on per matrix §13.
52    pub enabled: bool,
53}
54
55impl Default for TelemetryConfig {
56    fn default() -> Self {
57        Self { enabled: true }
58    }
59}
60
61#[derive(Debug, Error)]
62pub enum ConfigError {
63    #[error("config file {path}: io error: {source}")]
64    Io {
65        path: PathBuf,
66        #[source]
67        source: std::io::Error,
68    },
69    #[error("config file {path}: parse error: {source}")]
70    Parse {
71        path: PathBuf,
72        #[source]
73        source: toml::de::Error,
74    },
75    #[error("config file {path}: serialize error: {source}")]
76    Serialize {
77        path: PathBuf,
78        #[source]
79        source: toml::ser::Error,
80    },
81}
82
83/// Default config-file path: `$HOME/.cleanlibrary/config.toml`.
84/// Returns `None` if the OS has no discoverable home dir.
85pub fn default_path() -> Option<PathBuf> {
86    dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
87}
88
89/// Save config to TOML file. Creates parent dirs if needed. Overwrites
90/// existing content as a whole-file rewrite — preserves all explicit
91/// `Config` field values (auth/endpoint/telemetry) but does NOT preserve
92/// comments or non-Config keys. Customer should use env-var precedence for
93/// dynamic auth values to avoid hand-editing the file.
94pub fn save(config: &Config, path: &Path) -> Result<(), ConfigError> {
95    if let Some(parent) = path.parent() {
96        if !parent.as_os_str().is_empty() {
97            std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
98                path: path.to_path_buf(),
99                source,
100            })?;
101        }
102    }
103    let content = toml::to_string(config).map_err(|source| ConfigError::Serialize {
104        path: path.to_path_buf(),
105        source,
106    })?;
107    std::fs::write(path, content).map_err(|source| ConfigError::Io {
108        path: path.to_path_buf(),
109        source,
110    })?;
111    Ok(())
112}
113
114/// Load + parse a TOML config file.
115pub fn load(path: &Path) -> Result<Config, ConfigError> {
116    let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
117        path: path.to_path_buf(),
118        source,
119    })?;
120    toml::from_str(&content).map_err(|source| ConfigError::Parse {
121        path: path.to_path_buf(),
122        source,
123    })
124}
125
126/// Load config with env-var overrides applied on top of file values.
127/// Returns `Config::default()` if `path` is `None` or the file doesn't exist.
128///
129/// Env var precedence per Client spec rev1 §5:
130/// - `CLEANLIBRARY_API_KEY` → `auth.api_key`
131/// - `CLEANLIBRARY_ENDPOINT` → `endpoint.url`
132/// - `CLEANLIBRARY_API_VERSION` → `endpoint.api_version`
133/// - `CLEANLIBRARY_TELEMETRY` (`0`/`1`/`true`/`false`) → `telemetry.enabled`
134pub fn load_with_env_overrides(path: Option<&Path>) -> Result<Config, ConfigError> {
135    let mut config = match path {
136        Some(p) if p.exists() => load(p)?,
137        _ => Config::default(),
138    };
139
140    if let Ok(v) = std::env::var("CLEANLIBRARY_API_KEY") {
141        config.auth.api_key = Some(v);
142    }
143    if let Ok(v) = std::env::var("CLEANLIBRARY_ENDPOINT") {
144        config.endpoint.url = v;
145    }
146    if let Ok(v) = std::env::var("CLEANLIBRARY_API_VERSION") {
147        config.endpoint.api_version = v;
148    }
149    if let Ok(v) = std::env::var("CLEANLIBRARY_TELEMETRY") {
150        config.telemetry.enabled = matches!(v.as_str(), "1" | "true" | "TRUE" | "True");
151    }
152
153    Ok(config)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn default_endpoint_is_app_cleanlibrary_io() {
162        let c = Config::default();
163        assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
164        assert_eq!(c.endpoint.api_version, "v1");
165    }
166
167    #[test]
168    fn default_telemetry_opt_in_on() {
169        let c = Config::default();
170        assert!(c.telemetry.enabled);
171    }
172
173    #[test]
174    fn parse_full_config() {
175        let toml_str = r#"
176[auth]
177api_key = "cs_live_xyz"
178
179[endpoint]
180url = "https://staging.cleanlibrary.io"
181api_version = "v1"
182
183[telemetry]
184enabled = false
185"#;
186        let c: Config = toml::from_str(toml_str).unwrap();
187        assert_eq!(c.auth.api_key.as_deref(), Some("cs_live_xyz"));
188        assert_eq!(c.endpoint.url, "https://staging.cleanlibrary.io");
189        assert!(!c.telemetry.enabled);
190    }
191
192    #[test]
193    fn save_load_roundtrip() {
194        let tmp = std::env::temp_dir().join("cleanlib-config-roundtrip-test.toml");
195        let _ = std::fs::remove_file(&tmp);
196
197        let mut cfg = Config::default();
198        cfg.auth.api_key = Some("cs_live_roundtrip".to_string());
199        save(&cfg, &tmp).unwrap();
200
201        let loaded = load(&tmp).unwrap();
202        assert_eq!(loaded.auth.api_key.as_deref(), Some("cs_live_roundtrip"));
203        // Defaults preserved
204        assert_eq!(loaded.endpoint.url, "https://cleanapp.clnstrt.dev");
205        assert!(loaded.telemetry.enabled);
206
207        let _ = std::fs::remove_file(&tmp);
208    }
209
210    #[test]
211    fn save_creates_parent_dirs() {
212        let tmp_base = std::env::temp_dir().join("cleanlib-config-parent-test");
213        let _ = std::fs::remove_dir_all(&tmp_base);
214        let nested = tmp_base.join("nested").join("dir").join("config.toml");
215
216        let cfg = Config::default();
217        save(&cfg, &nested).unwrap();
218        assert!(nested.exists());
219
220        let _ = std::fs::remove_dir_all(&tmp_base);
221    }
222
223    #[test]
224    fn partial_config_uses_defaults() {
225        let toml_str = r#"
226[auth]
227api_key = "cs_live_xyz"
228"#;
229        let c: Config = toml::from_str(toml_str).unwrap();
230        assert_eq!(c.auth.api_key.as_deref(), Some("cs_live_xyz"));
231        // endpoint + telemetry fall back to Default
232        assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
233        assert!(c.telemetry.enabled);
234    }
235}