use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
Release,
Cargo,
Npm,
Brew,
Unknown,
}
impl InstallChannel {
pub fn as_str(self) -> &'static str {
match self {
Self::Release => "release",
Self::Cargo => "cargo",
Self::Npm => "npm",
Self::Brew => "brew",
Self::Unknown => "unknown",
}
}
pub fn allows_release_self_update(self) -> bool {
matches!(self, Self::Release | Self::Unknown)
}
pub fn upgrade_command(self) -> &'static str {
match self {
Self::Cargo => "cargo install sylphx-cli --locked --force",
Self::Npm => "npm i -g @sylphx/cli@latest",
Self::Brew => "brew upgrade sylphx",
Self::Release | Self::Unknown => "sylphx update",
}
}
}
pub fn detect_install_channel() -> InstallChannel {
let Ok(exe) = std::env::current_exe() else {
return InstallChannel::Unknown;
};
detect_from_path(&exe)
}
pub fn detect_from_path(exe: &Path) -> InstallChannel {
let path = normalize_path_string(exe);
let lower = path.to_ascii_lowercase();
if lower.contains("/cellar/sylphx")
|| lower.contains("/homebrew/")
|| lower.contains("/linuxbrew/")
|| lower.contains("homebrew/bin/sylphx")
{
return InstallChannel::Brew;
}
if lower.contains("/.cargo/bin/sylphx") || lower.ends_with("/cargo/bin/sylphx") {
return InstallChannel::Cargo;
}
if lower.contains("node_modules")
|| lower.contains("/.npm/")
|| lower.contains("@sylphx/cli")
|| lower.contains("/npm/_npx/")
{
return InstallChannel::Npm;
}
if lower.contains("/.local/bin/sylphx")
|| lower.contains("/usr/local/bin/sylphx")
|| lower.contains("/opt/sylphx/")
{
return InstallChannel::Release;
}
InstallChannel::Unknown
}
fn normalize_path_string(p: &Path) -> String {
let path: PathBuf = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
path.to_string_lossy().into_owned()
}
pub fn is_outdated(current: &str, latest: &str) -> bool {
let c = parse_triple(current);
let l = parse_triple(latest);
match (c, l) {
(Some(c), Some(l)) => c < l,
_ => false,
}
}
fn parse_triple(v: &str) -> Option<(u64, u64, u64)> {
let v = v.trim().trim_start_matches('v');
let mut num = String::new();
for ch in v.chars() {
if ch.is_ascii_digit() || ch == '.' {
num.push(ch);
} else if !num.is_empty() {
break;
}
}
let mut parts = num.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
pub async fn fetch_latest_release_version() -> anyhow::Result<String> {
let client = reqwest::Client::builder()
.user_agent(format!(
"sylphx-cli/{} (+https://sylphx.com)",
env!("CARGO_PKG_VERSION")
))
.redirect(reqwest::redirect::Policy::limited(10))
.build()?;
let crates_url = "https://crates.io/api/v1/crates/sylphx-cli";
let resp = client.get(crates_url).send().await?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
if let Some(v) = body
.pointer("/crate/max_version")
.and_then(|x| x.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Ok(v.to_string());
}
}
let url = "https://api.github.com/repos/SylphxAI/platform/releases?per_page=30";
let resp = client
.get(url)
.header("Accept", "application/vnd.github+json")
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("version lookup failed (crates.io + GitHub releases list HTTP {})", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let Some(arr) = body.as_array() else {
anyhow::bail!("GitHub releases list not an array");
};
let mut best: Option<(u64, u64, u64, String)> = None;
for rel in arr {
if rel.get("draft").and_then(|v| v.as_bool()).unwrap_or(false) {
continue;
}
let tag = rel
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let Some(ver) = tag.strip_prefix("cli-v") else {
continue;
};
let Some(triple) = parse_triple(ver) else {
continue;
};
let cand = (triple.0, triple.1, triple.2, ver.to_string());
let better = match &best {
None => true,
Some(b) => (b.0, b.1, b.2) < (cand.0, cand.1, cand.2),
};
if better {
best = Some(cand);
}
}
best.map(|b| b.3)
.ok_or_else(|| anyhow::anyhow!("no cli-v* GitHub releases found"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn detects_cargo_npm_brew_release() {
assert_eq!(
detect_from_path(Path::new("/home/u/.cargo/bin/sylphx")),
InstallChannel::Cargo
);
assert_eq!(
detect_from_path(Path::new(
"/home/u/.npm-global/lib/node_modules/@sylphx/cli/binaries/linux-x64/sylphx"
)),
InstallChannel::Npm
);
assert_eq!(
detect_from_path(Path::new(
"/opt/homebrew/Cellar/sylphx/0.2.3/bin/sylphx"
)),
InstallChannel::Brew
);
assert_eq!(
detect_from_path(Path::new("/home/u/.local/bin/sylphx")),
InstallChannel::Release
);
}
#[test]
fn outdated_semver() {
assert!(is_outdated("0.2.2", "0.2.3"));
assert!(!is_outdated("0.2.3", "0.2.3"));
assert!(!is_outdated("0.2.4", "0.2.3"));
assert!(is_outdated("sylphx 0.1.0", "0.2.0"));
}
#[test]
fn upgrade_commands() {
assert!(InstallChannel::Cargo.upgrade_command().contains("cargo install"));
assert!(InstallChannel::Brew.upgrade_command().contains("brew upgrade"));
assert_eq!(InstallChannel::Release.upgrade_command(), "sylphx update");
}
}