gemini-cli-sdk 0.1.0

Rust SDK wrapping Google's Gemini CLI as a subprocess via JSON-RPC 2.0
Documentation
//! CLI binary discovery and version checking.

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

use crate::{Error, Result};

/// Minimum CLI version that supports JSON-RPC mode (`--experimental-acp`).
pub const MIN_CLI_VERSION: &str = "0.1.0";

/// Locate the `gemini` CLI binary.
///
/// Search order:
/// 1. `which::which("gemini")` — `$PATH` lookup
/// 2. Platform-specific fallback locations (npm globals, homebrew, etc.)
///
/// # Errors
///
/// Returns [`Error::CliNotFound`] when the binary cannot be located through
/// any of the search paths.
///
/// # Examples
///
/// ```rust,no_run
/// use gemini_cli_sdk::discovery::find_cli;
///
/// let path = find_cli().expect("gemini CLI must be installed");
/// println!("Found at: {}", path.display());
/// ```
pub fn find_cli() -> Result<PathBuf> {
    if let Ok(path) = which::which("gemini") {
        return Ok(path);
    }

    let home = home_dir().ok_or(Error::CliNotFound)?;
    let candidates = platform_candidates(&home);
    for candidate in candidates {
        if candidate.exists() {
            return Ok(candidate);
        }
    }

    Err(Error::CliNotFound)
}

#[cfg(unix)]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
    let mut candidates = vec![
        home.join(".npm-global/bin/gemini"),
        PathBuf::from("/usr/local/bin/gemini"),
        home.join(".local/bin/gemini"),
        PathBuf::from("node_modules/.bin/gemini"),
        home.join(".yarn/bin/gemini"),
    ];
    #[cfg(target_os = "macos")]
    candidates.push(PathBuf::from("/opt/homebrew/bin/gemini"));
    candidates
}

#[cfg(windows)]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
    vec![
        home.join("AppData/Roaming/npm/gemini.exe"),
        PathBuf::from("node_modules/.bin/gemini.exe"),
        home.join("scoop/shims/gemini.exe"),
    ]
}

/// Run `gemini --version` and return the parsed version string.
///
/// The `timeout` parameter defaults to 5 seconds when `None`.
///
/// # Errors
///
/// - [`Error::Timeout`] — the subprocess did not respond within the deadline.
/// - [`Error::SpawnFailed`] — the OS refused to start the process.
/// - [`Error::CliNotFound`] — the subprocess exited with a non-zero status.
/// - [`Error::ProtocolError`] — the version string could not be parsed.
///
/// # Examples
///
/// ```rust,no_run
/// use std::path::Path;
/// use gemini_cli_sdk::discovery::check_cli_version;
///
/// #[tokio::main]
/// async fn main() {
///     let version = check_cli_version(Path::new("gemini"), None)
///         .await
///         .expect("version check failed");
///     println!("CLI version: {version}");
/// }
/// ```
pub async fn check_cli_version(cli_path: &Path, timeout: Option<Duration>) -> Result<String> {
    let timeout = timeout.unwrap_or(Duration::from_secs(5));

    let output = tokio::time::timeout(
        timeout,
        tokio::process::Command::new(cli_path)
            .arg("--version")
            .output(),
    )
    .await
    .map_err(|_| Error::Timeout("Version check timed out".to_string()))?
    .map_err(Error::SpawnFailed)?;

    if !output.status.success() {
        return Err(Error::CliNotFound);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_version_output(&stdout)
}

/// Parse a semantic version from CLI `--version` output.
///
/// Handles formats such as:
/// - `"0.30.0"` (bare version)
/// - `"gemini 0.30.0"` (name-prefixed)
/// - `"0.30.0-nightly.abc123"` (pre-release suffix stripped)
///
/// # Errors
///
/// Returns [`Error::ProtocolError`] when no recognisable version token is
/// found in `output`.
pub(crate) fn parse_version_output(output: &str) -> Result<String> {
    let trimmed = output.trim();
    for word in trimmed.split_whitespace() {
        if word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
            let version = word.split('-').next().unwrap_or(word);
            return Ok(version.to_string());
        }
    }
    Err(Error::ProtocolError(format!(
        "Could not parse version from output: {trimmed}"
    )))
}

/// Return `true` when `version` is greater than or equal to `minimum`.
///
/// Both arguments must be semver triples (`MAJOR.MINOR.PATCH`). Any component
/// that cannot be parsed as a `u32` causes the function to return `false`.
///
/// # Examples
///
/// ```rust
/// use gemini_cli_sdk::discovery::version_satisfies;
///
/// assert!(version_satisfies("1.0.0", "0.30.0"));
/// assert!(version_satisfies("0.30.0", "0.30.0"));
/// assert!(!version_satisfies("0.1.0", "0.30.0"));
/// assert!(!version_satisfies("x.y.z", "0.30.0"));
/// ```
pub fn version_satisfies(version: &str, minimum: &str) -> bool {
    let parse = |s: &str| -> Option<(u32, u32, u32)> {
        let mut parts = s.splitn(3, '.');
        let major = parts.next()?.parse().ok()?;
        let minor = parts.next()?.parse().ok()?;
        let patch = parts.next()?.parse().ok()?;
        Some((major, minor, patch))
    };

    match (parse(version), parse(minimum)) {
        (Some(v), Some(m)) => v >= m,
        _ => false,
    }
}

fn home_dir() -> Option<PathBuf> {
    #[cfg(unix)]
    {
        std::env::var_os("HOME").map(PathBuf::from)
    }
    #[cfg(windows)]
    {
        std::env::var_os("USERPROFILE").map(PathBuf::from)
    }
}

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

    // ── parse_version_output ─────────────────────────────────────────────────

    #[test]
    fn test_parse_version_output_simple() {
        let result = parse_version_output("0.30.0").unwrap();
        assert_eq!(result, "0.30.0");
    }

    #[test]
    fn test_parse_version_output_prefixed() {
        let result = parse_version_output("gemini 0.30.0").unwrap();
        assert_eq!(result, "0.30.0");
    }

    #[test]
    fn test_parse_version_output_nightly() {
        let result = parse_version_output("0.30.0-nightly.xyz").unwrap();
        assert_eq!(result, "0.30.0");
    }

    #[test]
    fn test_parse_version_output_invalid() {
        let result = parse_version_output("not a version");
        assert!(
            result.is_err(),
            "expected Err for unparseable input, got Ok"
        );
        assert!(matches!(result.unwrap_err(), Error::ProtocolError(_)));
    }

    // ── version_satisfies ────────────────────────────────────────────────────

    #[test]
    fn test_version_satisfies_equal() {
        assert!(version_satisfies("0.30.0", "0.30.0"));
    }

    #[test]
    fn test_version_satisfies_higher() {
        assert!(version_satisfies("1.0.0", "0.30.0"));
    }

    #[test]
    fn test_version_satisfies_lower() {
        assert!(!version_satisfies("0.1.0", "0.30.0"));
    }

    #[test]
    fn test_version_satisfies_invalid() {
        assert!(!version_satisfies("x.y.z", "0.30.0"));
    }
}