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,
60}
61
62impl Default for TelemetryConfig {
63 fn default() -> Self {
64 Self { enabled: false }
66 }
67}
68
69#[derive(Debug, Error)]
70pub enum ConfigError {
71 #[error("config file {path}: io error: {source}")]
72 Io {
73 path: PathBuf,
74 #[source]
75 source: std::io::Error,
76 },
77 #[error("config file {path}: parse error: {source}")]
78 Parse {
79 path: PathBuf,
80 #[source]
81 source: toml::de::Error,
82 },
83 #[error("config file {path}: serialize error: {source}")]
84 Serialize {
85 path: PathBuf,
86 #[source]
87 source: toml::ser::Error,
88 },
89}
90
91pub fn default_path() -> Option<PathBuf> {
94 dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
95}
96
97pub fn save(config: &Config, path: &Path) -> Result<(), ConfigError> {
103 if let Some(parent) = path.parent() {
104 if !parent.as_os_str().is_empty() {
105 std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
106 path: path.to_path_buf(),
107 source,
108 })?;
109 }
110 }
111 let content = toml::to_string(config).map_err(|source| ConfigError::Serialize {
112 path: path.to_path_buf(),
113 source,
114 })?;
115 std::fs::write(path, content).map_err(|source| ConfigError::Io {
116 path: path.to_path_buf(),
117 source,
118 })?;
119 Ok(())
120}
121
122pub fn load(path: &Path) -> Result<Config, ConfigError> {
124 let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
125 path: path.to_path_buf(),
126 source,
127 })?;
128 toml::from_str(&content).map_err(|source| ConfigError::Parse {
129 path: path.to_path_buf(),
130 source,
131 })
132}
133
134pub fn load_with_env_overrides(path: Option<&Path>) -> Result<Config, ConfigError> {
143 let mut config = match path {
144 Some(p) if p.exists() => load(p)?,
145 _ => Config::default(),
146 };
147
148 if let Ok(v) = std::env::var("CLEANLIBRARY_API_KEY") {
149 config.auth.api_key = Some(v);
150 }
151 if let Ok(v) = std::env::var("CLEANLIBRARY_ENDPOINT") {
152 config.endpoint.url = v;
153 }
154 if let Ok(v) = std::env::var("CLEANLIBRARY_API_VERSION") {
155 config.endpoint.api_version = v;
156 }
157 config.telemetry.enabled = telemetry_enabled();
162
163 Ok(config)
164}
165
166pub fn telemetry_enabled() -> bool {
172 resolve_telemetry(
173 std::env::var("DO_NOT_TRACK").ok().as_deref(),
174 std::env::var("CLEANLIBRARY_TELEMETRY").ok().as_deref(),
175 )
176}
177
178fn resolve_telemetry(do_not_track: Option<&str>, opt_in: Option<&str>) -> bool {
192 if let Some(v) = do_not_track {
193 if !v.is_empty() && v != "0" {
194 return false;
195 }
196 }
197 if let Some(v) = opt_in {
198 return matches!(v, "1" | "true" | "TRUE" | "True");
199 }
200 false
201}
202
203pub fn migrate_telemetry_consent(path: &Path) -> bool {
211 if !path.exists() {
212 return false;
213 }
214 let Ok(mut cfg) = load(path) else {
215 return false;
216 };
217 if !cfg.telemetry.enabled {
218 return false;
219 }
220 cfg.telemetry.enabled = false;
221 let _ = save(&cfg, path);
222 true
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn default_endpoint_is_app_cleanlibrary_io() {
231 let c = Config::default();
232 assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
233 assert_eq!(c.endpoint.api_version, "v1");
234 }
235
236 #[test]
237 fn default_telemetry_is_opt_out_off() {
238 let c = Config::default();
240 assert!(!c.telemetry.enabled);
241 }
242
243 #[test]
244 fn parse_full_config() {
245 let toml_str = r#"
246[auth]
247api_key = "cs_live_xyz"
248
249[endpoint]
250url = "https://staging.cleanlibrary.io"
251api_version = "v1"
252
253[telemetry]
254enabled = false
255"#;
256 let c: Config = toml::from_str(toml_str).unwrap();
257 assert_eq!(c.auth.api_key.as_deref(), Some("cs_live_xyz"));
258 assert_eq!(c.endpoint.url, "https://staging.cleanlibrary.io");
259 assert!(!c.telemetry.enabled);
260 }
261
262 #[test]
263 fn save_load_roundtrip() {
264 let tmp = std::env::temp_dir().join("cleanlib-config-roundtrip-test.toml");
265 let _ = std::fs::remove_file(&tmp);
266
267 let mut cfg = Config::default();
268 cfg.auth.api_key = Some("cs_live_roundtrip".to_string());
269 save(&cfg, &tmp).unwrap();
270
271 let loaded = load(&tmp).unwrap();
272 assert_eq!(loaded.auth.api_key.as_deref(), Some("cs_live_roundtrip"));
273 assert_eq!(loaded.endpoint.url, "https://cleanapp.clnstrt.dev");
275 assert!(!loaded.telemetry.enabled); let _ = std::fs::remove_file(&tmp);
278 }
279
280 #[test]
281 fn save_creates_parent_dirs() {
282 let tmp_base = std::env::temp_dir().join("cleanlib-config-parent-test");
283 let _ = std::fs::remove_dir_all(&tmp_base);
284 let nested = tmp_base.join("nested").join("dir").join("config.toml");
285
286 let cfg = Config::default();
287 save(&cfg, &nested).unwrap();
288 assert!(nested.exists());
289
290 let _ = std::fs::remove_dir_all(&tmp_base);
291 }
292
293 #[test]
294 fn partial_config_uses_defaults() {
295 let toml_str = r#"
296[auth]
297api_key = "cs_live_xyz"
298"#;
299 let c: Config = toml::from_str(toml_str).unwrap();
300 assert_eq!(c.auth.api_key.as_deref(), Some("cs_live_xyz"));
301 assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
303 assert!(!c.telemetry.enabled); }
305
306 #[test]
309 fn resolve_telemetry_default_off_no_env() {
310 assert!(!resolve_telemetry(None, None));
312 }
313
314 #[test]
315 fn resolve_telemetry_opt_in_env_turns_on() {
316 for v in ["1", "true", "TRUE", "True"] {
317 assert!(resolve_telemetry(None, Some(v)), "opt-in {v:?} should enable");
318 }
319 for v in ["0", "false", "no", ""] {
320 assert!(!resolve_telemetry(None, Some(v)), "opt-in {v:?} should not enable");
321 }
322 }
323
324 #[test]
325 fn resolve_telemetry_do_not_track_hard_off_even_with_opt_in() {
326 for dnt in ["1", "true", "yes", "please"] {
329 assert!(
330 !resolve_telemetry(Some(dnt), Some("1")),
331 "DO_NOT_TRACK={dnt:?} must hard-off even with opt-in=1"
332 );
333 }
334 assert!(resolve_telemetry(Some(""), Some("1")));
336 assert!(resolve_telemetry(Some("0"), Some("1")));
337 }
338
339 #[test]
340 fn migrate_telemetry_consent_resets_persisted_true_once() {
341 let tmp = std::env::temp_dir().join("cleanlib-627-migrate-test.toml");
342 let _ = std::fs::remove_file(&tmp);
343 std::fs::write(&tmp, "[telemetry]\nenabled = true\n").unwrap();
345
346 assert!(migrate_telemetry_consent(&tmp), "first run must reset + signal");
348 let after = load(&tmp).unwrap();
349 assert!(!after.telemetry.enabled, "file must now hold false (cat shows false)");
350
351 assert!(!migrate_telemetry_consent(&tmp), "second run must be a no-op");
353
354 let _ = std::fs::remove_file(&tmp);
355 }
356
357 #[test]
358 fn migrate_telemetry_consent_absent_file_is_noop() {
359 let missing = std::env::temp_dir().join("cleanlib-627-does-not-exist.toml");
360 let _ = std::fs::remove_file(&missing);
361 assert!(!migrate_telemetry_consent(&missing));
362 }
363}