use std::fmt;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use opencodecommit::config::Config;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallSource {
Npm,
Cargo,
Unknown,
}
impl fmt::Display for InstallSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Npm => write!(f, "npm"),
Self::Cargo => write!(f, "cargo"),
Self::Unknown => write!(f, "unknown"),
}
}
}
pub fn detect_install_source() -> InstallSource {
let exe = match std::env::current_exe().and_then(|p| std::fs::canonicalize(p)) {
Ok(p) => p,
Err(_) => return InstallSource::Unknown,
};
let path_str = exe.to_string_lossy();
if path_str.contains("node_modules") || path_str.contains("npm/opencodecommit/platforms/") {
return InstallSource::Npm;
}
let cargo_bin = std::env::var("CARGO_HOME")
.map(|h| PathBuf::from(h).join("bin"))
.or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".cargo").join("bin")));
if let Ok(bin_dir) = cargo_bin {
if let Ok(canon) = std::fs::canonicalize(&bin_dir) {
if exe.starts_with(&canon) {
return InstallSource::Cargo;
}
}
if exe.starts_with(&bin_dir) {
return InstallSource::Cargo;
}
}
InstallSource::Unknown
}
pub fn check_latest_version(source: InstallSource) -> Result<String, String> {
match source {
InstallSource::Npm => check_npm_latest(),
InstallSource::Cargo => check_cargo_latest(),
InstallSource::Unknown => check_npm_latest().or_else(|_| check_cargo_latest()),
}
}
fn check_npm_latest() -> Result<String, String> {
let output = Command::new("npm")
.args(["view", "opencodecommit", "version"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("failed to run npm: {e}"))?
.wait_with_output()
.map_err(|e| format!("npm failed: {e}"))?;
if !output.status.success() {
return Err("npm view failed".to_owned());
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if version.is_empty() {
return Err("empty version from npm".to_owned());
}
Ok(version)
}
fn check_cargo_latest() -> Result<String, String> {
let output = Command::new("cargo")
.args(["search", "opencodecommit", "--limit", "1"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("failed to run cargo: {e}"))?
.wait_with_output()
.map_err(|e| format!("cargo search failed: {e}"))?;
if !output.status.success() {
return Err("cargo search failed".to_owned());
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.starts_with("opencodecommit") {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
return Ok(line[start + 1..start + 1 + end].to_owned());
}
}
}
}
Err("could not parse cargo search output".to_owned())
}
pub fn is_newer(current: &str, latest: &str) -> bool {
let parse = |v: &str| -> Option<(u32, u32, u32)> {
let mut parts = v.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
};
match (parse(current), parse(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
const CHECK_INTERVAL_SECS: u64 = 86400;
#[derive(Serialize, Deserialize)]
struct UpdateCache {
last_check_epoch: u64,
latest_version: String,
}
fn cache_path() -> Option<PathBuf> {
Config::default_config_dir().map(|d| d.join("update-check.json"))
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn read_cache() -> Option<UpdateCache> {
let path = cache_path()?;
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
pub fn write_cache(latest_version: &str) {
let Some(path) = cache_path() else { return };
let cache = UpdateCache {
last_check_epoch: now_epoch(),
latest_version: latest_version.to_owned(),
};
if let Ok(json) = serde_json::to_string(&cache) {
let _ = std::fs::write(path, json);
}
}
pub fn should_check() -> (bool, Option<String>) {
match read_cache() {
Some(cache) => {
let elapsed = now_epoch().saturating_sub(cache.last_check_epoch);
if elapsed >= CHECK_INTERVAL_SECS {
(true, Some(cache.latest_version))
} else {
(false, Some(cache.latest_version))
}
}
None => (true, None),
}
}
pub fn run_update(source: InstallSource) -> Result<(), String> {
match source {
InstallSource::Npm => {
eprintln!("Running: npm install -g opencodecommit");
let status = Command::new("npm")
.args(["install", "-g", "opencodecommit"])
.status()
.map_err(|e| format!("failed to run npm: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("npm exited with {status}"))
}
}
InstallSource::Cargo => {
eprintln!("Running: cargo install opencodecommit");
let status = Command::new("cargo")
.args(["install", "opencodecommit"])
.status()
.map_err(|e| format!("failed to run cargo: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo exited with {status}"))
}
}
InstallSource::Unknown => Err("Could not detect installation source.\n\
Update manually with one of:\n \
npm install -g opencodecommit\n \
cargo install opencodecommit"
.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_newer() {
assert!(is_newer("1.1.4", "1.2.0"));
assert!(is_newer("1.1.4", "1.1.5"));
assert!(is_newer("1.1.4", "2.0.0"));
assert!(!is_newer("1.2.0", "1.2.0"));
assert!(!is_newer("2.0.0", "1.9.9"));
assert!(!is_newer("1.1.5", "1.1.4"));
}
#[test]
fn test_is_newer_invalid() {
assert!(!is_newer("bad", "1.0.0"));
assert!(!is_newer("1.0.0", "bad"));
assert!(!is_newer("", ""));
}
#[test]
fn test_detect_source_dev_build() {
assert_eq!(detect_install_source(), InstallSource::Unknown);
}
#[test]
fn test_cache_roundtrip() {
let dir = std::env::temp_dir().join("occ-test-cache");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("update-check.json");
let cache = UpdateCache {
last_check_epoch: 1712505600,
latest_version: "1.2.0".to_owned(),
};
let json = serde_json::to_string(&cache).unwrap();
std::fs::write(&path, &json).unwrap();
let loaded: UpdateCache =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.latest_version, "1.2.0");
assert_eq!(loaded.last_check_epoch, 1712505600);
let _ = std::fs::remove_dir_all(dir);
}
}