use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use tracing;
const APP_NAME: &str = "oxi";
const NETWORK_TIMEOUT_SECS: u64 = 10;
const DOWNLOAD_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolName {
Fd,
Rg,
}
impl ToolName {
pub fn key(&self) -> &'static str {
match self {
ToolName::Fd => "fd",
ToolName::Rg => "rg",
}
}
fn config(&self) -> ToolConfig {
match self {
ToolName::Fd => ToolConfig {
name: "fd",
repo: "sharkdp/fd",
binary_name: "fd",
system_binary_names: vec!["fd", "fdfind"],
tag_prefix: "v",
},
ToolName::Rg => ToolConfig {
name: "ripgrep",
repo: "BurntSushi/ripgrep",
binary_name: "rg",
system_binary_names: vec!["rg"],
tag_prefix: "",
},
}
}
}
struct ToolConfig {
name: &'static str,
repo: &'static str,
binary_name: &'static str,
system_binary_names: Vec<&'static str>,
tag_prefix: &'static str,
}
impl ToolConfig {
fn asset_name(&self, version: &str) -> Option<String> {
let (os, arch) = platform_info();
match self.binary_name {
"fd" => fd_asset_name(version, os, arch),
"rg" => rg_asset_name(version, os, arch),
_ => None,
}
}
}
type OsStr = &'static str;
type ArchStr = &'static str;
fn platform_info() -> (OsStr, ArchStr) {
let os = if cfg!(target_os = "macos") {
"darwin"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"linux"
};
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
};
(os, arch)
}
fn fd_asset_name(version: &str, os: OsStr, arch: ArchStr) -> Option<String> {
let arch_str = if arch == "aarch64" { "aarch64" } else { "x86_64" };
match os {
"darwin" => Some(format!("fd-v{version}-{arch_str}-apple-darwin.tar.gz")),
"linux" => Some(format!(
"fd-v{version}-{arch_str}-unknown-linux-gnu.tar.gz"
)),
"windows" => Some(format!(
"fd-v{version}-{arch_str}-pc-windows-msvc.zip"
)),
_ => None,
}
}
fn rg_asset_name(version: &str, os: OsStr, arch: ArchStr) -> Option<String> {
let arch_str = if arch == "aarch64" { "aarch64" } else { "x86_64" };
match os {
"darwin" => Some(format!(
"ripgrep-{version}-{arch_str}-apple-darwin.tar.gz"
)),
"linux" => {
if arch == "aarch64" {
Some(format!(
"ripgrep-{version}-aarch64-unknown-linux-gnu.tar.gz"
))
} else {
Some(format!(
"ripgrep-{version}-x86_64-unknown-linux-musl.tar.gz"
))
}
}
"windows" => Some(format!(
"ripgrep-{version}-{arch_str}-pc-windows-msvc.zip"
)),
_ => None,
}
}
pub fn get_tools_dir() -> PathBuf {
if let Ok(dir) = std::env::var("OXI_TOOLS_DIR") {
return PathBuf::from(dir);
}
dirs::home_dir()
.unwrap_or_default()
.join(".oxi")
.join("bin")
}
fn binary_suffix() -> &'static str {
if cfg!(target_os = "windows") {
".exe"
} else {
""
}
}
pub fn command_exists(cmd: &str) -> bool {
std::process::Command::new(cmd)
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn get_tool_path(tool: ToolName) -> Option<PathBuf> {
let config = tool.config();
let local_path = get_tools_dir().join(format!("{}{}", config.binary_name, binary_suffix()));
if local_path.exists() {
return Some(local_path);
}
let system_names = &config.system_binary_names;
for name in system_names {
if command_exists(name) {
return Some(PathBuf::from(*name));
}
}
None
}
async fn get_latest_version(repo: &str, tag_prefix: &str) -> Result<String> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(NETWORK_TIMEOUT_SECS))
.user_agent(format!("{APP_NAME}-coding-agent"))
.build()?;
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
bail!("GitHub API error: {}", resp.status());
}
let data: serde_json::Value = resp.json().await?;
let tag = data["tag_name"]
.as_str()
.context("missing tag_name in GitHub response")?;
let version = tag.strip_prefix(tag_prefix).unwrap_or(tag);
Ok(version.to_string())
}
async fn download_file(url: &str, dest: &Path) -> Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
.user_agent(format!("{APP_NAME}-coding-agent"))
.build()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
bail!("Failed to download: {} (HTTP {})", url, resp.status());
}
let bytes = resp.bytes().await?;
fs::write(dest, &bytes)?;
Ok(())
}
fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> {
let file = fs::File::open(archive).context("open tar.gz archive")?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
tar.unpack(dest_dir).context("extract tar.gz archive")?;
Ok(())
}
fn extract_zip(archive: &Path, dest_dir: &Path) -> Result<()> {
let file = fs::File::open(archive).context("open zip archive")?;
let mut archive = zip::ZipArchive::new(file).context("read zip archive")?;
archive.extract(dest_dir).context("extract zip archive")?;
Ok(())
}
fn find_binary_recursively(root_dir: &Path, binary_file_name: &str) -> Option<PathBuf> {
let mut stack = vec![root_dir.to_path_buf()];
while let Some(current) = stack.pop() {
let entries = match fs::read_dir(¤t) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if path.file_name().map(|n| n == binary_file_name).unwrap_or(false) {
return Some(path);
}
} else if path.is_dir() {
stack.push(path);
}
}
}
None
}
async fn download_tool(tool: ToolName) -> Result<PathBuf> {
let config = tool.config();
let tools_dir = get_tools_dir();
let version = get_latest_version(config.repo, config.tag_prefix).await?;
let asset_name = config
.asset_name(&version)
.ok_or_else(|| anyhow::anyhow!("Unsupported platform for tool {}", config.name))?;
fs::create_dir_all(&tools_dir)?;
let download_url = format!(
"https://github.com/{}/releases/download/{}{}/{}",
config.repo, config.tag_prefix, version, asset_name
);
let archive_path = tools_dir.join(&asset_name);
let ext = binary_suffix();
let binary_file_name = format!("{}{ext}", config.binary_name);
let binary_path = tools_dir.join(&binary_file_name);
tracing::debug!("Downloading {} from {}", config.name, download_url);
download_file(&download_url, &archive_path).await?;
let extract_dir = tools_dir.join(format!(
"extract_tmp_{}_{}",
config.binary_name,
std::process::id()
));
fs::create_dir_all(&extract_dir)?;
let result = (|| -> Result<()> {
if asset_name.ends_with(".tar.gz") {
extract_tar_gz(&archive_path, &extract_dir)?;
} else if asset_name.ends_with(".zip") {
extract_zip(&archive_path, &extract_dir)?;
} else {
bail!("Unsupported archive format: {}", asset_name);
}
let archive_stem = asset_name
.trim_end_matches(".tar.gz")
.trim_end_matches(".zip");
let nested_dir = extract_dir.join(archive_stem);
let extracted_binary = if nested_dir.join(&binary_file_name).exists() {
nested_dir.join(&binary_file_name)
} else if extract_dir.join(&binary_file_name).exists() {
extract_dir.join(&binary_file_name)
} else {
find_binary_recursively(&extract_dir, &binary_file_name)
.context(format!("Binary not found in archive: expected {binary_file_name}"))?
};
fs::rename(&extracted_binary, &binary_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755))?;
}
Ok(())
})();
let _ = fs::remove_file(&archive_path);
let _ = fs::remove_dir_all(&extract_dir);
result?;
tracing::debug!("{} installed to {}", config.name, binary_path.display());
Ok(binary_path)
}
fn is_offline_mode_enabled() -> bool {
match std::env::var("OXI_OFFLINE") {
Ok(v) => v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"),
Err(_) => false,
}
}
pub async fn ensure_tool(tool: ToolName) -> Result<PathBuf> {
if let Some(path) = get_tool_path(tool) {
return Ok(path);
}
let config = tool.config();
if is_offline_mode_enabled() {
tracing::warn!(
"{} not found. Offline mode enabled, skipping download.",
config.name
);
bail!(
"{} not found and offline mode is enabled",
config.name
);
}
tracing::info!("{} not found locally. Downloading...", config.name);
match download_tool(tool).await {
Ok(path) => {
tracing::info!("{} installed to {}", config.name, path.display());
Ok(path)
}
Err(e) => {
tracing::warn!("Failed to download {}: {}", config.name, e);
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fd_asset_name_macos_aarch64() {
let name = fd_asset_name("10.1.0", "darwin", "aarch64");
assert_eq!(
name,
Some("fd-v10.1.0-aarch64-apple-darwin.tar.gz".to_string())
);
}
#[test]
fn test_fd_asset_name_linux_x86_64() {
let name = fd_asset_name("10.1.0", "linux", "x86_64");
assert_eq!(
name,
Some("fd-v10.1.0-x86_64-unknown-linux-gnu.tar.gz".to_string())
);
}
#[test]
fn test_fd_asset_name_windows_x86_64() {
let name = fd_asset_name("10.1.0", "windows", "x86_64");
assert_eq!(
name,
Some("fd-v10.1.0-x86_64-pc-windows-msvc.zip".to_string())
);
}
#[test]
fn test_rg_asset_name_macos_aarch64() {
let name = rg_asset_name("14.1.0", "darwin", "aarch64");
assert_eq!(
name,
Some("ripgrep-14.1.0-aarch64-apple-darwin.tar.gz".to_string())
);
}
#[test]
fn test_rg_asset_name_linux_x86_64() {
let name = rg_asset_name("14.1.0", "linux", "x86_64");
assert_eq!(
name,
Some("ripgrep-14.1.0-x86_64-unknown-linux-musl.tar.gz".to_string())
);
}
#[test]
fn test_rg_asset_name_linux_aarch64() {
let name = rg_asset_name("14.1.0", "linux", "aarch64");
assert_eq!(
name,
Some("ripgrep-14.1.0-aarch64-unknown-linux-gnu.tar.gz".to_string())
);
}
#[test]
fn test_rg_asset_name_windows_x86_64() {
let name = rg_asset_name("14.1.0", "windows", "x86_64");
assert_eq!(
name,
Some("ripgrep-14.1.0-x86_64-pc-windows-msvc.zip".to_string())
);
}
#[test]
fn test_unsupported_platform() {
let name = fd_asset_name("10.1.0", "freebsd", "x86_64");
assert!(name.is_none());
}
#[test]
fn test_tool_config() {
assert_eq!(ToolName::Fd.key(), "fd");
assert_eq!(ToolName::Rg.key(), "rg");
let fd = ToolName::Fd.config();
assert_eq!(fd.repo, "sharkdp/fd");
assert_eq!(fd.tag_prefix, "v");
let rg = ToolName::Rg.config();
assert_eq!(rg.repo, "BurntSushi/ripgrep");
assert_eq!(rg.tag_prefix, "");
}
#[test]
fn test_get_tools_dir_default() {
let dir = get_tools_dir();
assert!(dir.to_string_lossy().contains(".oxi"));
assert!(dir.to_string_lossy().contains("bin"));
}
#[test]
fn test_command_exists_known_cmd() {
#[cfg(unix)]
{
let result = std::process::Command::new("uname")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.status();
assert!(result.is_ok());
}
}
#[test]
fn test_binary_suffix() {
#[cfg(target_os = "windows")]
assert_eq!(binary_suffix(), ".exe");
#[cfg(not(target_os = "windows"))]
assert_eq!(binary_suffix(), "");
}
}