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    /// CLEANLIB-627 (D-3, PM-ratified c774228): telemetry is OFF by default —
52    /// opt-in, not opt-out. Do NOT read this field to decide whether telemetry
53    /// is on: a persisted `true` is not consent (it only ever reached disk as a
54    /// `save()` side effect of the old default-on struct — a pre-ticked box,
55    /// which GDPR Recital 32 / Planet49 rule out as consent). Call
56    /// [`telemetry_enabled`] instead — the file can turn telemetry OFF but never
57    /// ON. There is no emitter yet, so nothing is sent regardless; this is
58    /// consent hygiene ahead of one existing.
59    pub enabled: bool,
60}
61
62impl Default for TelemetryConfig {
63    fn default() -> Self {
64        // CLEANLIB-627: opt-in. Was `true` (opt-out) — flipped per D-3.
65        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
91/// Default config-file path: `$HOME/.cleanlibrary/config.toml`.
92/// Returns `None` if the OS has no discoverable home dir.
93pub fn default_path() -> Option<PathBuf> {
94    dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
95}
96
97/// Save config to TOML file. Creates parent dirs if needed. Overwrites
98/// existing content as a whole-file rewrite — preserves all explicit
99/// `Config` field values (auth/endpoint/telemetry) but does NOT preserve
100/// comments or non-Config keys. Customer should use env-var precedence for
101/// dynamic auth values to avoid hand-editing the file.
102pub 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
122/// Load + parse a TOML config file.
123pub 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
134/// Load config with env-var overrides applied on top of file values.
135/// Returns `Config::default()` if `path` is `None` or the file doesn't exist.
136///
137/// Env var precedence per Client spec rev1 §5:
138/// - `CLEANLIBRARY_API_KEY` → `auth.api_key`
139/// - `CLEANLIBRARY_ENDPOINT` → `endpoint.url`
140/// - `CLEANLIBRARY_API_VERSION` → `endpoint.api_version`
141/// - `CLEANLIBRARY_TELEMETRY` (`0`/`1`/`true`/`false`) → `telemetry.enabled`
142pub 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    // CLEANLIB-627: resolve the EFFECTIVE telemetry state (DO_NOT_TRACK / opt-in
158    // env / file-ignored-for-true), overwriting whatever the file said. After
159    // this, `config.telemetry.enabled` is authoritative-effective, so no
160    // consumer (status included) can read a persisted pre-ticked `true` as on.
161    config.telemetry.enabled = telemetry_enabled();
162
163    Ok(config)
164}
165
166/// The EFFECTIVE telemetry-enabled state — the single source of truth for
167/// "is telemetry on?". Both a future emitter and `cleanlib status` MUST call
168/// this rather than reading `TelemetryConfig::enabled`, so the file's
169/// pre-ticked `true` can never be reported as enabled (CLEANLIB-627, the
170/// status-honesty defect + PM gate c). See [`resolve_telemetry`] for the rules.
171pub 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
178/// Pure precedence resolver for [`telemetry_enabled`] (env values passed in, so
179/// it is testable without mutating process env). CLEANLIB-627 Option A + PM
180/// amendments (c774228), highest wins:
181///   1. `DO_NOT_TRACK` any non-empty, non-`0` value -> OFF, hard short-circuit
182///      (Amendment 1: the donottrack.sh cross-tool convention; checked FIRST so
183///      a globally opted-out developer never learns our variable name).
184///   2. `CLEANLIBRARY_TELEMETRY` -> its boolean (Amendment 2: the env var is the
185///      ONLY channel that can turn telemetry ON — it cannot be produced by
186///      serialisation, unlike the config file).
187///   3. neither set -> OFF. The config file is honoured for `false` but IGNORED
188///      for `true` (D-3): a file can turn telemetry off but never on — off is a
189///      safe direction to accept from an unverified source, on is not. So the
190///      file never contributes an ON, and the effective value is simply OFF.
191fn 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
203/// CLEANLIB-627 §1/§4: force a file-persisted `telemetry.enabled = true` back to
204/// `false`, once. Returns `true` if a reset was performed, so the caller can
205/// show the one-time §4 notice. Self-clearing — after the rewrite the file holds
206/// `false`, so this is a no-op on every subsequent run (no marker needed). The
207/// rewrite is what makes `cat config.toml` honestly show `false` (§5); the
208/// runtime already ignores file-true via [`telemetry_enabled`], so a failed
209/// rewrite still leaves telemetry off. Best-effort: IO errors are swallowed.
210pub 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        // CLEANLIB-627 (D-3): telemetry defaults OFF (opt-in), was opt-out.
239        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        // Defaults preserved
274        assert_eq!(loaded.endpoint.url, "https://cleanapp.clnstrt.dev");
275        assert!(!loaded.telemetry.enabled); // CLEANLIB-627: default is off
276
277        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        // endpoint + telemetry fall back to Default
302        assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
303        assert!(!c.telemetry.enabled); // CLEANLIB-627: default is off
304    }
305
306    // ── CLEANLIB-627 telemetry consent (Option A + PM amendments) ───────────
307
308    #[test]
309    fn resolve_telemetry_default_off_no_env() {
310        // Neither env set: OFF. The file cannot turn it on.
311        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        // Amendment 1/2 + PM gate (b): DO_NOT_TRACK suppresses even when the
327        // opt-in env var is set ON. Any non-empty, non-"0" value counts.
328        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        // Empty or "0" is NOT a suppress -> falls through to the opt-in.
335        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        // A pre-627 config with the side-effect true persisted.
344        std::fs::write(&tmp, "[telemetry]\nenabled = true\n").unwrap();
345
346        // First run: migrates true -> false, signals the §4 notice.
347        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        // Self-clearing: second run is a no-op (no repeat notice, no marker).
352        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}