use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[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 {
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 {
pub enabled: bool,
}
impl Default for TelemetryConfig {
fn default() -> Self {
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,
},
}
pub fn default_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".cleanlibrary").join("config.toml"))
}
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(())
}
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,
})
}
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;
}
config.telemetry.enabled = telemetry_enabled();
Ok(config)
}
pub fn telemetry_enabled() -> bool {
resolve_telemetry(
std::env::var("DO_NOT_TRACK").ok().as_deref(),
std::env::var("CLEANLIBRARY_TELEMETRY").ok().as_deref(),
)
}
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
}
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() {
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"));
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"));
assert_eq!(c.endpoint.url, "https://cleanapp.clnstrt.dev");
assert!(!c.telemetry.enabled); }
#[test]
fn resolve_telemetry_default_off_no_env() {
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() {
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"
);
}
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);
std::fs::write(&tmp, "[telemetry]\nenabled = true\n").unwrap();
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)");
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));
}
}