herdr 0.1.0

terminal workspace manager for AI coding agents
//! Self-update mechanism.
//!
//! Checks GitHub releases for newer versions and downloads the binary.
//! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies.
//! JSON parsing uses serde_json (already in deps for persistence).

use std::env;
use std::fs;
use std::process::Command;

use serde::Deserialize;

const GITHUB_REPO: &str = "ogulcancelik/herdr";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");

// ---------------------------------------------------------------------------
// Version
// ---------------------------------------------------------------------------

/// Parsed semver version for comparison.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
    pub major: u32,
    pub minor: u32,
    pub patch: u32,
}

impl Version {
    pub fn parse(s: &str) -> Option<Self> {
        let s = s.strip_prefix('v').unwrap_or(s);
        let parts: Vec<&str> = s.split('.').collect();
        if parts.len() != 3 {
            return None;
        }
        Some(Self {
            major: parts[0].parse().ok()?,
            minor: parts[1].parse().ok()?,
            patch: parts[2].parse().ok()?,
        })
    }

    pub fn current() -> Self {
        Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION")
    }
}

impl std::fmt::Display for Version {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
    }
}

// ---------------------------------------------------------------------------
// GitHub release API types
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
struct GitHubRelease {
    tag_name: String,
    assets: Vec<GitHubAsset>,
}

#[derive(Deserialize)]
struct GitHubAsset {
    name: String,
    browser_download_url: String,
}

// ---------------------------------------------------------------------------
// Release info
// ---------------------------------------------------------------------------

/// Information about an available update.
struct ReleaseInfo {
    version: Version,
    download_url: String,
}

/// Check GitHub for the latest release. Returns release info if newer.
fn check_latest() -> Result<Option<ReleaseInfo>, String> {
    let current = Version::current();
    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");

    let output = Command::new("curl")
        .args([
            "-sfL",
            "--max-time", "10",
            "-H", "Accept: application/vnd.github+json",
            "-H", "User-Agent: herdr-updater",
            &url,
        ])
        .output()
        .map_err(|e| format!("curl failed: {e}"))?;

    if !output.status.success() {
        return Err("failed to fetch release info".into());
    }

    let release: GitHubRelease = serde_json::from_slice(&output.stdout)
        .map_err(|e| format!("failed to parse release JSON: {e}"))?;

    let latest = Version::parse(&release.tag_name)
        .ok_or_else(|| format!("invalid version tag: {}", release.tag_name))?;

    if latest <= current {
        return Ok(None); // up to date
    }

    // Find the asset for our platform
    let (os, arch) = platform_target();
    let asset_name = format!("herdr-{os}-{arch}");

    let download_url = release
        .assets
        .iter()
        .find(|a| a.name == asset_name)
        .map(|a| a.browser_download_url.clone())
        .ok_or_else(|| format!("no binary for {os}-{arch} in release {}", release.tag_name))?;

    Ok(Some(ReleaseInfo {
        version: latest,
        download_url,
    }))
}

// ---------------------------------------------------------------------------
// Download + install
// ---------------------------------------------------------------------------

/// Download and install a release. Returns the installed version.
fn download_and_install(release: &ReleaseInfo) -> Result<(), String> {
    let current_exe = env::current_exe()
        .map_err(|e| format!("can't find current binary: {e}"))?;

    let parent = current_exe
        .parent()
        .ok_or("can't find binary directory")?;

    // Check write permissions early
    let test_path = parent.join(".herdr-write-test");
    if let Err(e) = fs::write(&test_path, b"") {
        let _ = fs::remove_file(&test_path);
        return Err(format!(
            "install directory not writable: {} ({}). Try running with appropriate permissions.",
            parent.display(),
            e
        ));
    }
    let _ = fs::remove_file(&test_path);

    // Unique temp file (avoids races with concurrent instances)
    let tmp_path = parent.join(format!(
        ".herdr-update-{}.tmp",
        std::process::id()
    ));

    // Download the exact asset URL (pinned to the release we checked)
    let status = Command::new("curl")
        .args(["-sfL", "--max-time", "120", "-o"])
        .arg(&tmp_path)
        .arg(&release.download_url)
        .status()
        .map_err(|e| format!("download failed: {e}"))?;

    if !status.success() {
        let _ = fs::remove_file(&tmp_path);
        return Err("download failed".into());
    }

    // Make executable
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) {
            let _ = fs::remove_file(&tmp_path);
            return Err(format!("chmod failed: {e}"));
        }
    }

    // Atomic replace — rename over the current binary.
    // On Linux, the running process keeps its fd to the old inode.
    // Next launch picks up the new file.
    if let Err(e) = fs::rename(&tmp_path, &current_exe) {
        let _ = fs::remove_file(&tmp_path);
        return Err(format!("failed to replace binary: {e}"));
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Manual self-update command (`herdr update`).
pub fn self_update() -> Result<Version, String> {
    eprintln!("checking for updates...");

    let current = Version::current();

    let release = match check_latest()? {
        Some(r) => r,
        None => {
            eprintln!("already up to date (v{current})");
            return Ok(current);
        }
    };

    eprintln!("downloading v{}...", release.version);
    download_and_install(&release)?;
    eprintln!("updated to v{}", release.version);

    Ok(release.version)
}

/// Background auto-update: check, download, install, notify TUI.
/// Runs in a background thread at startup.
pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
    let release = match check_latest() {
        Ok(Some(r)) => r,
        _ => return, // up to date or failed — silently do nothing
    };

    tracing::info!(
        "new version v{} available, downloading from {}",
        release.version,
        release.download_url
    );

    if let Err(e) = download_and_install(&release) {
        tracing::warn!("auto-update failed: {e}");
        return;
    }

    tracing::info!("auto-update: v{} installed, restart to use", release.version);

    // Notify the TUI — blocking_send is safe from a std::thread
    let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
        version: release.version.to_string(),
    });
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn platform_target() -> (&'static str, &'static str) {
    let os = if cfg!(target_os = "linux") {
        "linux"
    } else if cfg!(target_os = "macos") {
        "macos"
    } else {
        "unknown"
    };

    let arch = if cfg!(target_arch = "x86_64") {
        "x86_64"
    } else if cfg!(target_arch = "aarch64") {
        "aarch64"
    } else {
        "unknown"
    };

    (os, arch)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn parse_version_basic() {
        assert_eq!(
            Version::parse("1.2.3"),
            Some(Version { major: 1, minor: 2, patch: 3 })
        );
    }

    #[test]
    fn parse_version_with_v_prefix() {
        assert_eq!(
            Version::parse("v0.1.0"),
            Some(Version { major: 0, minor: 1, patch: 0 })
        );
    }

    #[test]
    fn parse_version_invalid() {
        assert_eq!(Version::parse("1.2"), None);
        assert_eq!(Version::parse("abc"), None);
        assert_eq!(Version::parse(""), None);
    }

    #[test]
    fn version_ordering() {
        let v010 = Version::parse("0.1.0").unwrap();
        let v011 = Version::parse("0.1.1").unwrap();
        let v020 = Version::parse("0.2.0").unwrap();
        let v100 = Version::parse("1.0.0").unwrap();

        assert!(v010 < v011);
        assert!(v011 < v020);
        assert!(v020 < v100);
        assert!(v010 == Version::parse("0.1.0").unwrap());
    }

    #[test]
    fn version_display() {
        let v = Version { major: 0, minor: 1, patch: 0 };
        assert_eq!(v.to_string(), "0.1.0");
    }

    #[test]
    fn current_version_parses() {
        let v = Version::current();
        assert!(v.major < 100);
    }

    #[test]
    fn platform_target_is_known() {
        let (os, arch) = platform_target();
        assert!(os == "linux" || os == "macos", "os: {os}");
        assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}");
    }

    #[test]
    fn github_release_deserializes() {
        let json = r#"{
            "tag_name": "v0.2.0",
            "assets": [
                {"name": "herdr-linux-x86_64", "browser_download_url": "https://example.com/herdr-linux-x86_64"},
                {"name": "herdr-macos-aarch64", "browser_download_url": "https://example.com/herdr-macos-aarch64"}
            ]
        }"#;
        let release: GitHubRelease = serde_json::from_str(json).unwrap();
        assert_eq!(release.tag_name, "v0.2.0");
        assert_eq!(release.assets.len(), 2);
        assert_eq!(release.assets[0].name, "herdr-linux-x86_64");
    }

    #[test]
    fn github_release_ignores_extra_fields() {
        let json = r#"{
            "tag_name": "v0.1.0",
            "name": "Release v0.1.0",
            "body": "Some notes",
            "draft": false,
            "prerelease": false,
            "assets": []
        }"#;
        let release: GitHubRelease = serde_json::from_str(json).unwrap();
        assert_eq!(release.tag_name, "v0.1.0");
        assert!(release.assets.is_empty());
    }
}