use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use directories::ProjectDirs;
use log::{debug, info};
use reqwest::blocking::Client;
use serde::Deserialize;
use tempfile::TempDir;
use crate::plugin::{LoadedPlugin, PluginDescriptor};
use crate::routines::{
descriptor_path, host_target_triple, platform_library_filename, plugin_library_path,
};
#[derive(Debug, Clone)]
pub enum InstallSpec {
LocalPath(PathBuf),
Crate {
name: String,
version: String,
},
Github {
owner: String,
repo: String,
tag: String,
},
}
pub fn parse_install_spec(s: &str) -> Result<InstallSpec, String> {
if let Some(rest) = s.strip_prefix("path:") {
return Ok(InstallSpec::LocalPath(PathBuf::from(rest)));
}
if let Some(rest) = s.strip_prefix("crate:") {
let (name, version) = rest
.split_once('@')
.ok_or_else(|| "Expected crate:<name>@<version>".to_string())?;
return Ok(InstallSpec::Crate {
name: name.to_string(),
version: version.to_string(),
});
}
if let Some(rest) = s.strip_prefix("gh:") {
let (repo_part, tag) = rest
.split_once('@')
.ok_or_else(|| "Expected gh:<owner>/<repo>@<tagOrLatest>".to_string())?;
let (owner, repo) = repo_part
.split_once('/')
.ok_or_else(|| "Expected gh:<owner>/<repo>@...".to_string())?;
return Ok(InstallSpec::Github {
owner: owner.to_string(),
repo: repo.to_string(),
tag: tag.to_string(),
});
}
Err("Unknown install spec. Use path:, crate:, or gh:".to_string())
}
pub fn normalize_cdylib_stem(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("cdylib name must not be empty".to_string());
}
let lower = s.to_ascii_lowercase();
let base = if lower.ends_with(".dll") {
&s[..s.len() - 4]
} else if lower.ends_with(".dylib") {
&s[..s.len() - 6]
} else if lower.ends_with(".so") {
&s[..s.len() - 3]
} else {
s
};
let base = base.trim();
if base.is_empty() {
return Err("cdylib name must not be empty after stripping suffix".to_string());
}
Ok(base.to_string())
}
pub fn install(
proj_dirs: &ProjectDirs,
spec: InstallSpec,
alias_override: Option<&str>,
cdylib_name_override: Option<&str>,
overwrite: bool,
) -> Result<PluginDescriptor, String> {
match spec {
InstallSpec::LocalPath(path) => install_from_local_project(
proj_dirs,
&path,
alias_override,
cdylib_name_override,
overwrite,
),
InstallSpec::Crate { name, version } => {
install_from_crates_io(proj_dirs, &name, &version, cdylib_name_override, overwrite)
}
InstallSpec::Github { owner, repo, tag } => {
if cdylib_name_override.is_some() {
return Err(
"--cdylib-name applies only to path: and crate: installs (GitHub uses release assets)."
.to_string(),
);
}
install_from_github_release(proj_dirs, &owner, &repo, &tag, alias_override, overwrite)
}
}
}
fn install_from_local_project(
proj_dirs: &ProjectDirs,
path: &Path,
alias_override: Option<&str>,
cdylib_name_override: Option<&str>,
overwrite: bool,
) -> Result<PluginDescriptor, String> {
if !path.exists() {
return Err(format!("Local path does not exist: {}", path.display()));
}
let path =
fs::canonicalize(path).map_err(|e| format!("Failed to resolve project path: {e}"))?;
debug!("Installing from local project {}", path.display());
let target = host_target_triple()?;
let build_target_dir = proj_dirs.cache_dir().join("installs").join("local-project");
fs::create_dir_all(&build_target_dir)
.map_err(|e| format!("Failed to create build dir: {e}"))?;
let cdylib_name = match cdylib_name_override {
Some(stem) => stem.to_string(),
None => cargo_cdylib_name(&path)?,
};
let routine_name = alias_override.unwrap_or(&cdylib_name).to_string();
cargo_build_cdylib(&path, &build_target_dir, true)?;
let src = build_target_dir
.join("release")
.join(platform_library_filename(&cdylib_name));
if !src.exists() {
return Err(format!(
"Expected built library not found at {}",
src.display()
));
}
let dest = plugin_library_path(proj_dirs, &routine_name, "dev", &target);
if dest.exists() && !overwrite {
return Err(format!(
"{routine_name} already installed (use -f / --overwrite / --fo to overwrite)"
));
}
fs::create_dir_all(dest.parent().unwrap())
.map_err(|e| format!("Failed to create plugin dir: {e}"))?;
fs::copy(&src, &dest).map_err(|e| format!("Failed to copy plugin: {e}"))?;
let plugin = unsafe { LoadedPlugin::load(&dest) }?;
plugin.ensure_compatible()?;
let desc = plugin.descriptor()?;
let desc_path = descriptor_path(proj_dirs, &routine_name, "dev", &target);
fs::write(&desc_path, serde_json::to_vec_pretty(&desc).unwrap())
.map_err(|e| format!("Failed to write descriptor: {e}"))?;
run_finish_installation(proj_dirs, &plugin, &desc, Some(path.as_path()))?;
info!("Installed {routine_name} ({}) for {target}", desc.version);
Ok(desc)
}
fn install_from_crates_io(
proj_dirs: &ProjectDirs,
crate_name: &str,
version: &str,
cdylib_name_override: Option<&str>,
overwrite: bool,
) -> Result<PluginDescriptor, String> {
let target = host_target_triple()?;
debug!("Installing from crates.io {crate_name}@{version}");
let tmp = TempDir::new().map_err(|e| format!("Failed to create temp dir: {e}"))?;
let tarball = download_crates_io(crate_name, version, tmp.path())?;
let src_dir = extract_crate_tarball(&tarball, tmp.path())?;
let build_target_dir = proj_dirs
.cache_dir()
.join("installs")
.join(format!("crate-{crate_name}-{version}"));
fs::create_dir_all(&build_target_dir)
.map_err(|e| format!("Failed to create build dir: {e}"))?;
cargo_build_cdylib(&src_dir, &build_target_dir, true)?;
let cdylib_name = match cdylib_name_override {
Some(stem) => stem.to_string(),
None => cargo_cdylib_name(&src_dir)?,
};
let src = build_target_dir
.join("release")
.join(platform_library_filename(&cdylib_name));
if !src.exists() {
return Err(format!(
"Expected built library not found at {}",
src.display()
));
}
let routine_name = cdylib_name.clone();
let dest = plugin_library_path(proj_dirs, &routine_name, version, &target);
if dest.exists() && !overwrite {
return Err(format!(
"{routine_name}@{version} already installed (use -f / --overwrite / --fo to overwrite)"
));
}
fs::create_dir_all(dest.parent().unwrap())
.map_err(|e| format!("Failed to create plugin dir: {e}"))?;
fs::copy(&src, &dest).map_err(|e| format!("Failed to copy plugin: {e}"))?;
let plugin = unsafe { LoadedPlugin::load(&dest) }?;
plugin.ensure_compatible()?;
let desc = plugin.descriptor()?;
let desc_path = descriptor_path(proj_dirs, &routine_name, version, &target);
fs::write(&desc_path, serde_json::to_vec_pretty(&desc).unwrap())
.map_err(|e| format!("Failed to write descriptor: {e}"))?;
run_finish_installation(proj_dirs, &plugin, &desc, None)?;
info!("Installed {routine_name}@{version} for {target}");
Ok(desc)
}
fn install_from_github_release(
proj_dirs: &ProjectDirs,
owner: &str,
repo: &str,
tag: &str,
alias_override: Option<&str>,
overwrite: bool,
) -> Result<PluginDescriptor, String> {
let target = host_target_triple()?;
debug!("Installing from GitHub {owner}/{repo}@{tag}");
let client = Client::builder()
.user_agent("cotis-cli")
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let release = github_release(&client, owner, repo, tag)?;
let ext = if cfg!(windows) {
".dll"
} else if cfg!(target_os = "macos") {
".dylib"
} else {
".so"
};
let asset = release
.assets
.iter()
.find(|a| a.name.contains(&target) && a.name.ends_with(ext))
.or_else(|| release.assets.iter().find(|a| a.name.ends_with(ext)))
.ok_or_else(|| "No matching release asset found for this platform".to_string())?;
let routine_name = alias_override.unwrap_or(repo).to_string();
let version = release.tag_name.clone();
let dest = plugin_library_path(proj_dirs, &routine_name, &version, &target);
if dest.exists() && !overwrite {
return Err(format!(
"{routine_name}@{version} already installed (use -f / --overwrite / --fo to overwrite)"
));
}
fs::create_dir_all(dest.parent().unwrap())
.map_err(|e| format!("Failed to create plugin dir: {e}"))?;
debug!("Downloading asset {} -> {}", asset.name, dest.display());
let mut resp = client
.get(&asset.browser_download_url)
.send()
.map_err(|e| format!("Failed to download asset: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Asset download failed with HTTP {}", resp.status()));
}
let mut out =
fs::File::create(&dest).map_err(|e| format!("Failed to create output file: {e}"))?;
let mut buf = Vec::new();
resp.read_to_end(&mut buf)
.map_err(|e| format!("Failed to read download: {e}"))?;
out.write_all(&buf)
.map_err(|e| format!("Failed to write plugin file: {e}"))?;
let plugin = unsafe { LoadedPlugin::load(&dest) }?;
plugin.ensure_compatible()?;
let desc = plugin.descriptor()?;
let desc_path = descriptor_path(proj_dirs, &routine_name, &version, &target);
fs::write(&desc_path, serde_json::to_vec_pretty(&desc).unwrap())
.map_err(|e| format!("Failed to write descriptor: {e}"))?;
run_finish_installation(proj_dirs, &plugin, &desc, None)?;
info!("Installed {routine_name}@{version} for {target}");
Ok(desc)
}
fn run_finish_installation(
proj_dirs: &ProjectDirs,
plugin: &LoadedPlugin,
desc: &PluginDescriptor,
local_install_project_dir: Option<&Path>,
) -> Result<(), String> {
if !desc.finish_installation {
return Ok(());
}
unsafe {
std::env::set_var(
"COTIS_CLI_CACHE_DIR",
proj_dirs.cache_dir().to_string_lossy().into_owned(),
);
if let Some(p) = local_install_project_dir {
std::env::set_var(
"COTIS_CLI_INSTALL_PROJECT_DIR",
p.to_string_lossy().into_owned(),
);
} else {
std::env::remove_var("COTIS_CLI_INSTALL_PROJECT_DIR");
}
}
plugin.finish_installation_if_requested(desc)?;
unsafe {
std::env::remove_var("COTIS_CLI_INSTALL_PROJECT_DIR");
}
Ok(())
}
fn cargo_build_cdylib(project_dir: &Path, target_dir: &Path, release: bool) -> Result<(), String> {
let mut cmd = Command::new("cargo");
cmd.current_dir(project_dir);
cmd.args([
"build",
"--lib",
"--target-dir",
target_dir.to_str().unwrap(),
]);
if release {
cmd.arg("--release");
}
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let st = cmd
.status()
.map_err(|e| format!("Failed to run cargo build: {e}"))?;
if !st.success() {
return Err("cargo build failed".to_string());
}
Ok(())
}
fn cargo_cdylib_name(project_dir: &Path) -> Result<String, String> {
#[derive(Deserialize)]
struct Metadata {
packages: Vec<Package>,
}
#[derive(Deserialize)]
struct Package {
manifest_path: String,
targets: Vec<Target>,
}
#[derive(Deserialize)]
struct Target {
kind: Vec<String>,
name: String,
}
let project_dir = fs::canonicalize(project_dir).map_err(|e| {
format!(
"Failed to resolve project directory {}: {e}",
project_dir.display()
)
})?;
let expected_manifest = fs::canonicalize(project_dir.join("Cargo.toml")).map_err(|e| {
format!(
"Expected Cargo.toml at {}: {e}",
project_dir.join("Cargo.toml").display()
)
})?;
let out = Command::new("cargo")
.current_dir(&project_dir)
.args(["metadata", "--no-deps", "--format-version", "1"])
.stderr(Stdio::inherit())
.output()
.map_err(|e| format!("Failed to run cargo metadata: {e}"))?;
if !out.status.success() {
return Err("cargo metadata failed".to_string());
}
let md: Metadata = serde_json::from_slice(&out.stdout)
.map_err(|e| format!("Failed to parse cargo metadata: {e}"))?;
let pkg = md
.packages
.iter()
.find(|p| {
Path::new(&p.manifest_path).canonicalize().ok().as_ref() == Some(&expected_manifest)
})
.ok_or_else(|| {
format!(
"cargo metadata has no package with manifest {} (workspace root mis-detected?)",
expected_manifest.display()
)
})?;
let name = pkg
.targets
.iter()
.find(|t| t.kind.iter().any(|k| k == "cdylib"))
.map(|t| t.name.clone())
.ok_or_else(|| {
"No cdylib target found (is [lib] crate-type = [\"cdylib\"] set?)".to_string()
})?;
Ok(name)
}
fn download_crates_io(crate_name: &str, version: &str, out_dir: &Path) -> Result<PathBuf, String> {
let url = format!("https://crates.io/api/v1/crates/{crate_name}/{version}/download");
let client = Client::builder()
.user_agent("cotis-cli")
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let mut resp = client
.get(url)
.send()
.map_err(|e| format!("Failed to download crate: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Crate download failed with HTTP {}", resp.status()));
}
let tar_path = out_dir.join(format!("{crate_name}-{version}.crate.tar.gz"));
let mut file =
fs::File::create(&tar_path).map_err(|e| format!("Failed to create tarball file: {e}"))?;
let mut buf = Vec::new();
resp.read_to_end(&mut buf)
.map_err(|e| format!("Failed to read tarball: {e}"))?;
file.write_all(&buf)
.map_err(|e| format!("Failed to write tarball: {e}"))?;
Ok(tar_path)
}
fn extract_crate_tarball(tar_gz_path: &Path, out_dir: &Path) -> Result<PathBuf, String> {
use flate2::read::GzDecoder;
use tar::Archive;
let f = fs::File::open(tar_gz_path).map_err(|e| format!("Failed to open tarball: {e}"))?;
let gz = GzDecoder::new(f);
let mut ar = Archive::new(gz);
ar.unpack(out_dir)
.map_err(|e| format!("Failed to unpack tarball: {e}"))?;
let mut dirs = fs::read_dir(out_dir)
.map_err(|e| format!("Failed to read extracted dir: {e}"))?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect::<Vec<_>>();
if dirs.len() != 1 {
return Err("Unexpected tarball layout after extract".to_string());
}
Ok(dirs.remove(0).path())
}
#[derive(Debug, Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
#[derive(Debug, Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
fn github_release(
client: &Client,
owner: &str,
repo: &str,
tag: &str,
) -> Result<GithubRelease, String> {
let url = if tag == "latest" {
format!("https://api.github.com/repos/{owner}/{repo}/releases/latest")
} else {
format!("https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}")
};
let resp = client
.get(url)
.send()
.map_err(|e| format!("Failed to query GitHub release: {e}"))?;
if !resp.status().is_success() {
return Err(format!("GitHub API error HTTP {}", resp.status()));
}
resp.json::<GithubRelease>()
.map_err(|e| format!("Failed to parse GitHub release JSON: {e}"))
}