cleanlib-client 0.1.1

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 {
    /// Phase 1 design-partner default-on per matrix §13.
    pub enabled: bool,
}

impl Default for TelemetryConfig {
    fn default() -> Self {
        Self { enabled: true }
    }
}

#[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;
    }
    if let Ok(v) = std::env::var("CLEANLIBRARY_TELEMETRY") {
        config.telemetry.enabled = matches!(v.as_str(), "1" | "true" | "TRUE" | "True");
    }

    Ok(config)
}

#[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_opt_in_on() {
        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);

        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);
    }
}