1use std::fs;
20use std::io::{Read, Write};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23
24use directories::ProjectDirs;
25use log::{debug, info};
26use reqwest::blocking::Client;
27use serde::Deserialize;
28use tempfile::TempDir;
29
30use crate::plugin::{LoadedPlugin, PluginDescriptor};
31use crate::routines::{
32 descriptor_path, host_target_triple, platform_library_filename, plugin_library_path,
33};
34
35#[derive(Debug, Clone)]
37pub enum InstallSpec {
38 LocalPath(PathBuf),
40 Crate {
42 name: String,
44 version: String,
46 },
47 Github {
49 owner: String,
51 repo: String,
53 tag: String,
55 },
56}
57
58pub fn parse_install_spec(s: &str) -> Result<InstallSpec, String> {
100 if let Some(rest) = s.strip_prefix("path:") {
101 return Ok(InstallSpec::LocalPath(PathBuf::from(rest)));
102 }
103 if let Some(rest) = s.strip_prefix("crate:") {
104 let (name, version) = rest
105 .split_once('@')
106 .ok_or_else(|| "Expected crate:<name>@<version>".to_string())?;
107 return Ok(InstallSpec::Crate {
108 name: name.to_string(),
109 version: version.to_string(),
110 });
111 }
112 if let Some(rest) = s.strip_prefix("gh:") {
113 let (repo_part, tag) = rest
114 .split_once('@')
115 .ok_or_else(|| "Expected gh:<owner>/<repo>@<tagOrLatest>".to_string())?;
116 let (owner, repo) = repo_part
117 .split_once('/')
118 .ok_or_else(|| "Expected gh:<owner>/<repo>@...".to_string())?;
119 return Ok(InstallSpec::Github {
120 owner: owner.to_string(),
121 repo: repo.to_string(),
122 tag: tag.to_string(),
123 });
124 }
125 Err("Unknown install spec. Use path:, crate:, or gh:".to_string())
126}
127
128pub fn normalize_cdylib_stem(s: &str) -> Result<String, String> {
146 let s = s.trim();
147 if s.is_empty() {
148 return Err("cdylib name must not be empty".to_string());
149 }
150 let lower = s.to_ascii_lowercase();
151 let base = if lower.ends_with(".dll") {
152 &s[..s.len() - 4]
153 } else if lower.ends_with(".dylib") {
154 &s[..s.len() - 6]
155 } else if lower.ends_with(".so") {
156 &s[..s.len() - 3]
157 } else {
158 s
159 };
160 let base = base.trim();
161 if base.is_empty() {
162 return Err("cdylib name must not be empty after stripping suffix".to_string());
163 }
164 Ok(base.to_string())
165}
166
167pub fn install(
181 proj_dirs: &ProjectDirs,
182 spec: InstallSpec,
183 alias_override: Option<&str>,
184 cdylib_name_override: Option<&str>,
185 overwrite: bool,
186) -> Result<PluginDescriptor, String> {
187 match spec {
188 InstallSpec::LocalPath(path) => install_from_local_project(
189 proj_dirs,
190 &path,
191 alias_override,
192 cdylib_name_override,
193 overwrite,
194 ),
195 InstallSpec::Crate { name, version } => {
196 install_from_crates_io(proj_dirs, &name, &version, cdylib_name_override, overwrite)
197 }
198 InstallSpec::Github { owner, repo, tag } => {
199 if cdylib_name_override.is_some() {
200 return Err(
201 "--cdylib-name applies only to path: and crate: installs (GitHub uses release assets)."
202 .to_string(),
203 );
204 }
205 install_from_github_release(proj_dirs, &owner, &repo, &tag, alias_override, overwrite)
206 }
207 }
208}
209
210fn install_from_local_project(
211 proj_dirs: &ProjectDirs,
212 path: &Path,
213 alias_override: Option<&str>,
214 cdylib_name_override: Option<&str>,
215 overwrite: bool,
216) -> Result<PluginDescriptor, String> {
217 if !path.exists() {
218 return Err(format!("Local path does not exist: {}", path.display()));
219 }
220 let path =
221 fs::canonicalize(path).map_err(|e| format!("Failed to resolve project path: {e}"))?;
222 debug!("Installing from local project {}", path.display());
223
224 let target = host_target_triple()?;
225
226 let build_target_dir = proj_dirs.cache_dir().join("installs").join("local-project");
227 fs::create_dir_all(&build_target_dir)
228 .map_err(|e| format!("Failed to create build dir: {e}"))?;
229
230 let cdylib_name = match cdylib_name_override {
231 Some(stem) => stem.to_string(),
232 None => cargo_cdylib_name(&path)?,
233 };
234 let routine_name = alias_override.unwrap_or(&cdylib_name).to_string();
235
236 cargo_build_cdylib(&path, &build_target_dir, true)?;
237
238 let src = build_target_dir
239 .join("release")
240 .join(platform_library_filename(&cdylib_name));
241 if !src.exists() {
242 return Err(format!(
243 "Expected built library not found at {}",
244 src.display()
245 ));
246 }
247
248 let dest = plugin_library_path(proj_dirs, &routine_name, "dev", &target);
249 if dest.exists() && !overwrite {
250 return Err(format!(
251 "{routine_name} already installed (use -f / --overwrite / --fo to overwrite)"
252 ));
253 }
254 fs::create_dir_all(dest.parent().unwrap())
255 .map_err(|e| format!("Failed to create plugin dir: {e}"))?;
256 fs::copy(&src, &dest).map_err(|e| format!("Failed to copy plugin: {e}"))?;
257
258 let plugin = unsafe { LoadedPlugin::load(&dest) }?;
259 plugin.ensure_compatible()?;
260 let desc = plugin.descriptor()?;
261 let desc_path = descriptor_path(proj_dirs, &routine_name, "dev", &target);
262 fs::write(&desc_path, serde_json::to_vec_pretty(&desc).unwrap())
263 .map_err(|e| format!("Failed to write descriptor: {e}"))?;
264
265 run_finish_installation(proj_dirs, &plugin, &desc, Some(path.as_path()))?;
266
267 info!("Installed {routine_name} ({}) for {target}", desc.version);
268 Ok(desc)
269}
270
271fn install_from_crates_io(
272 proj_dirs: &ProjectDirs,
273 crate_name: &str,
274 version: &str,
275 cdylib_name_override: Option<&str>,
276 overwrite: bool,
277) -> Result<PluginDescriptor, String> {
278 let target = host_target_triple()?;
279 debug!("Installing from crates.io {crate_name}@{version}");
280
281 let tmp = TempDir::new().map_err(|e| format!("Failed to create temp dir: {e}"))?;
282 let tarball = download_crates_io(crate_name, version, tmp.path())?;
283 let src_dir = extract_crate_tarball(&tarball, tmp.path())?;
284
285 let build_target_dir = proj_dirs
286 .cache_dir()
287 .join("installs")
288 .join(format!("crate-{crate_name}-{version}"));
289 fs::create_dir_all(&build_target_dir)
290 .map_err(|e| format!("Failed to create build dir: {e}"))?;
291 cargo_build_cdylib(&src_dir, &build_target_dir, true)?;
292
293 let cdylib_name = match cdylib_name_override {
294 Some(stem) => stem.to_string(),
295 None => cargo_cdylib_name(&src_dir)?,
296 };
297 let src = build_target_dir
298 .join("release")
299 .join(platform_library_filename(&cdylib_name));
300 if !src.exists() {
301 return Err(format!(
302 "Expected built library not found at {}",
303 src.display()
304 ));
305 }
306
307 let routine_name = cdylib_name.clone();
308 let dest = plugin_library_path(proj_dirs, &routine_name, version, &target);
309 if dest.exists() && !overwrite {
310 return Err(format!(
311 "{routine_name}@{version} already installed (use -f / --overwrite / --fo to overwrite)"
312 ));
313 }
314 fs::create_dir_all(dest.parent().unwrap())
315 .map_err(|e| format!("Failed to create plugin dir: {e}"))?;
316 fs::copy(&src, &dest).map_err(|e| format!("Failed to copy plugin: {e}"))?;
317
318 let plugin = unsafe { LoadedPlugin::load(&dest) }?;
319 plugin.ensure_compatible()?;
320 let desc = plugin.descriptor()?;
321 let desc_path = descriptor_path(proj_dirs, &routine_name, version, &target);
322 fs::write(&desc_path, serde_json::to_vec_pretty(&desc).unwrap())
323 .map_err(|e| format!("Failed to write descriptor: {e}"))?;
324
325 run_finish_installation(proj_dirs, &plugin, &desc, None)?;
326
327 info!("Installed {routine_name}@{version} for {target}");
328 Ok(desc)
329}
330
331fn install_from_github_release(
332 proj_dirs: &ProjectDirs,
333 owner: &str,
334 repo: &str,
335 tag: &str,
336 alias_override: Option<&str>,
337 overwrite: bool,
338) -> Result<PluginDescriptor, String> {
339 let target = host_target_triple()?;
340 debug!("Installing from GitHub {owner}/{repo}@{tag}");
341
342 let client = Client::builder()
343 .user_agent("cotis-cli")
344 .build()
345 .map_err(|e| format!("Failed to build HTTP client: {e}"))?;
346
347 let release = github_release(&client, owner, repo, tag)?;
348 let ext = if cfg!(windows) {
349 ".dll"
350 } else if cfg!(target_os = "macos") {
351 ".dylib"
352 } else {
353 ".so"
354 };
355
356 let asset = release
359 .assets
360 .iter()
361 .find(|a| a.name.contains(&target) && a.name.ends_with(ext))
362 .or_else(|| release.assets.iter().find(|a| a.name.ends_with(ext)))
363 .ok_or_else(|| "No matching release asset found for this platform".to_string())?;
364
365 let routine_name = alias_override.unwrap_or(repo).to_string();
366 let version = release.tag_name.clone();
367
368 let dest = plugin_library_path(proj_dirs, &routine_name, &version, &target);
369 if dest.exists() && !overwrite {
370 return Err(format!(
371 "{routine_name}@{version} already installed (use -f / --overwrite / --fo to overwrite)"
372 ));
373 }
374 fs::create_dir_all(dest.parent().unwrap())
375 .map_err(|e| format!("Failed to create plugin dir: {e}"))?;
376
377 debug!("Downloading asset {} -> {}", asset.name, dest.display());
378 let mut resp = client
379 .get(&asset.browser_download_url)
380 .send()
381 .map_err(|e| format!("Failed to download asset: {e}"))?;
382 if !resp.status().is_success() {
383 return Err(format!("Asset download failed with HTTP {}", resp.status()));
384 }
385 let mut out =
386 fs::File::create(&dest).map_err(|e| format!("Failed to create output file: {e}"))?;
387 let mut buf = Vec::new();
388 resp.read_to_end(&mut buf)
389 .map_err(|e| format!("Failed to read download: {e}"))?;
390 out.write_all(&buf)
391 .map_err(|e| format!("Failed to write plugin file: {e}"))?;
392
393 let plugin = unsafe { LoadedPlugin::load(&dest) }?;
394 plugin.ensure_compatible()?;
395 let desc = plugin.descriptor()?;
396 let desc_path = descriptor_path(proj_dirs, &routine_name, &version, &target);
397 fs::write(&desc_path, serde_json::to_vec_pretty(&desc).unwrap())
398 .map_err(|e| format!("Failed to write descriptor: {e}"))?;
399
400 run_finish_installation(proj_dirs, &plugin, &desc, None)?;
401
402 info!("Installed {routine_name}@{version} for {target}");
403 Ok(desc)
404}
405
406fn run_finish_installation(
407 proj_dirs: &ProjectDirs,
408 plugin: &LoadedPlugin,
409 desc: &PluginDescriptor,
410 local_install_project_dir: Option<&Path>,
411) -> Result<(), String> {
412 if !desc.finish_installation {
413 return Ok(());
414 }
415
416 unsafe {
418 std::env::set_var(
419 "COTIS_CLI_CACHE_DIR",
420 proj_dirs.cache_dir().to_string_lossy().into_owned(),
421 );
422 if let Some(p) = local_install_project_dir {
423 std::env::set_var(
424 "COTIS_CLI_INSTALL_PROJECT_DIR",
425 p.to_string_lossy().into_owned(),
426 );
427 } else {
428 std::env::remove_var("COTIS_CLI_INSTALL_PROJECT_DIR");
429 }
430 }
431
432 plugin.finish_installation_if_requested(desc)?;
433
434 unsafe {
435 std::env::remove_var("COTIS_CLI_INSTALL_PROJECT_DIR");
436 }
437 Ok(())
438}
439
440fn cargo_build_cdylib(project_dir: &Path, target_dir: &Path, release: bool) -> Result<(), String> {
441 let mut cmd = Command::new("cargo");
442 cmd.current_dir(project_dir);
443 cmd.args([
444 "build",
445 "--lib",
446 "--target-dir",
447 target_dir.to_str().unwrap(),
448 ]);
449 if release {
450 cmd.arg("--release");
451 }
452 cmd.stdout(Stdio::inherit());
453 cmd.stderr(Stdio::inherit());
454 let st = cmd
455 .status()
456 .map_err(|e| format!("Failed to run cargo build: {e}"))?;
457 if !st.success() {
458 return Err("cargo build failed".to_string());
459 }
460 Ok(())
461}
462
463fn cargo_cdylib_name(project_dir: &Path) -> Result<String, String> {
464 #[derive(Deserialize)]
465 struct Metadata {
466 packages: Vec<Package>,
467 }
468 #[derive(Deserialize)]
469 struct Package {
470 manifest_path: String,
471 targets: Vec<Target>,
472 }
473 #[derive(Deserialize)]
474 struct Target {
475 kind: Vec<String>,
476 name: String,
477 }
478
479 let project_dir = fs::canonicalize(project_dir).map_err(|e| {
480 format!(
481 "Failed to resolve project directory {}: {e}",
482 project_dir.display()
483 )
484 })?;
485 let expected_manifest = fs::canonicalize(project_dir.join("Cargo.toml")).map_err(|e| {
486 format!(
487 "Expected Cargo.toml at {}: {e}",
488 project_dir.join("Cargo.toml").display()
489 )
490 })?;
491
492 let out = Command::new("cargo")
493 .current_dir(&project_dir)
494 .args(["metadata", "--no-deps", "--format-version", "1"])
495 .stderr(Stdio::inherit())
496 .output()
497 .map_err(|e| format!("Failed to run cargo metadata: {e}"))?;
498 if !out.status.success() {
499 return Err("cargo metadata failed".to_string());
500 }
501 let md: Metadata = serde_json::from_slice(&out.stdout)
502 .map_err(|e| format!("Failed to parse cargo metadata: {e}"))?;
503
504 let pkg = md
505 .packages
506 .iter()
507 .find(|p| {
508 Path::new(&p.manifest_path).canonicalize().ok().as_ref() == Some(&expected_manifest)
509 })
510 .ok_or_else(|| {
511 format!(
512 "cargo metadata has no package with manifest {} (workspace root mis-detected?)",
513 expected_manifest.display()
514 )
515 })?;
516
517 let name = pkg
518 .targets
519 .iter()
520 .find(|t| t.kind.iter().any(|k| k == "cdylib"))
521 .map(|t| t.name.clone())
522 .ok_or_else(|| {
523 "No cdylib target found (is [lib] crate-type = [\"cdylib\"] set?)".to_string()
524 })?;
525 Ok(name)
526}
527
528fn download_crates_io(crate_name: &str, version: &str, out_dir: &Path) -> Result<PathBuf, String> {
529 let url = format!("https://crates.io/api/v1/crates/{crate_name}/{version}/download");
530 let client = Client::builder()
531 .user_agent("cotis-cli")
532 .build()
533 .map_err(|e| format!("Failed to build HTTP client: {e}"))?;
534 let mut resp = client
535 .get(url)
536 .send()
537 .map_err(|e| format!("Failed to download crate: {e}"))?;
538 if !resp.status().is_success() {
539 return Err(format!("Crate download failed with HTTP {}", resp.status()));
540 }
541 let tar_path = out_dir.join(format!("{crate_name}-{version}.crate.tar.gz"));
542 let mut file =
543 fs::File::create(&tar_path).map_err(|e| format!("Failed to create tarball file: {e}"))?;
544 let mut buf = Vec::new();
545 resp.read_to_end(&mut buf)
546 .map_err(|e| format!("Failed to read tarball: {e}"))?;
547 file.write_all(&buf)
548 .map_err(|e| format!("Failed to write tarball: {e}"))?;
549 Ok(tar_path)
550}
551
552fn extract_crate_tarball(tar_gz_path: &Path, out_dir: &Path) -> Result<PathBuf, String> {
553 use flate2::read::GzDecoder;
554 use tar::Archive;
555
556 let f = fs::File::open(tar_gz_path).map_err(|e| format!("Failed to open tarball: {e}"))?;
557 let gz = GzDecoder::new(f);
558 let mut ar = Archive::new(gz);
559 ar.unpack(out_dir)
560 .map_err(|e| format!("Failed to unpack tarball: {e}"))?;
561
562 let mut dirs = fs::read_dir(out_dir)
563 .map_err(|e| format!("Failed to read extracted dir: {e}"))?
564 .filter_map(|e| e.ok())
565 .filter(|e| e.path().is_dir())
566 .collect::<Vec<_>>();
567 if dirs.len() != 1 {
568 return Err("Unexpected tarball layout after extract".to_string());
569 }
570 Ok(dirs.remove(0).path())
571}
572
573#[derive(Debug, Deserialize)]
574struct GithubRelease {
575 tag_name: String,
576 assets: Vec<GithubAsset>,
577}
578
579#[derive(Debug, Deserialize)]
580struct GithubAsset {
581 name: String,
582 browser_download_url: String,
583}
584
585fn github_release(
586 client: &Client,
587 owner: &str,
588 repo: &str,
589 tag: &str,
590) -> Result<GithubRelease, String> {
591 let url = if tag == "latest" {
592 format!("https://api.github.com/repos/{owner}/{repo}/releases/latest")
593 } else {
594 format!("https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}")
595 };
596 let resp = client
597 .get(url)
598 .send()
599 .map_err(|e| format!("Failed to query GitHub release: {e}"))?;
600 if !resp.status().is_success() {
601 return Err(format!("GitHub API error HTTP {}", resp.status()));
602 }
603 resp.json::<GithubRelease>()
604 .map_err(|e| format!("Failed to parse GitHub release JSON: {e}"))
605}