openlatch-provider 0.2.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `~/.openlatch/provider/install-state.json` reader/writer — port of
//! `openlatch-client::core::install_state` with provider-side label
//! swaps (`@openlatch/client` → `@openlatch/provider`,
//! `~/.openlatch/` → `~/.openlatch/provider/`).
//!
//! Surfaces the install posture of the local binary so `openlatch-provider
//! doctor` can detect drift between what npm last installed
//! (`npm_reported_version`) and what the auto-update path actually swapped
//! in (`actual_binary_version`). The file is the only durable state that
//! survives a daemon swap — the new binary reads it at startup and updates
//! it after a successful post-restart healthz probe.

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

use serde::{Deserialize, Serialize};

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

/// How the local binary was installed. Used to gate the auto-update
/// pipeline (`cargo install` users are pointed at the manual
/// `cargo install --force` recovery path) and to surface npm-vs-actual
/// drift in `doctor`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum InstallMethod {
    /// Installed via `npm install -g @openlatch/provider`.
    Npm,
    /// Installed via `cargo install openlatch-provider`. Auto-update
    /// pipeline refuses this install method unless `--force-cargo` is
    /// passed.
    CargoInstall,
    /// Hand-installed (curl + chmod, /usr/local/bin copy, etc.).
    Manual,
    #[default]
    Unknown,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "snake_case")]
pub struct InstallState {
    pub install_method: InstallMethod,
    pub install_path: Option<PathBuf>,
    pub npm_reported_version: Option<String>,
    pub actual_binary_version: Option<String>,
    pub last_updated_at: Option<String>,
    pub last_check_at: Option<String>,
}

impl InstallState {
    pub fn path() -> PathBuf {
        Self::path_in(&crate::config::provider_dir())
    }

    pub fn path_in(dir: &Path) -> PathBuf {
        dir.join("install-state.json")
    }

    pub fn load_or_default() -> Self {
        Self::load_from(&Self::path())
    }

    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        match serde_json::from_str::<Self>(&raw) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(target: "update", error = %e, path = %path.display(), "install-state.json malformed; falling back to default");
                Self::default()
            }
        }
    }

    pub fn save(&self) -> Result<(), OlError> {
        self.save_to(&Self::path())
    }

    pub fn save_to(&self, path: &Path) -> Result<(), OlError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    OL_4272_XDG_DIR_UNWRITABLE,
                    format!("create install-state dir {}: {e}", parent.display()),
                )
            })?;
        }
        let body = serde_json::to_string_pretty(self).map_err(|e| {
            OlError::new(
                OL_4272_XDG_DIR_UNWRITABLE,
                format!("serialise install-state: {e}"),
            )
        })?;
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, body).map_err(|e| {
            OlError::new(
                OL_4272_XDG_DIR_UNWRITABLE,
                format!("write install-state tmp {}: {e}", tmp.display()),
            )
        })?;
        std::fs::rename(&tmp, path).map_err(|e| {
            OlError::new(
                OL_4272_XDG_DIR_UNWRITABLE,
                format!(
                    "rename install-state {} -> {}: {e}",
                    tmp.display(),
                    path.display()
                ),
            )
        })?;
        Ok(())
    }

    pub fn record_applied(&mut self, version: &str) {
        self.actual_binary_version = Some(version.to_string());
        self.last_updated_at = Some(now_rfc3339());
    }

    pub fn record_check(&mut self) {
        self.last_check_at = Some(now_rfc3339());
    }

    /// Refresh + persist install state to reflect the binary that's
    /// actually running right now: detected install method, current
    /// `current_exe()`, and a `record_applied` + `record_check` stamp.
    pub fn stamp_for_running_binary(version: &str) {
        let mut s = Self::load_or_default();
        s.install_method = detect_install_method();
        s.install_path = std::env::current_exe().ok();
        s.record_applied(version);
        s.record_check();
        if let Err(e) = s.save() {
            tracing::warn!(target: "update", error = %e.message, "failed to write install-state.json");
        }
    }
}

pub fn now_rfc3339() -> String {
    use chrono::SecondsFormat;
    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}

/// Heuristic-detect how the running binary was installed.
pub fn detect_install_method() -> InstallMethod {
    let Ok(exe) = std::env::current_exe() else {
        return InstallMethod::Unknown;
    };
    detect_install_method_for_path(&exe)
}

pub fn detect_install_method_for_path(exe: &Path) -> InstallMethod {
    if is_under_cargo_bin(exe) {
        return InstallMethod::CargoInstall;
    }
    if has_npm_package_json(exe) {
        return InstallMethod::Npm;
    }
    InstallMethod::Manual
}

fn is_under_cargo_bin(exe: &Path) -> bool {
    let mut p = exe;
    while let Some(parent) = p.parent() {
        if parent.file_name().and_then(|s| s.to_str()) == Some("bin") {
            if let Some(grand) = parent.parent() {
                if grand.file_name().and_then(|s| s.to_str()) == Some(".cargo") {
                    return true;
                }
            }
        }
        p = parent;
    }
    false
}

fn has_npm_package_json(exe: &Path) -> bool {
    let mut depth = 0;
    let mut cursor = exe.parent();
    while let Some(dir) = cursor {
        if depth > 5 {
            break;
        }
        let pkg = dir.join("package.json");
        if pkg.is_file() {
            if let Ok(raw) = std::fs::read_to_string(&pkg) {
                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) {
                    if let Some(name) = value.get("name").and_then(|v| v.as_str()) {
                        if name.starts_with("@openlatch/provider") {
                            return true;
                        }
                    }
                }
            }
        }
        cursor = dir.parent();
        depth += 1;
    }
    false
}

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

    #[test]
    fn install_method_detects_cargo_bin() {
        let exe = PathBuf::from("/home/me/.cargo/bin/openlatch-provider");
        assert_eq!(
            detect_install_method_for_path(&exe),
            InstallMethod::CargoInstall
        );
    }

    #[test]
    fn install_method_detects_npm_package() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("package.json"),
            r#"{"name":"@openlatch/provider-linux-x64","version":"0.1.0"}"#,
        )
        .unwrap();
        let exe = dir.path().join(if cfg!(windows) {
            "openlatch-provider.exe"
        } else {
            "openlatch-provider"
        });
        std::fs::write(&exe, b"binary").unwrap();
        assert_eq!(detect_install_method_for_path(&exe), InstallMethod::Npm);
    }

    #[test]
    fn install_method_falls_back_to_manual_for_arbitrary_path() {
        let dir = tempfile::tempdir().unwrap();
        let exe = dir.path().join("openlatch-provider");
        std::fs::write(&exe, b"binary").unwrap();
        assert_eq!(detect_install_method_for_path(&exe), InstallMethod::Manual);
    }

    #[test]
    fn install_state_round_trip_serialises_kebab_case() {
        let s = InstallState {
            install_method: InstallMethod::CargoInstall,
            ..InstallState::default()
        };
        let json = serde_json::to_string(&s).unwrap();
        assert!(json.contains("\"cargo-install\""), "got {json}");
        let back: InstallState = serde_json::from_str(&json).unwrap();
        assert_eq!(back.install_method, InstallMethod::CargoInstall);
    }

    #[test]
    fn install_state_save_then_load_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let path = InstallState::path_in(dir.path());
        let mut s = InstallState {
            install_method: InstallMethod::Npm,
            npm_reported_version: Some("0.1.0".into()),
            ..InstallState::default()
        };
        s.record_applied("0.1.1");
        s.save_to(&path).unwrap();
        let back = InstallState::load_from(&path);
        assert_eq!(back.install_method, InstallMethod::Npm);
        assert_eq!(back.npm_reported_version.as_deref(), Some("0.1.0"));
        assert_eq!(back.actual_binary_version.as_deref(), Some("0.1.1"));
    }
}