standard-plugin-cli 0.1.1

standard-plugin: scaffold, build, check and pack Standard Code plugins
Documentation
//! `standard-plugin build`: cargo builds the crate for `wasm32-wasip2`
//! (whose output is already a component), the component is copied into
//! the bundle under the manifest's `module` name, and the browser artifact
//! is transpiled when `jco` is installed.

use std::path::{Path, PathBuf};
use std::process::Command;

use crate::project::Plugin;

pub const TARGET: &str = "wasm32-wasip2";

/// What a build produced.
#[derive(Clone, Debug)]
pub struct Built {
    pub component: PathBuf,
    /// The transpiled browser artifact directory, or why it was skipped.
    pub browser: Result<PathBuf, String>,
}

pub(crate) fn cargo() -> Command {
    Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
}

/// The crate's cdylib artifact path for a profile.
fn artifact(dir: &Path, release: bool) -> Result<PathBuf, String> {
    let output = cargo()
        .args(["metadata", "--format-version", "1", "--no-deps"])
        .current_dir(dir)
        .output()
        .map_err(|error| format!("cargo metadata: {error}"))?;
    if !output.status.success() {
        return Err(format!(
            "cargo metadata failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
        .map_err(|error| format!("cargo metadata: {error}"))?;
    let manifest = dir
        .join("Cargo.toml")
        .canonicalize()
        .map_err(|error| format!("{}: {error}", dir.join("Cargo.toml").display()))?;
    let package = metadata["packages"]
        .as_array()
        .into_iter()
        .flatten()
        .find(|package| {
            package["manifest_path"]
                .as_str()
                .and_then(|path| Path::new(path).canonicalize().ok())
                .is_some_and(|path| path == manifest)
        })
        .ok_or_else(|| format!("{} is not a package cargo knows", manifest.display()))?;
    let name = package["targets"]
        .as_array()
        .into_iter()
        .flatten()
        .find(|target| {
            target["kind"]
                .as_array()
                .is_some_and(|kinds| kinds.iter().any(|kind| kind == "cdylib"))
        })
        .and_then(|target| target["name"].as_str())
        .ok_or_else(|| "the crate has no cdylib library (crate-type = [\"cdylib\"])".to_string())?;
    let target_dir = metadata["target_directory"]
        .as_str()
        .ok_or("cargo metadata has no target directory")?;
    Ok(Path::new(target_dir)
        .join(TARGET)
        .join(if release { "release" } else { "debug" })
        .join(format!("{}.wasm", name.replace('-', "_"))))
}

fn find_on_path(program: &str) -> Option<PathBuf> {
    std::env::var_os("PATH").and_then(|paths| {
        std::env::split_paths(&paths)
            .map(|dir| dir.join(program))
            .find(|path| path.is_file())
    })
}

/// Builds one plugin.
pub fn build(plugin: &Plugin, release: bool) -> Result<Built, String> {
    let manifest = plugin.manifest()?;
    let mut command = cargo();
    command
        .args(["build", "--lib", "--target", TARGET])
        .current_dir(&plugin.dir);
    if release {
        command.arg("--release");
    }
    let status = command
        .status()
        .map_err(|error| format!("cargo build: {error}"))?;
    if !status.success() {
        return Err(format!("cargo build failed ({status})"));
    }
    let built = artifact(&plugin.dir, release)?;
    plugin
        .stage_manifest()
        .map_err(|error| format!("{}: {error}", plugin.bundle.display()))?;
    let component = plugin.bundle.join(&manifest.module);
    std::fs::copy(&built, &component)
        .map_err(|error| format!("{} -> {}: {error}", built.display(), component.display()))?;

    let browser = match find_on_path("jco") {
        None => {
            Err("skipped: `jco` is not installed (npm install -g @bytecodealliance/jco)".into())
        }
        Some(jco) => {
            let out = plugin.bundle.join("browser");
            let status = Command::new(jco)
                .arg("transpile")
                .arg(&component)
                .arg("--out-dir")
                .arg(&out)
                .args(["--name", &manifest.id])
                .status()
                .map_err(|error| format!("jco: {error}"))?;
            if status.success() {
                Ok(out)
            } else {
                Err(format!("jco transpile failed ({status})"))
            }
        }
    };
    Ok(Built { component, browser })
}