Skip to main content

rpi_cli/
install.rs

1//! `rpi install` — install a Rust `cdylib` extension from crates.io.
2//!
3//! Cargo's `cargo install` command is intended for binaries and does not copy
4//! dynamic-library targets. rpi extensions are cdylibs loaded by
5//! `rpi-extensions`, so this command creates a tiny temporary Cargo workspace,
6//! resolves the requested crate through Cargo, builds the dependency in
7//! release mode, and copies its cdylib into the same global directory scanned
8//! during normal startup.
9
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13use serde::Deserialize;
14
15const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
16
17#[derive(Debug, Clone)]
18struct InstallOptions {
19    package: String,
20    version: Option<String>,
21    path: Option<PathBuf>,
22    locked: bool,
23    force: bool,
24}
25
26#[derive(Debug, Deserialize)]
27struct CargoMetadata {
28    packages: Vec<CargoPackage>,
29}
30
31#[derive(Debug, Deserialize)]
32struct CargoPackage {
33    name: String,
34    targets: Vec<CargoTarget>,
35}
36
37#[derive(Debug, Deserialize)]
38struct CargoTarget {
39    name: String,
40    crate_types: Vec<String>,
41}
42
43/// Run `rpi install ...`. This is intentionally synchronous: it is a short
44/// lived package operation and keeping Cargo's build output attached to the
45/// user's terminal makes failures actionable.
46pub fn run(args: &[String]) -> i32 {
47    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
48        print_help();
49        return 0;
50    }
51    let options = match parse_args(args) {
52        Ok(options) => options,
53        Err(message) => {
54            eprintln!("error: {message}");
55            print_help();
56            return 2;
57        }
58    };
59
60    let temp = match tempfile::tempdir() {
61        Ok(temp) => temp,
62        Err(error) => {
63            eprintln!("error: could not create a temporary Cargo workspace: {error}");
64            return 1;
65        }
66    };
67    let manifest = temp.path().join("Cargo.toml");
68    if let Err(error) = write_manifest(&manifest, &options) {
69        eprintln!("error: could not prepare Cargo workspace: {error}");
70        return 1;
71    }
72
73    if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
74        eprintln!("error: could not resolve `{}`: {error}", options.package);
75        return 1;
76    }
77
78    let metadata = match cargo_metadata(&manifest, &options) {
79        Ok(metadata) => metadata,
80        Err(error) => {
81            eprintln!("error: could not inspect `{}`: {error}", options.package);
82            return 1;
83        }
84    };
85    let package = match metadata
86        .packages
87        .iter()
88        .find(|package| package.name == options.package)
89    {
90        Some(package) => package,
91        None => {
92            eprintln!(
93                "error: Cargo did not resolve a package named `{}`",
94                options.package
95            );
96            return 1;
97        }
98    };
99    let cdylib_targets: Vec<&CargoTarget> = package
100        .targets
101        .iter()
102        .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
103        .collect();
104    if cdylib_targets.is_empty() {
105        eprintln!(
106            "error: `{}` is not an rpi extension crate; it has no `cdylib` target",
107            options.package
108        );
109        eprintln!(
110            "hint: the crate must declare `crate-type = [\"cdylib\"]` and export `rpi_plugin_register`"
111        );
112        return 1;
113    }
114
115    if let Err(error) = cargo_command("build", &manifest, &options, true) {
116        eprintln!("error: failed to build `{}`: {error}", options.package);
117        return 1;
118    }
119
120    let artifact_dir = temp.path().join("target").join("release");
121    let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
122        Ok(artifacts) => artifacts,
123        Err(error) => {
124            eprintln!("error: {error}");
125            return 1;
126        }
127    };
128
129    let destination = match crate::config::agent_dir() {
130        Ok(dir) => dir.join("extensions"),
131        Err(error) => {
132            eprintln!("error: could not resolve the rpi config directory: {error}");
133            return 1;
134        }
135    };
136    if let Err(error) = std::fs::create_dir_all(&destination) {
137        eprintln!(
138            "error: could not create extension directory {}: {error}",
139            destination.display()
140        );
141        return 1;
142    }
143
144    for artifact in &artifacts {
145        let target = destination.join(artifact.file_name().unwrap_or_default());
146        if target.exists() && !options.force {
147            eprintln!(
148                "error: extension {} already exists; use --force to replace it",
149                target.display()
150            );
151            return 1;
152        }
153        if let Err(error) = std::fs::copy(artifact, &target) {
154            eprintln!(
155                "error: could not install {} to {}: {error}",
156                artifact.display(),
157                target.display()
158            );
159            return 1;
160        }
161        println!("installed {}", target.display());
162    }
163    println!("rpi will load this extension on the next start.");
164    0
165}
166
167fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
168    let mut package = None;
169    let mut version = None;
170    let mut path = None;
171    let mut locked = false;
172    let mut force = false;
173    let mut i = 0;
174    while i < args.len() {
175        match args[i].as_str() {
176            "--help" | "-h" => return Err(help_requested().to_string()),
177            "--locked" => locked = true,
178            "--force" | "-f" => force = true,
179            "--version" | "-V" => {
180                i += 1;
181                version = Some(value(args, i, "--version")?);
182            }
183            "--path" => {
184                i += 1;
185                path = Some(PathBuf::from(value(args, i, "--path")?));
186            }
187            value if value.starts_with('-') => {
188                return Err(format!("unknown install option `{value}`"));
189            }
190            value => {
191                if package.replace(value.to_string()).is_some() {
192                    return Err("install accepts exactly one crate name".to_string());
193                }
194            }
195        }
196        i += 1;
197    }
198    let package = package.ok_or_else(|| "missing crate name".to_string())?;
199    if path.is_some() && version.is_some() {
200        return Err("--path and --version cannot be used together".to_string());
201    }
202    if !valid_package_name(&package) {
203        return Err(format!("invalid Cargo package name `{package}`"));
204    }
205    Ok(InstallOptions {
206        package,
207        version,
208        path,
209        locked,
210        force,
211    })
212}
213
214fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
215    args.get(index)
216        .filter(|value| !value.starts_with('-'))
217        .cloned()
218        .ok_or_else(|| format!("{flag} requires a value"))
219}
220
221fn valid_package_name(name: &str) -> bool {
222    !name.is_empty()
223        && name
224            .bytes()
225            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
226}
227
228fn help_requested() -> &'static str {
229    "use `rpi install --help` for usage"
230}
231
232pub fn print_help() {
233    println!(
234        "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"
235    );
236}
237
238fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
239    let source_dir = path
240        .parent()
241        .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
242        .join("src");
243    std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
244    // Cargo requires the temporary root package to have a target even though
245    // rpi never builds it; the requested extension is built as a dependency.
246    std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
247        .map_err(|error| error.to_string())?;
248    let dependency = if let Some(local_path) = &options.path {
249        let absolute = if local_path.is_absolute() {
250            local_path.clone()
251        } else {
252            std::env::current_dir()
253                .map_err(|error| error.to_string())?
254                .join(local_path)
255        };
256        format!(
257            "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
258            options.package,
259            absolute.display().to_string()
260        )
261    } else {
262        let version = options.version.as_deref().unwrap_or("*");
263        format!(
264            "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
265            options.package, version
266        )
267    };
268    let contents = format!(
269        "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
270    );
271    std::fs::write(path, contents).map_err(|error| error.to_string())
272}
273
274fn cargo_command(
275    subcommand: &str,
276    manifest: &Path,
277    options: &InstallOptions,
278    build: bool,
279) -> Result<(), String> {
280    let mut command = Command::new("cargo");
281    command.arg(subcommand).arg("--manifest-path").arg(manifest);
282    if build {
283        command
284            .arg("--package")
285            .arg(&options.package)
286            .arg("--release")
287            .arg("--target-dir")
288            .arg(manifest.parent().unwrap().join("target"));
289    }
290    if options.locked {
291        command.arg("--locked");
292    }
293    let status = command
294        .stdin(Stdio::inherit())
295        .stdout(Stdio::inherit())
296        .stderr(Stdio::inherit())
297        .status()
298        .map_err(|error| format!("could not execute cargo: {error}"))?;
299    if status.success() {
300        Ok(())
301    } else {
302        Err(format!("cargo {subcommand} exited with {status}"))
303    }
304}
305
306fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
307    let mut command = Command::new("cargo");
308    command
309        .arg("metadata")
310        .arg("--format-version")
311        .arg("1")
312        .arg("--manifest-path")
313        .arg(manifest);
314    if options.locked {
315        command.arg("--locked");
316    }
317    let output = command
318        .output()
319        .map_err(|error| format!("could not execute cargo: {error}"))?;
320    if !output.status.success() {
321        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
322    }
323    serde_json::from_slice(&output.stdout)
324        .map_err(|error| format!("invalid cargo metadata: {error}"))
325}
326
327fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
328    let mut artifacts = Vec::new();
329    for target in targets {
330        let wanted = normalize_name(&target.name);
331        let mut matches = Vec::new();
332        for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
333            let entries = std::fs::read_dir(&dir).map_err(|error| {
334                format!("could not inspect build output {}: {error}", dir.display())
335            })?;
336            for entry in entries.flatten() {
337                let path = entry.path();
338                if !is_dynamic_library(&path) {
339                    continue;
340                }
341                let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
342                    continue;
343                };
344                let normalized = normalize_name(stem.trim_start_matches("lib"));
345                if normalized == wanted {
346                    matches.push(path);
347                }
348            }
349        }
350        matches.sort_by_key(|path| path.components().count());
351        let artifact = matches.into_iter().next().ok_or_else(|| {
352            format!(
353                "Cargo built `{}` but no cdylib artifact was found in {}",
354                target.name,
355                release_dir.display()
356            )
357        })?;
358        artifacts.push(artifact);
359    }
360    Ok(artifacts)
361}
362
363fn normalize_name(name: &str) -> String {
364    name.replace('-', "_").to_ascii_lowercase()
365}
366
367fn is_dynamic_library(path: &Path) -> bool {
368    matches!(
369        path.extension()
370            .and_then(|extension| extension.to_str())
371            .map(|extension| extension.to_ascii_lowercase())
372            .as_deref(),
373        Some("dll" | "so" | "dylib" | "pyd")
374    )
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn args(values: &[&str]) -> Vec<String> {
382        values.iter().map(|value| value.to_string()).collect()
383    }
384
385    #[test]
386    fn parses_registry_package_and_options() {
387        let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
388        assert_eq!(parsed.package, "my-extension");
389        assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
390        assert!(parsed.force);
391    }
392
393    #[test]
394    fn parses_local_package() {
395        let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
396        assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
397    }
398
399    #[test]
400    fn rejects_non_extension_options_and_invalid_names() {
401        assert!(parse_args(&args(&["my.extension"])).is_err());
402        assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
403        assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
404    }
405}