cleanlib-client 0.3.0

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! Config-file + env-var loader per Client spec rev1 §5 and SDK config-file
//! format decision 2026-05-20. TOML on disk at `~/.cleanlibrary/config.toml`;
//! env vars override file values.
//!
//! Precedence (highest wins): explicit constructor args > env vars >
//! `~/.cleanlibrary/config.toml` > defaults. Constructor-arg precedence is
//! consumer-side (CLI flags / SDK options); this module exposes file + env
//! merging.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Top-level config schema matching the canonical TOML structure.
#[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 {
    /// Opaque CleanLibrary API key. Sent as `Authorization: Bearer <key>` per
    /// matrix §5 + App Rev 1 §7 item 1.
    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 {
    /// CLEANLIB-627 (D-3, PM-ratified c774228): telemetry is OFF by default —
    /// opt-in, not opt-out. Do NOT read this field to decide whether telemetry
    /// is on: a persisted `true` is not consent (it only ever reached disk as a
    /// `save()` side effect of the old default-on struct — a pre-ticked box,
    /// which GDPR Recital 32 / Planet49 rule out as consent). Call
    /// [`telemetry_enabled`] instead — the file can turn telemetry OFF but never
    /// ON. There is no emitter yet, so nothing is sent regardless; this is
    /// consent hygiene ahead of one existing.
    pub enabled: bool,
}

impl Default for TelemetryConfig {
    fn default() -> Self {
        // CLEANLIB-627: opt-in. Was `true` (opt-out) — flipped per D-3.
        Self { enabled: false }
    }
}

#[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,
    },
}

/// Default config-file path: `$HOME/.cleanlibrary/config.toml`.
/// Returns `None` if the OS has no discoverable home dir.
pub fn default_path() -> Option<PathBuf> {
    dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
}

/// Save config to TOML file. Creates parent dirs if needed. Overwrites
/// existing content as a whole-file rewrite — preserves all explicit
/// `Config` field values (auth/endpoint/telemetry) but does NOT preserve
/// comments or non-Config keys. Customer should use env-var precedence for
/// dynamic auth values to avoid hand-editing the file.
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(())
}

/// Load + parse a TOML config file.
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,
    })
}

/// Load config with env-var overrides applied on top of file values.
/// Returns `Config::default()` if `path` is `None` or the file doesn't exist.
///
/// Env var precedence per Client spec rev1 §5:
/// - `CLEANLIBRARY_API_KEY` → `auth.api_key`
/// - `CLEANLIBRARY_ENDPOINT` → `endpoint.url`
/// - `CLEANLIBRARY_API_VERSION` → `endpoint.api_version`
/// - `CLEANLIBRARY_TELEMETRY` (`0`/`1`/`true`/`false`) → `telemetry.enabled`
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;
    }
    // CLEANLIB-627: resolve the EFFECTIVE telemetry state (DO_NOT_TRACK / opt-in
    // env / file-ignored-for-true), overwriting whatever the file said. After
    // this, `config.telemetry.enabled` is authoritative-effective, so no
    // consumer (status included) can read a persisted pre-ticked `true` as on.
    config.telemetry.enabled = telemetry_enabled();

    Ok(config)
}

/// The EFFECTIVE telemetry-enabled state — the single source of truth for
/// "is telemetry on?". Both a future emitter and `cleanlib status` MUST call
/// this rather than reading `TelemetryConfig::enabled`, so the file's
/// pre-ticked `true` can never be reported as enabled (CLEANLIB-627, the
/// status-honesty defect + PM gate c). See [`resolve_telemetry`] for the rules.
pub fn telemetry_enabled() -> bool {
    resolve_telemetry(
        std::env::var("DO_NOT_TRACK").ok().as_deref(),
        std::env::var("CLEANLIBRARY_TELEMETRY").ok().as_deref(),
    )
}

/// Pure precedence resolver for [`telemetry_enabled`] (env values passed in, so
/// it is testable without mutating process env). CLEANLIB-627 Option A + PM
/// amendments (c774228), highest wins:
///   1. `DO_NOT_TRACK` any non-empty, non-`0` value -> OFF, hard short-circuit
///      (Amendment 1: the donottrack.sh cross-tool convention; checked FIRST so
///      a globally opted-out developer never learns our variable name).
///   2. `CLEANLIBRARY_TELEMETRY` -> its boolean (Amendment 2: the env var is the
///      ONLY channel that can turn telemetry ON — it cannot be produced by
///      serialisation, unlike the config file).
///   3. neither set -> OFF. The config file is honoured for `false` but IGNORED
///      for `true` (D-3): a file can turn telemetry off but never on — off is a
///      safe direction to accept from an unverified source, on is not. So the
///      file never contributes an ON, and the effective value is simply OFF.
fn resolve_telemetry(do_not_track: Option<&str>, opt_in: Option<&str>) -> bool {
    if let Some(v) = do_not_track {
        if !v.is_empty() && v != "0" {
            return false;
        }
    }
    if let Some(v) = opt_in {
        return matches!(v, "1" | "true" | "TRUE" | "True");
    }
    false
}

/// CLEANLIB-627 §1/§4: force a file-persisted `telemetry.enabled = true` back to
/// `false`, once. Returns `true` if a reset was performed, so the caller can
/// show the one-time §4 notice. Self-clearing — after the rewrite the file holds
/// `false`, so this is a no-op on every subsequent run (no marker needed). The
/// rewrite is what makes `cat config.toml` honestly show `false` (§5); the
/// runtime already ignores file-true via [`telemetry_enabled`], so a failed
/// rewrite still leaves telemetry off. Best-effort: IO errors are swallowed.
pub fn migrate_telemetry_consent(path: &Path) -> bool {
    if !path.exists() {
        return false;
    }
    let Ok(mut cfg) = load(path) else {
        return false;
    };
    if !cfg.telemetry.enabled {
        return false;
    }
    cfg.telemetry.enabled = false;
    let _ = save(&cfg, path);
    true
}

#[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_is_opt_out_off() {
        // CLEANLIB-627 (D-3): telemetry defaults OFF (opt-in), was opt-out.
        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"));
        // Defaults preserved
        assert_eq!(loaded.endpoint.url, "https://cleanapp.clnstrt.dev");
        assert!(!loaded.telemetry.enabled); // CLEANLIB-627: default is off

        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"));
        // endpoint + telemetry fall back to Default
        assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
        assert!(!c.telemetry.enabled); // CLEANLIB-627: default is off
    }

    // ── CLEANLIB-627 telemetry consent (Option A + PM amendments) ───────────

    #[test]
    fn resolve_telemetry_default_off_no_env() {
        // Neither env set: OFF. The file cannot turn it on.
        assert!(!resolve_telemetry(None, None));
    }

    #[test]
    fn resolve_telemetry_opt_in_env_turns_on() {
        for v in ["1", "true", "TRUE", "True"] {
            assert!(resolve_telemetry(None, Some(v)), "opt-in {v:?} should enable");
        }
        for v in ["0", "false", "no", ""] {
            assert!(!resolve_telemetry(None, Some(v)), "opt-in {v:?} should not enable");
        }
    }

    #[test]
    fn resolve_telemetry_do_not_track_hard_off_even_with_opt_in() {
        // Amendment 1/2 + PM gate (b): DO_NOT_TRACK suppresses even when the
        // opt-in env var is set ON. Any non-empty, non-"0" value counts.
        for dnt in ["1", "true", "yes", "please"] {
            assert!(
                !resolve_telemetry(Some(dnt), Some("1")),
                "DO_NOT_TRACK={dnt:?} must hard-off even with opt-in=1"
            );
        }
        // Empty or "0" is NOT a suppress -> falls through to the opt-in.
        assert!(resolve_telemetry(Some(""), Some("1")));
        assert!(resolve_telemetry(Some("0"), Some("1")));
    }

    #[test]
    fn migrate_telemetry_consent_resets_persisted_true_once() {
        let tmp = std::env::temp_dir().join("cleanlib-627-migrate-test.toml");
        let _ = std::fs::remove_file(&tmp);
        // A pre-627 config with the side-effect true persisted.
        std::fs::write(&tmp, "[telemetry]\nenabled = true\n").unwrap();

        // First run: migrates true -> false, signals the §4 notice.
        assert!(migrate_telemetry_consent(&tmp), "first run must reset + signal");
        let after = load(&tmp).unwrap();
        assert!(!after.telemetry.enabled, "file must now hold false (cat shows false)");

        // Self-clearing: second run is a no-op (no repeat notice, no marker).
        assert!(!migrate_telemetry_consent(&tmp), "second run must be a no-op");

        let _ = std::fs::remove_file(&tmp);
    }

    #[test]
    fn migrate_telemetry_consent_absent_file_is_noop() {
        let missing = std::env::temp_dir().join("cleanlib-627-does-not-exist.toml");
        let _ = std::fs::remove_file(&missing);
        assert!(!migrate_telemetry_consent(&missing));
    }
}