1use 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
43pub 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 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}