1use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use serde::{Deserialize, Serialize};
11
12const REGISTRY: &str = "packages.json";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct ProjectPackage {
16 pub name: String,
17 pub version: String,
18 pub source: String,
19 pub cargo_package: String,
20 #[serde(default)]
21 pub artifacts: Vec<String>,
22}
23
24#[derive(Debug, Default, Serialize, Deserialize)]
25struct PackageRegistry {
26 #[serde(default)]
27 packages: Vec<ProjectPackage>,
28}
29
30pub fn run(args: &[String]) -> i32 {
31 match args.first().map(String::as_str) {
32 None | Some("--help") | Some("-h") => {
33 print_help();
34 0
35 }
36 Some("link") => link(&args[1..]),
37 Some("list") => list(&args[1..]),
38 Some("update") => update(&args[1..]),
39 Some("unlink") => unlink(&args[1..]),
40 Some(other) => {
41 eprintln!("error: unknown package command `{other}`");
42 print_help();
43 2
44 }
45 }
46}
47
48fn link(args: &[String]) -> i32 {
49 let mut source = None;
50 let mut cargo_package = None;
51 let mut name = None;
52 let mut release = true;
53 let mut force = false;
54 let mut locked = false;
55 let mut i = 0;
56 while i < args.len() {
57 match args[i].as_str() {
58 "--path" => {
59 i += 1;
60 source = value(args, i, "--path");
61 if source.is_none() {
62 return 2;
63 }
64 }
65 "--package" | "-P" => {
66 i += 1;
67 cargo_package = value(args, i, "--package");
68 if cargo_package.is_none() {
69 return 2;
70 }
71 }
72 "--name" => {
73 i += 1;
74 name = value(args, i, "--name");
75 if name.is_none() {
76 return 2;
77 }
78 }
79 "--debug" => release = false,
80 "--locked" => locked = true,
81 "--force" | "-f" => force = true,
82 "--help" | "-h" => {
83 print_link_help();
84 return 0;
85 }
86 value if value.starts_with('-') => {
87 eprintln!("error: unknown link option `{value}`");
88 return 2;
89 }
90 value => {
91 if name.replace(value.to_string()).is_some() {
92 eprintln!("error: link accepts one package name");
93 return 2;
94 }
95 }
96 }
97 i += 1;
98 }
99 let project_root = match std::env::current_dir() {
100 Ok(p) => p,
101 Err(e) => {
102 eprintln!("error: {e}");
103 return 1;
104 }
105 };
106 let source = source
107 .map(|p| project_root.join(p))
108 .unwrap_or_else(|| project_root.clone());
109 let metadata = match cargo_metadata(&source) {
110 Ok(m) => m,
111 Err(e) => {
112 eprintln!("error: {e}");
113 return 1;
114 }
115 };
116 let selected = match select_package(&metadata, cargo_package.as_deref(), &source) {
117 Ok(p) => p,
118 Err(e) => {
119 eprintln!("error: {e}");
120 return 1;
121 }
122 };
123 let package_name = name.unwrap_or_else(|| selected.name.clone());
124 if !valid_name(&package_name) {
125 eprintln!("error: invalid package name `{package_name}`");
126 return 2;
127 }
128 eprintln!("package: building {}...", selected.name);
129 let artifact = match build(&selected, release, locked) {
130 Ok(p) => p,
131 Err(e) => {
132 eprintln!("error: {e}");
133 return 1;
134 }
135 };
136 let root = project_root.join(".rpi/packages").join(&package_name);
137 let ext = root.join("extensions");
138 if let Err(e) = std::fs::create_dir_all(&ext) {
139 eprintln!("error: could not create {}: {e}", ext.display());
140 return 1;
141 }
142 let file = match artifact.file_name().and_then(|n| n.to_str()) {
143 Some(n) => n.to_string(),
144 None => {
145 eprintln!("error: invalid artifact name");
146 return 1;
147 }
148 };
149 let target = ext.join(&file);
150 if target.exists() && !force {
151 eprintln!("error: package already exists; use --force");
152 return 1;
153 }
154 let tmp = ext.join(format!(".{file}.tmp"));
155 if force {
156 if let Err(e) = std::fs::remove_file(&target) {
157 if e.kind() != std::io::ErrorKind::NotFound {
158 eprintln!("error: could not replace existing package artifact: {e}");
159 return 1;
160 }
161 }
162 }
163 if let Err(e) = std::fs::copy(&artifact, &tmp).and_then(|_| std::fs::rename(&tmp, &target)) {
164 let _ = std::fs::remove_file(&tmp);
165 eprintln!("error: could not activate package: {e}");
166 return 1;
167 }
168 let mut registry = match read_registry(&project_root) {
169 Ok(r) => r,
170 Err(e) => {
171 eprintln!("error: {e}");
172 return 1;
173 }
174 };
175 registry.packages.retain(|p| p.name != package_name);
176 registry.packages.push(ProjectPackage {
177 name: package_name.clone(),
178 version: selected.version,
179 source: source.display().to_string(),
180 cargo_package: selected.name,
181 artifacts: vec![file],
182 });
183 registry.packages.sort_by(|a, b| a.name.cmp(&b.name));
184 if let Err(e) = write_registry(&project_root, ®istry) {
185 eprintln!("error: {e}");
186 return 1;
187 }
188 println!("linked project package `{package_name}`; run `rpi` in this directory to load it");
189 0
190}
191
192fn list(args: &[String]) -> i32 {
193 if !args.is_empty() && args[0] != "--help" && args[0] != "-h" {
194 eprintln!("error: unexpected list argument");
195 return 2;
196 }
197 let cwd = match std::env::current_dir() {
198 Ok(p) => p,
199 Err(e) => {
200 eprintln!("error: {e}");
201 return 1;
202 }
203 };
204 match read_registry(&cwd) {
205 Ok(r) => {
206 if r.packages.is_empty() {
207 println!("no project packages");
208 } else {
209 for p in r.packages {
210 println!("{} {} [{}]", p.name, p.version, p.source);
211 }
212 }
213 0
214 }
215 Err(e) => {
216 eprintln!("error: {e}");
217 1
218 }
219 }
220}
221
222fn update(args: &[String]) -> i32 {
223 let mut name = None;
224 let mut link_args = Vec::new();
225 let mut i = 0;
226 while i < args.len() {
227 match args[i].as_str() {
228 "--debug" | "--locked" => link_args.push(args[i].clone()),
229 "--help" | "-h" => {
230 print_update_help();
231 return 0;
232 }
233 value if value.starts_with('-') => {
234 eprintln!("error: unknown update option `{value}`");
235 return 2;
236 }
237 value => {
238 if name.replace(value.to_string()).is_some() {
239 eprintln!("error: update accepts at most one package name");
240 return 2;
241 }
242 }
243 }
244 i += 1;
245 }
246
247 let cwd = match std::env::current_dir() {
248 Ok(p) => p,
249 Err(e) => {
250 eprintln!("error: {e}");
251 return 1;
252 }
253 };
254 let registry = match read_registry(&cwd) {
255 Ok(r) => r,
256 Err(e) => {
257 eprintln!("error: {e}");
258 return 1;
259 }
260 };
261 let packages = registry
262 .packages
263 .iter()
264 .filter(|package| {
265 name.as_deref()
266 .map_or(true, |wanted| wanted == package.name)
267 })
268 .cloned()
269 .collect::<Vec<_>>();
270
271 let mut status = 0;
272 for package in packages {
273 let package_name = package.name.clone();
274 eprintln!("package: updating project package `{package_name}`...");
275 let mut args = vec![
276 "--path".to_string(),
277 package.source,
278 "--package".to_string(),
279 package.cargo_package,
280 "--name".to_string(),
281 package.name,
282 "--force".to_string(),
283 ];
284 args.extend(link_args.iter().cloned());
285 if link(&args) != 0 {
286 status = 1;
287 }
288 }
289
290 let global_packages = match crate::install::installed_native_packages_strict() {
295 Ok(packages) => packages
296 .into_iter()
297 .filter(|package| {
298 package.source.is_none()
299 && name
300 .as_deref()
301 .map_or(true, |wanted| wanted == package.name)
302 })
303 .collect::<Vec<_>>(),
304 Err(error) => {
305 eprintln!("error: {error}");
306 return 1;
307 }
308 };
309 if let Some(wanted) = name.as_deref() {
310 let project_found = registry
311 .packages
312 .iter()
313 .any(|package| package.name == wanted);
314 let global_found = global_packages.iter().any(|package| package.name == wanted);
315 if !project_found && !global_found {
316 eprintln!("error: package `{wanted}` is not installed");
317 return 1;
318 }
319 }
320 for package in global_packages {
321 eprintln!("package: updating global package `{}`...", package.name);
322 if !update_global_package(&package.name, &link_args) {
323 status = 1;
324 }
325 }
326
327 if status == 0 && registry.packages.is_empty() && name.is_none() {
328 let has_global = crate::install::installed_native_packages()
329 .into_iter()
330 .any(|package| package.source.is_none());
331 if !has_global {
332 println!("no project or global packages to update");
333 }
334 }
335 status
336}
337
338fn update_global_package(name: &str, args: &[String]) -> bool {
339 let executable = match std::env::current_exe() {
340 Ok(path) => path,
341 Err(error) => {
342 eprintln!("error: could not resolve rpi executable: {error}");
343 return false;
344 }
345 };
346 let mut command = std::process::Command::new(executable);
347 command.args(["install", name, "--force"]);
348 if args.iter().any(|arg| arg == "--locked") {
349 command.arg("--locked");
350 }
351 match command.status() {
352 Ok(exit) if exit.success() => true,
353 Ok(exit) => {
354 eprintln!("error: updating global package `{name}` failed with {exit}");
355 false
356 }
357 Err(error) => {
358 eprintln!("error: could not run rpi install for `{name}`: {error}");
359 false
360 }
361 }
362}
363
364fn unlink(args: &[String]) -> i32 {
365 let Some(name) = args.first() else {
366 eprintln!("error: missing package name");
367 return 2;
368 };
369 if args.len() != 1 || !valid_name(name) {
370 eprintln!("error: invalid package name or arguments");
371 return 2;
372 }
373 let cwd = match std::env::current_dir() {
374 Ok(p) => p,
375 Err(e) => {
376 eprintln!("error: {e}");
377 return 1;
378 }
379 };
380 let mut registry = match read_registry(&cwd) {
381 Ok(r) => r,
382 Err(e) => {
383 eprintln!("error: {e}");
384 return 1;
385 }
386 };
387 if !registry.packages.iter().any(|p| p.name == *name) {
388 eprintln!("error: project package `{name}` is not installed");
389 return 1;
390 }
391 let dir = cwd.join(".rpi/packages").join(name);
392 if let Err(e) = std::fs::remove_dir_all(&dir) {
393 eprintln!("error: could not remove package: {e}");
394 return 1;
395 }
396 registry.packages.retain(|p| p.name != *name);
397 if let Err(e) = write_registry(&cwd, ®istry) {
398 eprintln!("error: {e}");
399 return 1;
400 }
401 println!("unlinked project package `{name}`");
402 0
403}
404
405#[derive(Debug, Clone)]
406struct CargoPackage {
407 name: String,
408 version: String,
409 manifest: PathBuf,
410 target: String,
411}
412#[derive(Deserialize)]
413struct Metadata {
414 packages: Vec<MetadataPackage>,
415}
416#[derive(Deserialize)]
417struct MetadataPackage {
418 name: String,
419 version: String,
420 manifest_path: PathBuf,
421 targets: Vec<Target>,
422}
423#[derive(Deserialize)]
424struct Target {
425 name: String,
426 crate_types: Vec<String>,
427}
428
429fn cargo_metadata(cwd: &Path) -> Result<Metadata, String> {
430 let out = Command::new("cargo")
431 .args(["metadata", "--format-version", "1", "--no-deps"])
432 .current_dir(cwd)
433 .stdin(Stdio::null())
434 .output()
435 .map_err(|e| format!("could not execute cargo metadata: {e}"))?;
436 if !out.status.success() {
437 return Err(format!(
438 "cargo metadata failed:\n{}",
439 String::from_utf8_lossy(&out.stderr).trim()
440 ));
441 }
442 serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid cargo metadata output: {e}"))
443}
444fn select_package(
445 m: &Metadata,
446 requested: Option<&str>,
447 cwd: &Path,
448) -> Result<CargoPackage, String> {
449 let mut found = Vec::new();
450 for p in &m.packages {
451 if requested.is_some_and(|n| n != p.name) {
452 continue;
453 }
454 for t in &p.targets {
455 if t.crate_types.iter().any(|k| k == "cdylib") {
456 found.push(CargoPackage {
457 name: p.name.clone(),
458 version: p.version.clone(),
459 manifest: p.manifest_path.clone(),
460 target: t.name.clone(),
461 });
462 }
463 }
464 }
465 if requested.is_none() {
466 let mut local = found
467 .iter()
468 .filter(|p| p.manifest.parent().is_some_and(|r| cwd.starts_with(r)))
469 .cloned()
470 .collect::<Vec<_>>();
471 if local.len() == 1 {
472 return Ok(local.remove(0));
473 }
474 }
475 match found.len() {
476 1 => Ok(found.remove(0)),
477 0 => Err("no cdylib package found; use --package to select one".into()),
478 _ => Err(format!(
479 "multiple cdylib packages found: {}; use --package",
480 found
481 .iter()
482 .map(|p| p.name.as_str())
483 .collect::<Vec<_>>()
484 .join(", ")
485 )),
486 }
487}
488fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
489 let mut c = Command::new("cargo");
490 c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
491 "--package",
492 &p.name,
493 "--message-format=json-render-diagnostics",
494 ]);
495 if release {
496 c.arg("--release");
497 }
498 if locked {
499 c.arg("--locked");
500 }
501 c.stdout(Stdio::piped()).stderr(Stdio::piped());
502 let out = c
503 .output()
504 .map_err(|e| format!("could not execute cargo build: {e}"))?;
505 let mut artifact = None;
506 for line in String::from_utf8_lossy(&out.stdout).lines() {
507 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
508 if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
509 && v.get("target")
510 .and_then(|v| v.get("name"))
511 .and_then(|v| v.as_str())
512 == Some(&p.target)
513 {
514 artifact = v
515 .get("filenames")
516 .and_then(|v| v.as_array())
517 .into_iter()
518 .flatten()
519 .filter_map(|v| v.as_str())
520 .map(PathBuf::from)
521 .find(|p| is_dynamic(p));
522 }
523 }
524 }
525 eprint!("{}", String::from_utf8_lossy(&out.stderr));
526 if !out.status.success() {
527 return Err(format!("cargo build exited with {}", out.status));
528 }
529 artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
530}
531fn is_dynamic(p: &Path) -> bool {
532 matches!(
533 p.extension()
534 .and_then(|x| x.to_str())
535 .map(|x| x.to_ascii_lowercase())
536 .as_deref(),
537 Some("dll" | "so" | "dylib")
538 )
539}
540fn valid_name(s: &str) -> bool {
541 !s.is_empty()
542 && s.bytes()
543 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
544}
545fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
546 match args.get(i) {
547 Some(v) if !v.starts_with('-') => Some(v.clone()),
548 _ => {
549 eprintln!("error: {flag} requires a value");
550 None
551 }
552 }
553}
554fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
555 let path = cwd.join(".rpi").join(REGISTRY);
556 match std::fs::read_to_string(path) {
557 Ok(s) => {
558 serde_json::from_str(&s).map_err(|e| format!("invalid project package registry: {e}"))
559 }
560 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PackageRegistry::default()),
561 Err(e) => Err(e.to_string()),
562 }
563}
564fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
565 let dir = cwd.join(".rpi");
566 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
567 let path = dir.join(REGISTRY);
568 let tmp = dir.join(".packages.json.tmp");
569 let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
570 std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
571 std::fs::rename(tmp, path).map_err(|e| e.to_string())
572}
573pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
574 match read_registry(cwd) {
575 Ok(registry) => registry
576 .packages
577 .into_iter()
578 .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
579 .collect(),
580 Err(error) => {
581 eprintln!("warning: could not load project package registry: {error}");
582 Vec::new()
583 }
584 }
585}
586pub fn print_help() {
587 println!("Usage: rpi package <command>\n\nCommands:\n link [name] [--path <dir>] [--package <name>] [--force] Build and link a project-local Rust extension\n list List project packages\n update [name] [--debug] [--locked] Update project and global installed packages\n unlink <name> Remove a project package");
588}
589fn print_link_help() {
590 println!("Usage: rpi package link [name] [options]\n\nOptions:\n --path <dir> Extension source directory (default: current directory)\n --package, -P <n> Select a Cargo cdylib package\n --debug Build debug artifacts instead of release\n --locked Pass --locked to Cargo\n --force, -f Replace an existing package");
591}
592fn print_update_help() {
593 println!("Usage: rpi package update [name] [options]\n\nOptions:\n --debug Build debug artifacts instead of release\n --locked Pass --locked to Cargo\n --help, -h Show this help\n\nWithout a name, all project and global installed packages are updated.");
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn package_names_reject_path_traversal() {
602 assert!(valid_name("review-tools"));
603 assert!(!valid_name("../review-tools"));
604 assert!(!valid_name("review/tools"));
605 assert!(!valid_name(""));
606 }
607
608 #[test]
609 fn update_help_is_available_without_a_registry() {
610 print_update_help();
611 }
612
613 #[test]
614 fn dynamic_library_extensions_are_platform_neutral() {
615 assert!(is_dynamic(Path::new("plugin.dll")));
616 assert!(is_dynamic(Path::new("libplugin.so")));
617 assert!(is_dynamic(Path::new("libplugin.dylib")));
618 assert!(!is_dynamic(Path::new("plugin.exe")));
619 }
620}