use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{OlError, OL_4272_XDG_DIR_UNWRITABLE};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum InstallMethod {
Npm,
CargoInstall,
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());
}
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)
}
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"));
}
}