sylphx-cli 0.2.6

Sylphx Platform CLI — dogfoods the Rust Management SDK
//! Install-channel detection and upgrade guidance (ADR CLI distribution).

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

/// How this `sylphx` binary was likely installed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
    /// GitHub Releases / install-sylphx.sh / self-updated binary.
    Release,
    /// `cargo install sylphx-cli` (typically `~/.cargo/bin/sylphx`).
    Cargo,
    /// npm global/local adapter (`@sylphx/cli`).
    Npm,
    /// Homebrew formula/tap.
    Brew,
    /// Could not classify.
    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",
        }
    }

    /// Whether `sylphx update` may in-place replace this binary by default.
    pub fn allows_release_self_update(self) -> bool {
        matches!(self, Self::Release | Self::Unknown)
    }

    /// Native package-manager upgrade command for humans/agents.
    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",
        }
    }
}

/// Detect install channel from the current executable path.
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();

    // Homebrew
    if lower.contains("/cellar/sylphx")
        || lower.contains("/homebrew/")
        || lower.contains("/linuxbrew/")
        || lower.contains("homebrew/bin/sylphx")
    {
        return InstallChannel::Brew;
    }

    // Cargo
    if lower.contains("/.cargo/bin/sylphx") || lower.ends_with("/cargo/bin/sylphx") {
        return InstallChannel::Cargo;
    }

    // npm adapter / npx
    if lower.contains("node_modules")
        || lower.contains("/.npm/")
        || lower.contains("@sylphx/cli")
        || lower.contains("/npm/_npx/")
    {
        return InstallChannel::Npm;
    }

    // Common release install locations
    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 {
    // Prefer canonical path when available (resolves symlinks like brew stubs).
    let path: PathBuf = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    path.to_string_lossy().into_owned()
}

/// Compare semver-ish versions (MAJOR.MINOR.PATCH only; pre-release ignored).
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');
    // accept "sylphx 0.2.3" or "0.2.3 (Rust)"
    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))
}

/// Fetch latest `cli-v*` product version from GitHub Releases (best-effort).
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()?;

    // Primary: crates.io max_version for sylphx-cli (public, no GitHub auth).
    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());
        }
    }

    // Fallback: GitHub releases list (may 404 on private repos without token).
    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");
    }
}