//! `rpi install` — install a Rust `cdylib` extension from crates.io.
//!
//! Cargo's `cargo install` command is intended for binaries and does not copy
//! dynamic-library targets. rpi extensions are cdylibs loaded by
//! `rpi-extensions`, so this command creates a tiny temporary Cargo workspace,
//! resolves the requested crate through Cargo, builds the dependency in
//! release mode, and copies its cdylib into the same global directory scanned
//! during normal startup.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
const NATIVE_PACKAGES_FILE: &str = "native-packages.json";
/// A Rust-native extension installed through `rpi install`.
///
/// `source` is absent for crates.io packages and contains the local source
/// path for `--path` installs. Local development crates are intentionally not
/// eligible for automatic registry updates.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstalledNativePackage {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// Dynamic-library file names copied into the extension store. Older
/// metadata files omit this field; uninstall falls back to crate-name
/// matching for those records.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<String>,
}
#[derive(Debug, Clone)]
struct InstallOptions {
package: String,
version: Option<String>,
path: Option<PathBuf>,
locked: bool,
force: bool,
}
#[derive(Debug, Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
}
#[derive(Debug, Deserialize)]
struct CargoPackage {
name: String,
version: String,
targets: Vec<CargoTarget>,
}
#[derive(Debug, Deserialize)]
struct CargoTarget {
name: String,
crate_types: Vec<String>,
}
/// Run `rpi install ...`. This is intentionally synchronous: it is a short
/// lived package operation and keeping Cargo's build output attached to the
/// user's terminal makes failures actionable.
pub fn run(args: &[String]) -> i32 {
if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
print_help();
return 0;
}
let options = match parse_args(args) {
Ok(options) => options,
Err(message) => {
eprintln!("error: {message}");
print_help();
return 2;
}
};
let temp = match tempfile::tempdir() {
Ok(temp) => temp,
Err(error) => {
eprintln!("error: could not create a temporary Cargo workspace: {error}");
return 1;
}
};
let manifest = temp.path().join("Cargo.toml");
if let Err(error) = write_manifest(&manifest, &options) {
eprintln!("error: could not prepare Cargo workspace: {error}");
return 1;
}
if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
eprintln!("error: could not resolve `{}`: {error}", options.package);
return 1;
}
let metadata = match cargo_metadata(&manifest, &options) {
Ok(metadata) => metadata,
Err(error) => {
eprintln!("error: could not inspect `{}`: {error}", options.package);
return 1;
}
};
let package = match metadata
.packages
.iter()
.find(|package| package.name == options.package)
{
Some(package) => package,
None => {
eprintln!(
"error: Cargo did not resolve a package named `{}`",
options.package
);
return 1;
}
};
let cdylib_targets: Vec<&CargoTarget> = package
.targets
.iter()
.filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
.collect();
if cdylib_targets.is_empty() {
eprintln!(
"error: `{}` is not an rpi extension crate; it has no `cdylib` target",
options.package
);
eprintln!(
"hint: the crate must declare `crate-type = [\"cdylib\"]` and export `rpi_plugin_register`"
);
return 1;
}
if let Err(error) = cargo_command("build", &manifest, &options, true) {
eprintln!("error: failed to build `{}`: {error}", options.package);
return 1;
}
let artifact_dir = temp.path().join("target").join("release");
let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
Ok(artifacts) => artifacts,
Err(error) => {
eprintln!("error: {error}");
return 1;
}
};
let destination = match crate::config::agent_dir() {
Ok(dir) => dir.join("extensions"),
Err(error) => {
eprintln!("error: could not resolve the rpi config directory: {error}");
return 1;
}
};
if let Err(error) = std::fs::create_dir_all(&destination) {
eprintln!(
"error: could not create extension directory {}: {error}",
destination.display()
);
return 1;
}
for artifact in &artifacts {
let target = destination.join(artifact.file_name().unwrap_or_default());
if target.exists() && !options.force {
eprintln!(
"error: extension {} already exists; use --force to replace it",
target.display()
);
return 1;
}
if let Err(error) = std::fs::copy(artifact, &target) {
eprintln!(
"error: could not install {} to {}: {error}",
artifact.display(),
target.display()
);
return 1;
}
println!("installed {}", target.display());
}
let version = metadata
.packages
.iter()
.find(|candidate| candidate.name == options.package)
.map(|candidate| candidate.version.clone())
.unwrap_or_else(|| "0.0.0".to_string());
let record = InstalledNativePackage {
name: options.package.clone(),
version,
source: options
.path
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
artifacts: artifacts
.iter()
.filter_map(|path| {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.collect(),
};
if let Err(error) = record_native_package(&record) {
eprintln!("warning: extension installed but package metadata was not saved: {error}");
}
println!("rpi will load this extension on the next start.");
0
}
/// Remove a Rust-native extension installed by `rpi install`.
///
/// The install registry is authoritative for new installs. For metadata from
/// older rpi versions, dynamic libraries whose normalized file stem matches
/// the crate name are removed as a compatibility fallback.
pub fn uninstall(args: &[String]) -> i32 {
if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
print_uninstall_help();
return 0;
}
let name = match parse_uninstall_name(args) {
Ok(name) => name,
Err(error) => {
eprintln!("error: {error}");
print_uninstall_help();
return 2;
}
};
let agent = match crate::config::agent_dir() {
Ok(path) => path,
Err(error) => {
eprintln!("error: could not resolve the rpi config directory: {error}");
return 1;
}
};
let extension_dir = agent.join("extensions");
let records = installed_native_packages();
let had_record = records.iter().any(|record| record.name == name);
let wanted = normalize_name(&name);
let artifact_names: std::collections::HashSet<String> = records
.iter()
.filter(|record| record.name == name)
.flat_map(|record| record.artifacts.iter().cloned())
.collect();
let mut removed = 0usize;
if let Ok(entries) = std::fs::read_dir(&extension_dir) {
for entry in entries.flatten() {
let path = entry.path();
let is_artifact = artifact_names.contains(
&path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default(),
) || path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
.unwrap_or(false);
if is_artifact && is_dynamic_library(&path) {
match std::fs::remove_file(&path) {
Ok(()) => {
println!("removed {}", path.display());
removed += 1;
}
Err(error) => {
eprintln!("warning: could not remove {}: {error}", path.display())
}
}
}
}
}
let mut remaining: Vec<_> = records
.into_iter()
.filter(|record| record.name != name)
.collect();
if had_record {
remaining.sort_by(|left, right| left.name.cmp(&right.name));
if let Err(error) = write_native_packages(&remaining) {
eprintln!("error: extension files removed but metadata could not be saved: {error}");
return 1;
}
}
if removed == 0 && !had_record {
println!("Rust extension is not installed: {name}");
return 0;
}
println!("uninstalled Rust extension {name}");
0
}
fn parse_uninstall_name(args: &[String]) -> Result<String, String> {
let mut name = None;
for arg in args {
match arg.as_str() {
"--help" | "-h" => return Err("use `rpi uninstall --help` for usage".into()),
value if value.starts_with('-') => {
return Err(format!("unknown uninstall option `{value}`"))
}
value => {
if name.replace(value.to_string()).is_some() {
return Err("uninstall accepts exactly one crate name".into());
}
}
}
}
let name = name.ok_or_else(|| "missing crate name".to_string())?;
if !valid_package_name(&name) {
return Err(format!("invalid Cargo package name `{name}`"));
}
Ok(name)
}
fn write_native_packages(records: &[InstalledNativePackage]) -> Result<(), String> {
let path = crate::config::agent_dir()
.map_err(|error| error.to_string())?
.join(NATIVE_PACKAGES_FILE);
if records.is_empty() {
match std::fs::remove_file(&path) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.to_string()),
}
}
let parent = path
.parent()
.ok_or_else(|| "native package metadata has no parent".to_string())?;
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let data = serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?;
std::fs::write(path, data).map_err(|error| error.to_string())
}
/// Read the registry of Rust-native extensions installed by `rpi install`.
pub fn installed_native_packages() -> Vec<InstalledNativePackage> {
let Ok(path) = crate::config::agent_dir().map(|dir| dir.join(NATIVE_PACKAGES_FILE)) else {
return Vec::new();
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn record_native_package(record: &InstalledNativePackage) -> Result<(), String> {
let path = crate::config::agent_dir()
.map_err(|error| error.to_string())?
.join(NATIVE_PACKAGES_FILE);
let mut records = installed_native_packages();
if let Some(existing) = records.iter_mut().find(|item| item.name == record.name) {
*existing = record.clone();
} else {
records.push(record.clone());
}
records.sort_by(|left, right| left.name.cmp(&right.name));
let parent = path
.parent()
.ok_or_else(|| "native package metadata has no parent".to_string())?;
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let data = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
std::fs::write(path, data).map_err(|error| error.to_string())
}
fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
let mut package = None;
let mut version = None;
let mut path = None;
let mut locked = false;
let mut force = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => return Err(help_requested().to_string()),
"--locked" => locked = true,
"--force" | "-f" => force = true,
"--version" | "-V" => {
i += 1;
version = Some(value(args, i, "--version")?);
}
"--path" => {
i += 1;
path = Some(PathBuf::from(value(args, i, "--path")?));
}
value if value.starts_with('-') => {
return Err(format!("unknown install option `{value}`"));
}
value => {
if package.replace(value.to_string()).is_some() {
return Err("install accepts exactly one crate name".to_string());
}
}
}
i += 1;
}
let package = package.ok_or_else(|| "missing crate name".to_string())?;
if path.is_some() && version.is_some() {
return Err("--path and --version cannot be used together".to_string());
}
if !valid_package_name(&package) {
return Err(format!("invalid Cargo package name `{package}`"));
}
Ok(InstallOptions {
package,
version,
path,
locked,
force,
})
}
fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
args.get(index)
.filter(|value| !value.starts_with('-'))
.cloned()
.ok_or_else(|| format!("{flag} requires a value"))
}
fn valid_package_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
fn help_requested() -> &'static str {
"use `rpi install --help` for usage"
}
pub fn print_help() {
println!(
"Usage: rpi install <crate> [options]\n\nInstall an rpi Rust cdylib extension from crates.io.\n\nOptions:\n --version <version> Install a specific crates.io version\n --path <directory> Build a local extension crate\n --locked Require Cargo.lock to remain unchanged\n --force, -f Replace an existing installed extension\n --help, -h Show this help\n\nExamples:\n rpi install rpi-extension-example\n rpi install rpi-extension-example --version 0.1.0\n rpi install my-extension --path ../my-rpi-extension --force"
);
}
fn print_uninstall_help() {
println!(
"Usage: rpi uninstall <crate>\n\nRemove a Rust cdylib extension installed by `rpi install`.\n\nOptions:\n --help, -h Show this help\n\nExample:\n rpi uninstall rpi-extension-example"
);
}
fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
let source_dir = path
.parent()
.ok_or_else(|| "temporary workspace has no parent directory".to_string())?
.join("src");
std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
// Cargo requires the temporary root package to have a target even though
// rpi never builds it; the requested extension is built as a dependency.
std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
.map_err(|error| error.to_string())?;
let dependency = if let Some(local_path) = &options.path {
let absolute = if local_path.is_absolute() {
local_path.clone()
} else {
std::env::current_dir()
.map_err(|error| error.to_string())?
.join(local_path)
};
format!(
"rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
options.package,
absolute.display().to_string()
)
} else {
let version = options.version.as_deref().unwrap_or("*");
format!(
"rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
options.package, version
)
};
let contents = format!(
"[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
);
std::fs::write(path, contents).map_err(|error| error.to_string())
}
fn cargo_command(
subcommand: &str,
manifest: &Path,
options: &InstallOptions,
build: bool,
) -> Result<(), String> {
let mut command = Command::new("cargo");
command.arg(subcommand).arg("--manifest-path").arg(manifest);
if build {
command
.arg("--package")
.arg(&options.package)
.arg("--release")
.arg("--target-dir")
.arg(manifest.parent().unwrap().join("target"));
}
if options.locked {
command.arg("--locked");
}
let status = command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|error| format!("could not execute cargo: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo {subcommand} exited with {status}"))
}
}
fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
let mut command = Command::new("cargo");
command
.arg("metadata")
.arg("--format-version")
.arg("1")
.arg("--manifest-path")
.arg(manifest);
if options.locked {
command.arg("--locked");
}
let output = command
.output()
.map_err(|error| format!("could not execute cargo: {error}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
serde_json::from_slice(&output.stdout)
.map_err(|error| format!("invalid cargo metadata: {error}"))
}
fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
let mut artifacts = Vec::new();
for target in targets {
let wanted = normalize_name(&target.name);
let mut matches = Vec::new();
for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
let entries = std::fs::read_dir(&dir).map_err(|error| {
format!("could not inspect build output {}: {error}", dir.display())
})?;
for entry in entries.flatten() {
let path = entry.path();
if !is_dynamic_library(&path) {
continue;
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let normalized = normalize_name(stem.trim_start_matches("lib"));
if normalized == wanted {
matches.push(path);
}
}
}
matches.sort_by_key(|path| path.components().count());
let artifact = matches.into_iter().next().ok_or_else(|| {
format!(
"Cargo built `{}` but no cdylib artifact was found in {}",
target.name,
release_dir.display()
)
})?;
artifacts.push(artifact);
}
Ok(artifacts)
}
fn normalize_name(name: &str) -> String {
name.replace('-', "_").to_ascii_lowercase()
}
fn is_dynamic_library(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref(),
Some("dll" | "so" | "dylib" | "pyd")
)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
#[test]
fn parses_registry_package_and_options() {
let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
assert_eq!(parsed.package, "my-extension");
assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
assert!(parsed.force);
}
#[test]
fn parses_local_package() {
let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
}
#[test]
fn rejects_non_extension_options_and_invalid_names() {
assert!(parse_args(&args(&["my.extension"])).is_err());
assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
}
}