use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::{Error, Result};
pub const MIN_CLI_VERSION: &str = "0.1.0";
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"),
]
}
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)
}
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}"
)))
}
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::*;
#[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(_)));
}
#[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"));
}
}