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, Serialize};
14
15const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
16const NATIVE_PACKAGES_FILE: &str = "native-packages.json";
17
18/// A Rust-native extension installed through `rpi install`.
19///
20/// `source` is absent for crates.io packages and contains the local source
21/// path for `--path` installs. Local development crates are intentionally not
22/// eligible for automatic registry updates.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct InstalledNativePackage {
25    pub name: String,
26    pub version: String,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub source: Option<String>,
29    /// Dynamic-library file names copied into the extension store. Older
30    /// metadata files omit this field; uninstall falls back to crate-name
31    /// matching for those records.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub artifacts: Vec<String>,
34}
35
36#[derive(Debug, Clone)]
37struct InstallOptions {
38    package: String,
39    version: Option<String>,
40    path: Option<PathBuf>,
41    locked: bool,
42    force: bool,
43}
44
45#[derive(Debug, Deserialize)]
46struct CargoMetadata {
47    packages: Vec<CargoPackage>,
48}
49
50#[derive(Debug, Deserialize)]
51struct CargoPackage {
52    name: String,
53    version: String,
54    targets: Vec<CargoTarget>,
55}
56
57#[derive(Debug, Deserialize)]
58struct CargoTarget {
59    name: String,
60    crate_types: Vec<String>,
61}
62
63/// Run `rpi install ...`. This is intentionally synchronous: it is a short
64/// lived package operation and keeping Cargo's build output attached to the
65/// user's terminal makes failures actionable.
66pub fn run(args: &[String]) -> i32 {
67    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
68        print_help();
69        return 0;
70    }
71    let options = match parse_args(args) {
72        Ok(options) => options,
73        Err(message) => {
74            eprintln!("error: {message}");
75            print_help();
76            return 2;
77        }
78    };
79
80    let temp = match tempfile::tempdir() {
81        Ok(temp) => temp,
82        Err(error) => {
83            eprintln!("error: could not create a temporary Cargo workspace: {error}");
84            return 1;
85        }
86    };
87    let manifest = temp.path().join("Cargo.toml");
88    if let Err(error) = write_manifest(&manifest, &options) {
89        eprintln!("error: could not prepare Cargo workspace: {error}");
90        return 1;
91    }
92
93    if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
94        eprintln!("error: could not resolve `{}`: {error}", options.package);
95        return 1;
96    }
97
98    let metadata = match cargo_metadata(&manifest, &options) {
99        Ok(metadata) => metadata,
100        Err(error) => {
101            eprintln!("error: could not inspect `{}`: {error}", options.package);
102            return 1;
103        }
104    };
105    let package = match metadata
106        .packages
107        .iter()
108        .find(|package| package.name == options.package)
109    {
110        Some(package) => package,
111        None => {
112            eprintln!(
113                "error: Cargo did not resolve a package named `{}`",
114                options.package
115            );
116            return 1;
117        }
118    };
119    let cdylib_targets: Vec<&CargoTarget> = package
120        .targets
121        .iter()
122        .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
123        .collect();
124    if cdylib_targets.is_empty() {
125        eprintln!(
126            "error: `{}` is not an rpi extension crate; it has no `cdylib` target",
127            options.package
128        );
129        eprintln!(
130            "hint: the crate must declare `crate-type = [\"cdylib\"]` and export `rpi_plugin_register`"
131        );
132        return 1;
133    }
134
135    if let Err(error) = cargo_command("build", &manifest, &options, true) {
136        eprintln!("error: failed to build `{}`: {error}", options.package);
137        return 1;
138    }
139
140    let artifact_dir = temp.path().join("target").join("release");
141    let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
142        Ok(artifacts) => artifacts,
143        Err(error) => {
144            eprintln!("error: {error}");
145            return 1;
146        }
147    };
148
149    let destination = match crate::config::agent_dir() {
150        Ok(dir) => dir.join("extensions"),
151        Err(error) => {
152            eprintln!("error: could not resolve the rpi config directory: {error}");
153            return 1;
154        }
155    };
156    if let Err(error) = std::fs::create_dir_all(&destination) {
157        eprintln!(
158            "error: could not create extension directory {}: {error}",
159            destination.display()
160        );
161        return 1;
162    }
163
164    for artifact in &artifacts {
165        let target = destination.join(artifact.file_name().unwrap_or_default());
166        if target.exists() && !options.force {
167            eprintln!(
168                "error: extension {} already exists; use --force to replace it",
169                target.display()
170            );
171            return 1;
172        }
173        if let Err(error) = std::fs::copy(artifact, &target) {
174            eprintln!(
175                "error: could not install {} to {}: {error}",
176                artifact.display(),
177                target.display()
178            );
179            return 1;
180        }
181        println!("installed {}", target.display());
182    }
183    let version = metadata
184        .packages
185        .iter()
186        .find(|candidate| candidate.name == options.package)
187        .map(|candidate| candidate.version.clone())
188        .unwrap_or_else(|| "0.0.0".to_string());
189    let record = InstalledNativePackage {
190        name: options.package.clone(),
191        version,
192        source: options
193            .path
194            .as_ref()
195            .map(|path| path.to_string_lossy().into_owned()),
196        artifacts: artifacts
197            .iter()
198            .filter_map(|path| {
199                path.file_name()
200                    .map(|name| name.to_string_lossy().into_owned())
201            })
202            .collect(),
203    };
204    if let Err(error) = record_native_package(&record) {
205        eprintln!("warning: extension installed but package metadata was not saved: {error}");
206    }
207    println!("rpi will load this extension on the next start.");
208    0
209}
210
211/// Remove a Rust-native extension installed by `rpi install`.
212///
213/// The install registry is authoritative for new installs. For metadata from
214/// older rpi versions, dynamic libraries whose normalized file stem matches
215/// the crate name are removed as a compatibility fallback.
216pub fn uninstall(args: &[String]) -> i32 {
217    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
218        print_uninstall_help();
219        return 0;
220    }
221    let name = match parse_uninstall_name(args) {
222        Ok(name) => name,
223        Err(error) => {
224            eprintln!("error: {error}");
225            print_uninstall_help();
226            return 2;
227        }
228    };
229    let agent = match crate::config::agent_dir() {
230        Ok(path) => path,
231        Err(error) => {
232            eprintln!("error: could not resolve the rpi config directory: {error}");
233            return 1;
234        }
235    };
236    let extension_dir = agent.join("extensions");
237    let records = installed_native_packages();
238    let had_record = records.iter().any(|record| record.name == name);
239    let wanted = normalize_name(&name);
240    let artifact_names: std::collections::HashSet<String> = records
241        .iter()
242        .filter(|record| record.name == name)
243        .flat_map(|record| record.artifacts.iter().cloned())
244        .collect();
245    let mut removed = 0usize;
246    if let Ok(entries) = std::fs::read_dir(&extension_dir) {
247        for entry in entries.flatten() {
248            let path = entry.path();
249            let is_artifact = artifact_names.contains(
250                &path
251                    .file_name()
252                    .map(|name| name.to_string_lossy().into_owned())
253                    .unwrap_or_default(),
254            ) || path
255                .file_stem()
256                .and_then(|stem| stem.to_str())
257                .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
258                .unwrap_or(false);
259            if is_artifact && is_dynamic_library(&path) {
260                match std::fs::remove_file(&path) {
261                    Ok(()) => {
262                        println!("removed {}", path.display());
263                        removed += 1;
264                    }
265                    Err(error) => {
266                        eprintln!("warning: could not remove {}: {error}", path.display())
267                    }
268                }
269            }
270        }
271    }
272    let mut remaining: Vec<_> = records
273        .into_iter()
274        .filter(|record| record.name != name)
275        .collect();
276    if had_record {
277        remaining.sort_by(|left, right| left.name.cmp(&right.name));
278        if let Err(error) = write_native_packages(&remaining) {
279            eprintln!("error: extension files removed but metadata could not be saved: {error}");
280            return 1;
281        }
282    }
283    if removed == 0 && !had_record {
284        println!("Rust extension is not installed: {name}");
285        return 0;
286    }
287    println!("uninstalled Rust extension {name}");
288    0
289}
290
291fn parse_uninstall_name(args: &[String]) -> Result<String, String> {
292    let mut name = None;
293    for arg in args {
294        match arg.as_str() {
295            "--help" | "-h" => return Err("use `rpi uninstall --help` for usage".into()),
296            value if value.starts_with('-') => {
297                return Err(format!("unknown uninstall option `{value}`"))
298            }
299            value => {
300                if name.replace(value.to_string()).is_some() {
301                    return Err("uninstall accepts exactly one crate name".into());
302                }
303            }
304        }
305    }
306    let name = name.ok_or_else(|| "missing crate name".to_string())?;
307    if !valid_package_name(&name) {
308        return Err(format!("invalid Cargo package name `{name}`"));
309    }
310    Ok(name)
311}
312
313fn write_native_packages(records: &[InstalledNativePackage]) -> Result<(), String> {
314    let path = crate::config::agent_dir()
315        .map_err(|error| error.to_string())?
316        .join(NATIVE_PACKAGES_FILE);
317    if records.is_empty() {
318        match std::fs::remove_file(&path) {
319            Ok(()) => return Ok(()),
320            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
321            Err(error) => return Err(error.to_string()),
322        }
323    }
324    let parent = path
325        .parent()
326        .ok_or_else(|| "native package metadata has no parent".to_string())?;
327    std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
328    let data = serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?;
329    std::fs::write(path, data).map_err(|error| error.to_string())
330}
331
332/// Read the registry of Rust-native extensions installed by `rpi install`.
333pub fn installed_native_packages() -> Vec<InstalledNativePackage> {
334    let Ok(path) = crate::config::agent_dir().map(|dir| dir.join(NATIVE_PACKAGES_FILE)) else {
335        return Vec::new();
336    };
337    std::fs::read_to_string(path)
338        .ok()
339        .and_then(|text| serde_json::from_str(&text).ok())
340        .unwrap_or_default()
341}
342
343fn record_native_package(record: &InstalledNativePackage) -> Result<(), String> {
344    let path = crate::config::agent_dir()
345        .map_err(|error| error.to_string())?
346        .join(NATIVE_PACKAGES_FILE);
347    let mut records = installed_native_packages();
348    if let Some(existing) = records.iter_mut().find(|item| item.name == record.name) {
349        *existing = record.clone();
350    } else {
351        records.push(record.clone());
352    }
353    records.sort_by(|left, right| left.name.cmp(&right.name));
354    let parent = path
355        .parent()
356        .ok_or_else(|| "native package metadata has no parent".to_string())?;
357    std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
358    let data = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
359    std::fs::write(path, data).map_err(|error| error.to_string())
360}
361
362fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
363    let mut package = None;
364    let mut version = None;
365    let mut path = None;
366    let mut locked = false;
367    let mut force = false;
368    let mut i = 0;
369    while i < args.len() {
370        match args[i].as_str() {
371            "--help" | "-h" => return Err(help_requested().to_string()),
372            "--locked" => locked = true,
373            "--force" | "-f" => force = true,
374            "--version" | "-V" => {
375                i += 1;
376                version = Some(value(args, i, "--version")?);
377            }
378            "--path" => {
379                i += 1;
380                path = Some(PathBuf::from(value(args, i, "--path")?));
381            }
382            value if value.starts_with('-') => {
383                return Err(format!("unknown install option `{value}`"));
384            }
385            value => {
386                if package.replace(value.to_string()).is_some() {
387                    return Err("install accepts exactly one crate name".to_string());
388                }
389            }
390        }
391        i += 1;
392    }
393    let package = package.ok_or_else(|| "missing crate name".to_string())?;
394    if path.is_some() && version.is_some() {
395        return Err("--path and --version cannot be used together".to_string());
396    }
397    if !valid_package_name(&package) {
398        return Err(format!("invalid Cargo package name `{package}`"));
399    }
400    Ok(InstallOptions {
401        package,
402        version,
403        path,
404        locked,
405        force,
406    })
407}
408
409fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
410    args.get(index)
411        .filter(|value| !value.starts_with('-'))
412        .cloned()
413        .ok_or_else(|| format!("{flag} requires a value"))
414}
415
416fn valid_package_name(name: &str) -> bool {
417    !name.is_empty()
418        && name
419            .bytes()
420            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
421}
422
423fn help_requested() -> &'static str {
424    "use `rpi install --help` for usage"
425}
426
427pub fn print_help() {
428    println!(
429        "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"
430    );
431}
432
433fn print_uninstall_help() {
434    println!(
435        "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"
436    );
437}
438
439fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
440    let source_dir = path
441        .parent()
442        .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
443        .join("src");
444    std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
445    // Cargo requires the temporary root package to have a target even though
446    // rpi never builds it; the requested extension is built as a dependency.
447    std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
448        .map_err(|error| error.to_string())?;
449    let dependency = if let Some(local_path) = &options.path {
450        let absolute = if local_path.is_absolute() {
451            local_path.clone()
452        } else {
453            std::env::current_dir()
454                .map_err(|error| error.to_string())?
455                .join(local_path)
456        };
457        format!(
458            "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
459            options.package,
460            absolute.display().to_string()
461        )
462    } else {
463        let version = options.version.as_deref().unwrap_or("*");
464        format!(
465            "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
466            options.package, version
467        )
468    };
469    let contents = format!(
470        "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
471    );
472    std::fs::write(path, contents).map_err(|error| error.to_string())
473}
474
475fn cargo_command(
476    subcommand: &str,
477    manifest: &Path,
478    options: &InstallOptions,
479    build: bool,
480) -> Result<(), String> {
481    let mut command = Command::new("cargo");
482    command.arg(subcommand).arg("--manifest-path").arg(manifest);
483    if build {
484        command
485            .arg("--package")
486            .arg(&options.package)
487            .arg("--release")
488            .arg("--target-dir")
489            .arg(manifest.parent().unwrap().join("target"));
490    }
491    if options.locked {
492        command.arg("--locked");
493    }
494    let status = command
495        .stdin(Stdio::inherit())
496        .stdout(Stdio::inherit())
497        .stderr(Stdio::inherit())
498        .status()
499        .map_err(|error| format!("could not execute cargo: {error}"))?;
500    if status.success() {
501        Ok(())
502    } else {
503        Err(format!("cargo {subcommand} exited with {status}"))
504    }
505}
506
507fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
508    let mut command = Command::new("cargo");
509    command
510        .arg("metadata")
511        .arg("--format-version")
512        .arg("1")
513        .arg("--manifest-path")
514        .arg(manifest);
515    if options.locked {
516        command.arg("--locked");
517    }
518    let output = command
519        .output()
520        .map_err(|error| format!("could not execute cargo: {error}"))?;
521    if !output.status.success() {
522        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
523    }
524    serde_json::from_slice(&output.stdout)
525        .map_err(|error| format!("invalid cargo metadata: {error}"))
526}
527
528fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
529    let mut artifacts = Vec::new();
530    for target in targets {
531        let wanted = normalize_name(&target.name);
532        let mut matches = Vec::new();
533        for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
534            let entries = std::fs::read_dir(&dir).map_err(|error| {
535                format!("could not inspect build output {}: {error}", dir.display())
536            })?;
537            for entry in entries.flatten() {
538                let path = entry.path();
539                if !is_dynamic_library(&path) {
540                    continue;
541                }
542                let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
543                    continue;
544                };
545                let normalized = normalize_name(stem.trim_start_matches("lib"));
546                if normalized == wanted {
547                    matches.push(path);
548                }
549            }
550        }
551        matches.sort_by_key(|path| path.components().count());
552        let artifact = matches.into_iter().next().ok_or_else(|| {
553            format!(
554                "Cargo built `{}` but no cdylib artifact was found in {}",
555                target.name,
556                release_dir.display()
557            )
558        })?;
559        artifacts.push(artifact);
560    }
561    Ok(artifacts)
562}
563
564fn normalize_name(name: &str) -> String {
565    name.replace('-', "_").to_ascii_lowercase()
566}
567
568fn is_dynamic_library(path: &Path) -> bool {
569    matches!(
570        path.extension()
571            .and_then(|extension| extension.to_str())
572            .map(|extension| extension.to_ascii_lowercase())
573            .as_deref(),
574        Some("dll" | "so" | "dylib" | "pyd")
575    )
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    fn args(values: &[&str]) -> Vec<String> {
583        values.iter().map(|value| value.to_string()).collect()
584    }
585
586    #[test]
587    fn parses_registry_package_and_options() {
588        let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
589        assert_eq!(parsed.package, "my-extension");
590        assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
591        assert!(parsed.force);
592    }
593
594    #[test]
595    fn parses_local_package() {
596        let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
597        assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
598    }
599
600    #[test]
601    fn rejects_non_extension_options_and_invalid_names() {
602        assert!(parse_args(&args(&["my.extension"])).is_err());
603        assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
604        assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
605    }
606}