1use 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#[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 #[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
63pub 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
211pub 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
332pub 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 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}