Skip to main content

cotis_cli/
install.rs

1//! Install routines from local projects, crates.io, or GitHub releases.
2//!
3//! Parses `path:`, `crate:`, and `gh:` install specs, builds or downloads cdylibs, validates the
4//! plugin ABI, writes `descriptor.json`, and optionally runs `cotis_plugin_finish_installation`.
5//!
6//! ## Version naming
7//!
8//! | Source | Cache version directory | Routine name |
9//! |--------|-------------------------|--------------|
10//! | `path:` | Always `"dev"` | cdylib stem or alias |
11//! | `crate:` | Crate version string | cdylib stem (alias ignored) |
12//! | `gh:` | Release `tag_name` | repo name or alias |
13//!
14//! ## Overwrite
15//!
16//! Unless `overwrite` is true, install fails if the target plugin file already exists.
17//! Use `-f` / `--overwrite` / `--fo` on the CLI, or `update` / `reinstall` which always overwrite.
18
19use 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/// Parsed install source for [`install`].
36#[derive(Debug, Clone)]
37pub enum InstallSpec {
38    /// Local Cargo project directory (`path:<dir>`).
39    LocalPath(PathBuf),
40    /// crates.io crate (`crate:<name>@<version>`).
41    Crate {
42        /// Crate name on crates.io.
43        name: String,
44        /// Exact crate version to download and build.
45        version: String,
46    },
47    /// GitHub release (`gh:<owner>/<repo>@<tag|latest>`).
48    Github {
49        /// Repository owner.
50        owner: String,
51        /// Repository name.
52        repo: String,
53        /// Release tag, or `"latest"` for the newest release.
54        tag: String,
55    },
56}
57
58/// Parse an install spec string into [`InstallSpec`].
59///
60/// Accepted forms:
61/// - `path:<directory>`
62/// - `crate:<name>@<version>`
63/// - `gh:<owner>/<repo>@<tagOrLatest>`
64///
65/// # Errors
66///
67/// Returns an error if the prefix is unknown or required components are missing.
68///
69/// # Examples
70///
71/// ```
72/// use cotis_cli::install::{parse_install_spec, InstallSpec};
73/// use std::path::PathBuf;
74///
75/// match parse_install_spec("path:./my-routine").unwrap() {
76///     InstallSpec::LocalPath(p) => assert_eq!(p, PathBuf::from("./my-routine")),
77///     _ => panic!("expected LocalPath"),
78/// }
79///
80/// match parse_install_spec("crate:my-routine@0.2.0").unwrap() {
81///     InstallSpec::Crate { name, version } => {
82///         assert_eq!(name, "my-routine");
83///         assert_eq!(version, "0.2.0");
84///     }
85///     _ => panic!("expected Crate"),
86/// }
87///
88/// match parse_install_spec("gh:org/repo@latest").unwrap() {
89///     InstallSpec::Github { owner, repo, tag } => {
90///         assert_eq!(owner, "org");
91///         assert_eq!(repo, "repo");
92///         assert_eq!(tag, "latest");
93///     }
94///     _ => panic!("expected Github"),
95/// }
96///
97/// assert!(parse_install_spec("invalid").is_err());
98/// ```
99pub 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
128/// Normalizes `--cdylib-name` / `--dll-name`: trim and strip a trailing platform suffix if present.
129///
130/// # Errors
131///
132/// Returns an error if the stem is empty before or after stripping.
133///
134/// # Examples
135///
136/// ```
137/// use cotis_cli::install::normalize_cdylib_stem;
138///
139/// assert_eq!(normalize_cdylib_stem("my_plugin").unwrap(), "my_plugin");
140/// assert_eq!(normalize_cdylib_stem("my_plugin.dll").unwrap(), "my_plugin");
141/// assert_eq!(normalize_cdylib_stem("  libfoo.so  ").unwrap(), "libfoo");
142/// assert!(normalize_cdylib_stem("").is_err());
143/// assert!(normalize_cdylib_stem(".dll").is_err());
144/// ```
145pub 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
167/// Install a routine into the cotis-cli cache.
168///
169/// Builds or downloads the cdylib, copies it to the cache, validates the plugin ABI, writes
170/// `descriptor.json`, and runs `cotis_plugin_finish_installation` when requested by the descriptor.
171///
172/// `alias_override` sets the routine cache name for `path:` and `gh:` installs (ignored for `crate:`).
173/// `cdylib_name_override` overrides cargo metadata cdylib detection for `path:` and `crate:` only.
174///
175/// # Errors
176///
177/// Returns an error for missing paths, failed cargo builds, HTTP failures, missing cdylib artifacts,
178/// already-installed routines (when `overwrite` is false), incompatible plugins, or failed
179/// finish-installation hooks.
180pub 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    // Prefer an asset whose name contains the host triple and matching extension;
357    // otherwise take the first asset with a matching extension.
358    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    // SAFETY: `cotis-cli install` runs single-threaded; env is restored immediately after the plugin hook returns.
417    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}