openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `config get / set / list` — basic key/value access over `~/.openlatch/provider/config.toml`.
//!
//! Supported keys (T2.4):
//!   - `telemetry.enabled` (bool, mirrors `~/.openlatch/provider/telemetry.json`)
//!   - `crashreport.enabled` (bool)
//!   - `profiles.<name>.api_url` (string)
//!
//! Anything outside that list is rejected with OL-4271 so we don't accept
//! typos that look like they did something.

use crate::cli::{ConfigAction, GlobalArgs};
use crate::config::{config_path, provider_dir, Config};
use crate::error::{OlError, OL_4270_CONFIG_UNREADABLE, OL_4271_PROFILE_NOT_FOUND};
use crate::telemetry::consent_file::{read_consent, write_consent};
use crate::ui::output::OutputConfig;

pub async fn run(g: &GlobalArgs, action: ConfigAction) -> Result<(), OlError> {
    match action {
        ConfigAction::Get { key } => get(g, &key).await,
        ConfigAction::Set { key, value } => set(g, &key, &value).await,
        ConfigAction::List => list(g).await,
    }
}

async fn get(g: &GlobalArgs, key: &str) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let value = read_value(key)?;
    if out.is_machine() {
        out.print_json(&serde_json::json!({ "key": key, "value": value }));
    } else {
        println!("{value}");
    }
    Ok(())
}

async fn set(g: &GlobalArgs, key: &str, value: &str) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let current = read_value(key).unwrap_or_default();
    if current == value {
        out.print_step(&format!("`{key}` already set to `{value}` — no-op"));
        return Ok(());
    }
    write_value(key, value)?;
    out.print_step(&format!("Set `{key}` to `{value}`"));
    Ok(())
}

async fn list(g: &GlobalArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let cfg = Config::load().unwrap_or_default();
    let telemetry_enabled = read_consent(&provider_dir().join("telemetry.json"))
        .ok()
        .flatten()
        .map(|f| f.enabled);

    let snapshot = serde_json::json!({
        "config_path": config_path().display().to_string(),
        "machine_id": cfg.telemetry.machine_id,
        "crashreport.enabled": cfg.crashreport.enabled,
        "telemetry.enabled": telemetry_enabled,
        "profiles": cfg.profiles,
    });

    if out.is_machine() {
        out.print_json(&snapshot);
    } else {
        out.print_step(&format!("Config: {}", config_path().display()));
        if let Some(id) = &cfg.telemetry.machine_id {
            out.print_substep(&format!("machine_id        = {id}"));
        }
        out.print_substep(&format!(
            "crashreport.enabled = {}",
            cfg.crashreport.enabled
        ));
        out.print_substep(&format!(
            "telemetry.enabled  = {}",
            telemetry_enabled
                .map(|b| b.to_string())
                .unwrap_or_else(|| "(not set)".into())
        ));
        for (name, p) in &cfg.profiles {
            out.print_substep(&format!(
                "profiles.{name}.api_url = {}",
                p.api_url.as_deref().unwrap_or("(unset)")
            ));
        }
    }
    Ok(())
}

fn read_value(key: &str) -> Result<String, OlError> {
    let cfg = Config::load().unwrap_or_default();
    match key {
        "crashreport.enabled" => Ok(cfg.crashreport.enabled.to_string()),
        "telemetry.machine_id" => Ok(cfg.telemetry.machine_id.clone().unwrap_or_default()),
        "telemetry.enabled" => Ok(read_consent(&provider_dir().join("telemetry.json"))
            .ok()
            .flatten()
            .map(|f| f.enabled.to_string())
            .unwrap_or_else(|| "false".into())),
        k if k.starts_with("profiles.") => {
            // profiles.<name>.api_url
            let rest = &k["profiles.".len()..];
            let (name, field) = rest.split_once('.').ok_or_else(|| {
                OlError::new(
                    OL_4271_PROFILE_NOT_FOUND,
                    format!("expected profiles.<name>.<field>, got `{k}`"),
                )
            })?;
            let profile = cfg.profiles.get(name).ok_or_else(|| {
                OlError::new(
                    OL_4271_PROFILE_NOT_FOUND,
                    format!("profile `{name}` not defined"),
                )
            })?;
            match field {
                "api_url" => Ok(profile.api_url.clone().unwrap_or_default()),
                other => Err(OlError::new(
                    OL_4270_CONFIG_UNREADABLE,
                    format!("unknown profile field `{other}` (only `api_url` is supported)"),
                )),
            }
        }
        other => Err(OlError::new(
            OL_4270_CONFIG_UNREADABLE,
            format!("unknown config key `{other}`"),
        )),
    }
}

fn write_value(key: &str, value: &str) -> Result<(), OlError> {
    match key {
        "crashreport.enabled" => {
            let mut cfg = Config::load().unwrap_or_default();
            cfg.crashreport.enabled = parse_bool(value)?;
            cfg.save()
        }
        "telemetry.enabled" => {
            let enabled = parse_bool(value)?;
            write_consent(&provider_dir().join("telemetry.json"), enabled)
        }
        k if k.starts_with("profiles.") => {
            let rest = &k["profiles.".len()..];
            let (name, field) = rest.split_once('.').ok_or_else(|| {
                OlError::new(
                    OL_4271_PROFILE_NOT_FOUND,
                    format!("expected profiles.<name>.<field>, got `{k}`"),
                )
            })?;
            if field != "api_url" {
                return Err(OlError::new(
                    OL_4270_CONFIG_UNREADABLE,
                    format!("unknown profile field `{field}` (only `api_url` is supported)"),
                ));
            }
            let mut cfg = Config::load().unwrap_or_default();
            let profile = cfg.profiles.entry(name.to_string()).or_default();
            profile.api_url = Some(value.to_string());
            cfg.save()
        }
        other => Err(OlError::new(
            OL_4270_CONFIG_UNREADABLE,
            format!("config key `{other}` is read-only or unknown"),
        )),
    }
}

fn parse_bool(s: &str) -> Result<bool, OlError> {
    match s.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        other => Err(OlError::new(
            OL_4270_CONFIG_UNREADABLE,
            format!("expected boolean, got `{other}`"),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_bool_accepts_common_forms() {
        assert!(parse_bool("true").unwrap());
        assert!(parse_bool("Yes").unwrap());
        assert!(!parse_bool("off").unwrap());
        assert!(parse_bool("maybe").is_err());
    }
}