openlatch-provider 0.2.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Profile-aware config loaded from `~/.openlatch/provider/config.toml` (XDG-compliant).
//!
//! The full profile/`config get/set` UX lands in P1.T2.4. T1 only needs:
//! - locating the provider dir
//! - generating + persisting `machine_id` on first use (telemetry identity)
//! - reading the `[crashreport]` and `[telemetry]` sections
//!
//! On first run the file is created with mode `0700` parent and `0600` content
//! (Unix). Windows inherits ACLs from `%APPDATA%\openlatch\provider`.

use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::error::{
    OlError, OL_4262_CONSENT_FILE_CORRUPT, OL_4272_XDG_DIR_UNWRITABLE, OL_4273_MANIFEST_UNREADABLE,
};

/// Top-level shape of `~/.openlatch/provider/config.toml`.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub telemetry: TelemetrySection,
    #[serde(default)]
    pub crashreport: CrashreportSection,
    /// `[profiles.<name>]` table — populated in P1.T2.4.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub profiles: std::collections::BTreeMap<String, ProfileSection>,
}

#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct TelemetrySection {
    /// Stable per-machine identifier — used as the pre-auth PostHog
    /// `distinct_id`. Generated once and never rotated. Format: `mach_<uuidv7>`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrashreportSection {
    /// Whether Sentry crash-report capture is on. Defaults to `true`; see
    /// `.claude/rules/telemetry.md` — Sentry is opt-out, PostHog is opt-in.
    #[serde(default = "default_true")]
    pub enabled: bool,
}

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

fn default_true() -> bool {
    true
}

#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ProfileSection {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_url: Option<String>,
    /// Web/app URL — used by the login flow's `/cli-auth` redirect. Mirrors
    /// `api_url`; usually only diverges in local-dev (e.g. Vite on :5173).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub app_url: Option<String>,
    /// Slug of the active editor manifest. Resolved to
    /// `<provider_dir>/<slug>.yaml` by [`active_manifest_path`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub manifest_slug: Option<String>,
}

// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------

/// Process-cached openlatch-provider state dir. Computed once via
/// `OPENLATCH_PROVIDER_CONFIG_DIR` env override, else
/// `dirs::home_dir()/.openlatch/provider/`. The `provider/` subdir keeps our
/// state isolated from `openlatch-client`, which writes to `~/.openlatch/`
/// directly.
pub fn provider_dir() -> PathBuf {
    static DIR: OnceLock<PathBuf> = OnceLock::new();
    DIR.get_or_init(compute_provider_dir).clone()
}

fn compute_provider_dir() -> PathBuf {
    if let Ok(override_dir) = std::env::var("OPENLATCH_PROVIDER_CONFIG_DIR") {
        return PathBuf::from(override_dir);
    }
    if let Some(home) = dirs::home_dir() {
        return home.join(".openlatch").join("provider");
    }
    // Last-resort fallback: cwd. The init flow surfaces OL-4272 if writes fail.
    PathBuf::from(".openlatch").join("provider")
}

/// Path to `config.toml` inside the provider dir.
pub fn config_path() -> PathBuf {
    provider_dir().join("config.toml")
}

/// Build the manifest path for a given slug — `<provider_dir>/<slug>.yaml`.
pub fn manifest_path_for_slug(slug: &str) -> PathBuf {
    provider_dir().join(format!("{slug}.yaml"))
}

/// Resolve the active manifest path from `config.toml`.
///
/// Returns `OL-4273` if the requested profile (default: `default`) has no
/// `manifest_slug` set — that means `init` has never run, so the user gets a
/// pointer at it. Callers that accept an explicit `--manifest <path>` flag
/// must short-circuit before calling this.
pub fn active_manifest_path(profile: Option<&str>) -> Result<PathBuf, OlError> {
    let cfg = Config::load()?;
    let profile_name = profile.unwrap_or("default");
    let slug = cfg
        .profiles
        .get(profile_name)
        .and_then(|p| p.manifest_slug.as_deref())
        .filter(|s| !s.is_empty())
        .ok_or_else(|| {
            OlError::new(
                OL_4273_MANIFEST_UNREADABLE,
                format!(
                    "no manifest configured for profile `{profile_name}` (config.toml has no \
                     `[profiles.{profile_name}] manifest_slug`)"
                ),
            )
            .with_suggestion(
                "Run `openlatch-provider init` to scaffold a manifest, or pass \
                 `--manifest <path>` to use one explicitly.",
            )
        })?;
    Ok(manifest_path_for_slug(slug))
}

/// Persist `manifest_slug` for the given profile, creating the profile entry
/// if it does not exist yet.
pub fn set_manifest_slug(profile: Option<&str>, slug: &str) -> Result<(), OlError> {
    let mut cfg = Config::load()?;
    let profile_name = profile.unwrap_or("default").to_string();
    cfg.profiles.entry(profile_name).or_default().manifest_slug = Some(slug.to_string());
    cfg.save()
}

// ---------------------------------------------------------------------------
// Load / save
// ---------------------------------------------------------------------------

impl Config {
    /// Read `~/.openlatch/provider/config.toml`. Missing file → default config.
    /// Malformed file → [`OL_4262_CONSENT_FILE_CORRUPT`].
    pub fn load() -> Result<Self, OlError> {
        Self::load_from(&config_path())
    }

    pub fn load_from(path: &Path) -> Result<Self, OlError> {
        match fs::read_to_string(path) {
            Ok(raw) => toml::from_str(&raw).map_err(|e| {
                OlError::new(
                    OL_4262_CONSENT_FILE_CORRUPT,
                    format!("config.toml at '{}' is malformed: {e}", path.display()),
                )
                .with_suggestion(
                    "Delete the file and re-run any openlatch-provider command to regenerate it.",
                )
            }),
            Err(e) if e.kind() == ErrorKind::NotFound => Ok(Self::default()),
            Err(e) => Err(OlError::new(
                OL_4262_CONSENT_FILE_CORRUPT,
                format!("cannot read config.toml at '{}': {e}", path.display()),
            )),
        }
    }

    /// Atomically write the config back. Creates the parent directory if it
    /// doesn't exist.
    pub fn save(&self) -> Result<(), OlError> {
        Self::save_to(self, &config_path())
    }

    pub fn save_to(&self, path: &Path) -> Result<(), OlError> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    OL_4272_XDG_DIR_UNWRITABLE,
                    format!("cannot create '{}': {e}", parent.display()),
                )
            })?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
            }
        }

        let serialized = toml::to_string_pretty(self).map_err(|e| {
            OlError::new(
                OL_4272_XDG_DIR_UNWRITABLE,
                format!("cannot serialize config.toml: {e}"),
            )
        })?;

        let tmp = path.with_extension("toml.tmp");
        fs::write(&tmp, serialized.as_bytes()).map_err(|e| {
            OlError::new(
                OL_4272_XDG_DIR_UNWRITABLE,
                format!("cannot write '{}': {e}", tmp.display()),
            )
        })?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
        }
        fs::rename(&tmp, path).map_err(|e| {
            OlError::new(
                OL_4272_XDG_DIR_UNWRITABLE,
                format!("cannot rename '{}' into place: {e}", tmp.display()),
            )
        })?;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// machine_id
// ---------------------------------------------------------------------------

/// Return the existing `machine_id` or generate + persist a fresh one.
///
/// Stable across upgrades. Format: `mach_<uuidv7-simple>`. On first call the
/// id is written back to `config.toml` so the next process sees the same
/// value.
pub fn machine_id_or_init() -> Result<String, OlError> {
    machine_id_or_init_in(&config_path())
}

pub fn machine_id_or_init_in(path: &Path) -> Result<String, OlError> {
    let mut cfg = Config::load_from(path)?;
    if let Some(id) = cfg.telemetry.machine_id.as_ref() {
        if !id.is_empty() {
            return Ok(id.clone());
        }
    }
    let id = format!("mach_{}", Uuid::now_v7().simple());
    cfg.telemetry.machine_id = Some(id.clone());
    cfg.save_to(path)?;
    Ok(id)
}

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

    #[test]
    fn machine_id_round_trips() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        let first = machine_id_or_init_in(&path).unwrap();
        assert!(first.starts_with("mach_"));
        let second = machine_id_or_init_in(&path).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn missing_file_yields_default_config() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("absent.toml");
        let cfg = Config::load_from(&path).unwrap();
        assert!(cfg.telemetry.machine_id.is_none());
        assert!(cfg.crashreport.enabled);
    }

    #[test]
    fn malformed_file_returns_corrupt_error() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("bad.toml");
        std::fs::write(&path, b"not = a [valid toml").unwrap();
        let err = Config::load_from(&path).unwrap_err();
        assert_eq!(err.code.code, "OL-4262");
    }

    #[test]
    fn manifest_slug_round_trips_through_toml() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        let mut cfg = Config::default();
        cfg.profiles.insert(
            "default".to_string(),
            ProfileSection {
                manifest_slug: Some("my-editor".into()),
                ..Default::default()
            },
        );
        cfg.save_to(&path).unwrap();

        let reloaded = Config::load_from(&path).unwrap();
        assert_eq!(
            reloaded
                .profiles
                .get("default")
                .and_then(|p| p.manifest_slug.as_deref()),
            Some("my-editor"),
        );
    }

    #[test]
    fn manifest_path_for_slug_appends_yaml_extension() {
        let path = manifest_path_for_slug("my-editor");
        assert!(path.ends_with("my-editor.yaml"));
    }

    /// Default-path lock-in: when no env override is set, `provider_dir` MUST
    /// land under `<home>/.openlatch/provider/` so that openlatch-client and
    /// openlatch-provider don't share a single byte of disk state. This test
    /// targets `compute_provider_dir` directly because `provider_dir` caches
    /// in a `OnceLock` — the cached value would survive env mutation.
    ///
    /// Mutates `OPENLATCH_PROVIDER_CONFIG_DIR`; not safe to run in parallel
    /// with tests that read it.
    #[test]
    fn default_provider_dir_is_under_home_openlatch_provider() {
        std::env::remove_var("OPENLATCH_PROVIDER_CONFIG_DIR");
        let path = compute_provider_dir();
        let s = path.to_string_lossy().replace('\\', "/");
        assert!(
            s.ends_with("/.openlatch/provider") || s == ".openlatch/provider",
            "expected default to end with .openlatch/provider, got: {s}"
        );
    }
}