cleanlib_client/
config.rs1use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15#[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 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 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
83pub fn default_path() -> Option<PathBuf> {
86 dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
87}
88
89pub 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
114pub 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
126pub 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 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 assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
233 assert!(c.telemetry.enabled);
234 }
235}