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)]
15#[serde(deny_unknown_fields)]
16pub struct ProjectPackage {
17 pub name: String,
18 pub version: String,
19 pub source: String,
20 pub cargo_package: String,
21 #[serde(default)]
22 pub artifacts: Vec<String>,
23}
24
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct PackageRegistry {
27 #[serde(default)]
28 packages: Vec<ProjectPackage>,
29}
30
31pub fn run_extension(args: &[String]) -> i32 {
33 match args.first().map(String::as_str) {
34 None | Some("--help") | Some("-h") => {
35 print_extension_help();
36 0
37 }
38 Some("update") => update(&args[1..]),
39 Some(other) => {
40 eprintln!("error: unknown extension command `{other}`");
41 print_extension_help();
42 2
43 }
44 }
45}
46
47pub fn run(args: &[String]) -> i32 {
48 match args.first().map(String::as_str) {
49 None | Some("--help") | Some("-h") => {
50 print_help();
51 0
52 }
53 Some("link") => link(&args[1..]),
54 Some("list") => list(&args[1..]),
55 Some("update") => update(&args[1..]),
56 Some("unlink") => unlink(&args[1..]),
57 Some(other) => {
58 eprintln!("error: unknown package command `{other}`");
59 print_help();
60 2
61 }
62 }
63}
64
65fn link(args: &[String]) -> i32 {
66 let mut source = None;
67 let mut cargo_package = None;
68 let mut name = None;
69 let mut release = true;
70 let mut force = false;
71 let mut locked = false;
72 let mut i = 0;
73 while i < args.len() {
74 match args[i].as_str() {
75 "--path" => {
76 i += 1;
77 source = value(args, i, "--path");
78 if source.is_none() {
79 return 2;
80 }
81 }
82 "--package" | "-P" => {
83 i += 1;
84 cargo_package = value(args, i, "--package");
85 if cargo_package.is_none() {
86 return 2;
87 }
88 }
89 "--name" => {
90 i += 1;
91 name = value(args, i, "--name");
92 if name.is_none() {
93 return 2;
94 }
95 }
96 "--debug" => release = false,
97 "--locked" => locked = true,
98 "--force" | "-f" => force = true,
99 "--help" | "-h" => {
100 print_link_help();
101 return 0;
102 }
103 value if value.starts_with('-') => {
104 eprintln!("error: unknown link option `{value}`");
105 return 2;
106 }
107 value => {
108 if name.replace(value.to_string()).is_some() {
109 eprintln!("error: link accepts one package name");
110 return 2;
111 }
112 }
113 }
114 i += 1;
115 }
116 let project_root = match std::env::current_dir() {
117 Ok(p) => p,
118 Err(e) => {
119 eprintln!("error: {e}");
120 return 1;
121 }
122 };
123 let native_root = project_root.join(".rpi");
124 let _lease = match crate::resource_management::mutation_lease(&native_root) {
125 Ok(lease) => lease,
126 Err(error) => {
127 eprintln!("error: {error}");
128 return 1;
129 }
130 };
131 if let Err(error) = read_registry(&project_root) {
132 eprintln!("error: {error}");
133 return 1;
134 }
135 if let Some(name) = &name {
137 if let Err(error) =
138 crate::resource_management::legacy_unowned(&native_root, "package", name)
139 {
140 eprintln!("error: {error}");
141 return 1;
142 }
143 }
144 let source = source
145 .map(|p| project_root.join(p))
146 .unwrap_or_else(|| project_root.clone());
147 let metadata = match cargo_metadata(&source) {
148 Ok(m) => m,
149 Err(e) => {
150 eprintln!("error: {e}");
151 return 1;
152 }
153 };
154 let selected = match select_package(&metadata, cargo_package.as_deref(), &source) {
155 Ok(p) => p,
156 Err(e) => {
157 eprintln!("error: {e}");
158 return 1;
159 }
160 };
161 let package_name = name.unwrap_or_else(|| selected.name.clone());
162 if !valid_name(&package_name) {
163 eprintln!("error: invalid package name `{package_name}`");
164 return 2;
165 }
166 if let Err(error) =
167 crate::resource_management::legacy_unowned(&native_root, "package", &package_name)
168 {
169 eprintln!("error: {error}");
170 return 1;
171 }
172 eprintln!("package: building {}...", selected.name);
173 let artifact = match build(&selected, release, locked) {
174 Ok(p) => p,
175 Err(e) => {
176 eprintln!("error: {e}");
177 return 1;
178 }
179 };
180 let root = project_root.join(".rpi/packages").join(&package_name);
181 let ext = root.join("extensions");
182 let file = match artifact.file_name().and_then(|n| n.to_str()) {
183 Some(n) => n.to_string(),
184 None => {
185 eprintln!("error: invalid artifact name");
186 return 1;
187 }
188 };
189 let target = ext.join(&file);
190 let tmp = ext.join(format!(".{file}.tmp"));
191 if let Err(error) =
194 crate::resource_management::legacy_unowned(&native_root, "package", &package_name).and_then(
195 |_| {
196 crate::resource_management::legacy_unowned_targets(
197 &native_root,
198 &[target.clone(), tmp.clone()],
199 )
200 },
201 )
202 {
203 eprintln!("error: {error}");
204 return 1;
205 }
206 if target.exists() && !force {
207 eprintln!("error: package already exists; use --force");
208 return 1;
209 }
210 if let Err(e) = std::fs::create_dir_all(&ext) {
211 eprintln!("error: could not create {}: {e}", ext.display());
212 return 1;
213 }
214 let mut staged = match std::fs::OpenOptions::new()
217 .write(true)
218 .create_new(true)
219 .open(&tmp)
220 {
221 Ok(file) => file,
222 Err(error) => {
223 eprintln!("error: could not stage package artifact: {error}");
224 return 1;
225 }
226 };
227 let copied = std::fs::File::open(&artifact)
228 .and_then(|mut input| std::io::copy(&mut input, &mut staged))
229 .and_then(|_| staged.sync_all());
230 drop(staged);
231 if let Err(error) = copied {
232 let _ = std::fs::remove_file(&tmp);
233 eprintln!("error: could not stage package artifact: {error}");
234 return 1;
235 }
236 if force {
237 if let Err(e) = std::fs::remove_file(&target) {
238 if e.kind() != std::io::ErrorKind::NotFound {
239 let _ = std::fs::remove_file(&tmp);
240 eprintln!("error: could not replace existing package artifact: {e}");
241 return 1;
242 }
243 }
244 }
245 if let Err(e) = std::fs::rename(&tmp, &target) {
246 let _ = std::fs::remove_file(&tmp);
247 eprintln!("error: could not activate package: {e}");
248 return 1;
249 }
250 let mut registry = match read_registry(&project_root) {
251 Ok(r) => r,
252 Err(e) => {
253 eprintln!("error: {e}");
254 return 1;
255 }
256 };
257 registry.packages.retain(|p| p.name != package_name);
258 registry.packages.push(ProjectPackage {
259 name: package_name.clone(),
260 version: selected.version,
261 source: source.display().to_string(),
262 cargo_package: selected.name,
263 artifacts: vec![file],
264 });
265 registry.packages.sort_by(|a, b| a.name.cmp(&b.name));
266 if let Err(e) = write_registry(&project_root, ®istry) {
267 eprintln!("error: {e}");
268 return 1;
269 }
270 println!("linked project package `{package_name}`; run `rpi` in this directory to load it");
271 0
272}
273
274fn list(args: &[String]) -> i32 {
275 if !args.is_empty() && args[0] != "--help" && args[0] != "-h" {
276 eprintln!("error: unexpected list argument");
277 return 2;
278 }
279 let cwd = match std::env::current_dir() {
280 Ok(p) => p,
281 Err(e) => {
282 eprintln!("error: {e}");
283 return 1;
284 }
285 };
286 match read_registry(&cwd) {
287 Ok(r) => {
288 if r.packages.is_empty() {
289 println!("no project packages");
290 } else {
291 for p in r.packages {
292 println!("{} {} [{}]", p.name, p.version, p.source);
293 }
294 }
295 0
296 }
297 Err(e) => {
298 eprintln!("error: {e}");
299 1
300 }
301 }
302}
303
304fn update(args: &[String]) -> i32 {
305 let cwd = match std::env::current_dir() {
306 Ok(path) => path,
307 Err(error) => {
308 eprintln!("error: {error}");
309 return 1;
310 }
311 };
312 update_at(&cwd, args, update_global_package)
313}
314
315fn update_at(
316 cwd: &Path,
317 args: &[String],
318 mut update_global: impl FnMut(&crate::install::InstalledNativePackage, &[String]) -> bool,
319) -> i32 {
320 let mut name = None;
321 let mut link_args = Vec::new();
322 let mut i = 0;
323 while i < args.len() {
324 match args[i].as_str() {
325 "--debug" | "--locked" => link_args.push(args[i].clone()),
326 "--help" | "-h" => {
327 print_update_help();
328 return 0;
329 }
330 value if value.starts_with('-') => {
331 eprintln!("error: unknown update option `{value}`");
332 return 2;
333 }
334 value => {
335 if name.replace(value.to_string()).is_some() {
336 eprintln!("error: update accepts at most one extension name");
337 return 2;
338 }
339 }
340 }
341 i += 1;
342 }
343
344 let registry = match read_registry(cwd) {
345 Ok(r) => r,
346 Err(e) => {
347 eprintln!("error: {e}");
348 return 1;
349 }
350 };
351 let resource_owned =
352 match crate::resource_management::resource_owned_names(&cwd.join(".rpi"), "package") {
353 Ok(names) => names,
354 Err(error) => {
355 eprintln!("error: {error}");
356 return 1;
357 }
358 };
359 if name
360 .as_ref()
361 .is_some_and(|name| resource_owned.contains(name))
362 {
363 eprintln!("error: resource-owned Package; use rpi resource update package NAME --scope project --path PATH");
364 return 1;
365 }
366 let packages = registry
367 .packages
368 .iter()
369 .filter(|package| !resource_owned.contains(&package.name))
370 .filter(|package| {
371 name.as_deref()
372 .map_or(true, |wanted| wanted == package.name)
373 })
374 .cloned()
375 .collect::<Vec<_>>();
376
377 let global_packages = match crate::install::installed_native_packages_strict() {
382 Ok(packages) => packages
383 .into_iter()
384 .filter(|package| {
385 package.supports_registry_update()
386 && name
387 .as_deref()
388 .map_or(true, |wanted| wanted == package.name)
389 })
390 .collect::<Vec<_>>(),
391 Err(error) => {
392 eprintln!("error: {error}");
393 return 1;
394 }
395 };
396 if let Some(wanted) = name.as_deref() {
397 let project_found = registry
398 .packages
399 .iter()
400 .any(|package| package.name == wanted);
401 let global_found = global_packages.iter().any(|package| package.name == wanted);
402 if !project_found && !global_found {
403 eprintln!("error: extension `{wanted}` is not installed");
404 return 1;
405 }
406 }
407
408 if packages.is_empty() && global_packages.is_empty() {
409 println!("no project or global extensions to update");
410 return 0;
411 }
412
413 let mut status = 0;
414 for package in packages {
415 let package_name = package.name.clone();
416 eprintln!("extension: updating project extension `{package_name}`...");
417 let mut args = vec![
418 "--path".to_string(),
419 package.source,
420 "--package".to_string(),
421 package.cargo_package,
422 "--name".to_string(),
423 package.name,
424 "--force".to_string(),
425 ];
426 args.extend(link_args.iter().cloned());
427 if link(&args) != 0 {
428 status = 1;
429 }
430 }
431 for package in global_packages {
432 eprintln!("extension: updating global extension `{}`...", package.name);
433 if !update_global(&package, &link_args) {
434 status = 1;
435 }
436 }
437
438 status
439}
440
441fn global_update_args(
442 package: &crate::install::InstalledNativePackage,
443 args: &[String],
444) -> Vec<String> {
445 let mut install_args = vec!["install".into(), package.name.clone()];
446 if let Some(source) = &package.prebuilt {
447 if source.custom_repository {
448 install_args.extend(["--github".into(), source.repository.clone()]);
449 }
450 } else {
451 install_args.push("--build".into());
452 }
453 if package.prebuilt.is_none() && args.iter().any(|arg| arg == "--locked") {
454 install_args.push("--locked".into());
455 }
456 install_args
457}
458
459fn update_global_package(
460 package: &crate::install::InstalledNativePackage,
461 args: &[String],
462) -> bool {
463 let name = &package.name;
464 let executable = match std::env::current_exe() {
465 Ok(path) => path,
466 Err(error) => {
467 eprintln!("error: could not resolve rpi executable: {error}");
468 return false;
469 }
470 };
471 let mut command = std::process::Command::new(executable);
472 command.args(global_update_args(package, args));
473 match command.status() {
474 Ok(exit) if exit.success() => true,
475 Ok(exit) => {
476 eprintln!("error: updating global extension `{name}` failed with {exit}");
477 false
478 }
479 Err(error) => {
480 eprintln!("error: could not run rpi install for `{name}`: {error}");
481 false
482 }
483 }
484}
485
486fn unlink(args: &[String]) -> i32 {
487 let Some(name) = args.first() else {
488 eprintln!("error: missing package name");
489 return 2;
490 };
491 if args.len() != 1 || !valid_name(name) {
492 eprintln!("error: invalid package name or arguments");
493 return 2;
494 }
495 let cwd = match std::env::current_dir() {
496 Ok(p) => p,
497 Err(e) => {
498 eprintln!("error: {e}");
499 return 1;
500 }
501 };
502 let native_root = cwd.join(".rpi");
503 let _lease = match crate::resource_management::mutation_lease(&native_root) {
504 Ok(lease) => lease,
505 Err(error) => {
506 eprintln!("error: {error}");
507 return 1;
508 }
509 };
510 if let Err(error) = crate::resource_management::legacy_unowned(&native_root, "package", name) {
511 eprintln!("error: {error}");
512 return 1;
513 }
514 let mut registry = match read_registry(&cwd) {
515 Ok(r) => r,
516 Err(e) => {
517 eprintln!("error: {e}");
518 return 1;
519 }
520 };
521 if !registry.packages.iter().any(|p| p.name == *name) {
522 eprintln!("error: project package `{name}` is not installed");
523 return 1;
524 }
525 let dir = cwd.join(".rpi/packages").join(name);
526 if let Err(e) = std::fs::remove_dir_all(&dir) {
527 eprintln!("error: could not remove package: {e}");
528 return 1;
529 }
530 registry.packages.retain(|p| p.name != *name);
531 if let Err(e) = write_registry(&cwd, ®istry) {
532 eprintln!("error: {e}");
533 return 1;
534 }
535 println!("unlinked project package `{name}`");
536 0
537}
538
539#[derive(Debug, Clone)]
540struct CargoPackage {
541 name: String,
542 version: String,
543 manifest: PathBuf,
544 target: String,
545}
546#[derive(Deserialize)]
547struct Metadata {
548 packages: Vec<MetadataPackage>,
549}
550#[derive(Deserialize)]
551struct MetadataPackage {
552 name: String,
553 version: String,
554 manifest_path: PathBuf,
555 targets: Vec<Target>,
556}
557#[derive(Deserialize)]
558struct Target {
559 name: String,
560 crate_types: Vec<String>,
561}
562
563fn cargo_metadata(cwd: &Path) -> Result<Metadata, String> {
564 let out = Command::new("cargo")
565 .args(["metadata", "--format-version", "1", "--no-deps"])
566 .current_dir(cwd)
567 .stdin(Stdio::null())
568 .output()
569 .map_err(|e| format!("could not execute cargo metadata: {e}"))?;
570 if !out.status.success() {
571 return Err(format!(
572 "cargo metadata failed:\n{}",
573 String::from_utf8_lossy(&out.stderr).trim()
574 ));
575 }
576 serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid cargo metadata output: {e}"))
577}
578fn select_package(
579 m: &Metadata,
580 requested: Option<&str>,
581 cwd: &Path,
582) -> Result<CargoPackage, String> {
583 let mut found = Vec::new();
584 for p in &m.packages {
585 if requested.is_some_and(|n| n != p.name) {
586 continue;
587 }
588 for t in &p.targets {
589 if t.crate_types.iter().any(|k| k == "cdylib") {
590 found.push(CargoPackage {
591 name: p.name.clone(),
592 version: p.version.clone(),
593 manifest: p.manifest_path.clone(),
594 target: t.name.clone(),
595 });
596 }
597 }
598 }
599 if requested.is_none() {
600 let source = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
604 let mut local = found
605 .iter()
606 .filter(|p| {
607 p.manifest.parent().is_some_and(|root| {
608 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
609 source.starts_with(root)
610 })
611 })
612 .cloned()
613 .collect::<Vec<_>>();
614 if local.len() == 1 {
615 return Ok(local.remove(0));
616 }
617 }
618 match found.len() {
619 1 => Ok(found.remove(0)),
620 0 => Err("no cdylib package found; use --package to select one".into()),
621 _ => Err(format!(
622 "multiple cdylib packages found: {}; use --package",
623 found
624 .iter()
625 .map(|p| p.name.as_str())
626 .collect::<Vec<_>>()
627 .join(", ")
628 )),
629 }
630}
631fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
632 let mut c = Command::new("cargo");
633 c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
634 "--package",
635 &p.name,
636 "--message-format=json-render-diagnostics",
637 ]);
638 if release {
639 c.arg("--release");
640 }
641 if locked {
642 c.arg("--locked");
643 }
644 c.stdout(Stdio::piped()).stderr(Stdio::piped());
645 let out = c
646 .output()
647 .map_err(|e| format!("could not execute cargo build: {e}"))?;
648 let mut artifact = None;
649 for line in String::from_utf8_lossy(&out.stdout).lines() {
650 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
651 if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
652 && v.get("target")
653 .and_then(|v| v.get("name"))
654 .and_then(|v| v.as_str())
655 == Some(&p.target)
656 {
657 artifact = v
658 .get("filenames")
659 .and_then(|v| v.as_array())
660 .into_iter()
661 .flatten()
662 .filter_map(|v| v.as_str())
663 .map(PathBuf::from)
664 .find(|p| is_dynamic(p));
665 }
666 }
667 }
668 eprint!("{}", String::from_utf8_lossy(&out.stderr));
669 if !out.status.success() {
670 return Err(format!("cargo build exited with {}", out.status));
671 }
672 artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
673}
674fn is_dynamic(p: &Path) -> bool {
675 matches!(
676 p.extension()
677 .and_then(|x| x.to_str())
678 .map(|x| x.to_ascii_lowercase())
679 .as_deref(),
680 Some("dll" | "so" | "dylib")
681 )
682}
683pub(crate) fn valid_name(s: &str) -> bool {
684 !s.is_empty()
685 && s != "."
686 && s != ".."
687 && s.bytes()
688 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
689}
690fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
691 match args.get(i) {
692 Some(v) if !v.starts_with('-') => Some(v.clone()),
693 _ => {
694 eprintln!("error: {flag} requires a value");
695 None
696 }
697 }
698}
699fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
700 let path = cwd.join(".rpi").join(REGISTRY);
701 match std::fs::read_to_string(path) {
702 Ok(s) => {
703 serde_json::from_str(&s).map_err(|e| format!("invalid project package registry: {e}"))
704 }
705 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PackageRegistry::default()),
706 Err(e) => Err(e.to_string()),
707 }
708}
709fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
710 let dir = cwd.join(".rpi");
711 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
712 let path = dir.join(REGISTRY);
713 let tmp = dir.join(".packages.json.tmp");
714 let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
715 std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
716 std::fs::rename(tmp, path).map_err(|e| e.to_string())
717}
718pub fn selected_project_package(cwd: &Path, name: &str) -> Result<ProjectPackage, String> {
719 selected_package_at(&cwd.join(".rpi"), name)
720}
721
722pub(crate) fn selected_package_at(root: &Path, name: &str) -> Result<ProjectPackage, String> {
723 if !valid_name(name) {
724 return Err("invalid main package name".into());
725 }
726 use std::io::Read;
727 let mut bytes = Vec::new();
728 let limit = crate::resource_selection::MAX_STATE_BYTES;
729 std::fs::File::open(root.join(REGISTRY))
730 .map_err(|e| e.to_string())?
731 .take((limit + 1) as u64)
732 .read_to_end(&mut bytes)
733 .map_err(|e| e.to_string())?;
734 if bytes.len() > limit {
735 return Err("project package registry exceeds 256 KiB".into());
736 }
737 let registry: PackageRegistry = serde_json::from_slice(&bytes)
738 .map_err(|e| format!("invalid project package registry: {e}"))?;
739 let mut names = std::collections::HashSet::new();
740 for package in ®istry.packages {
741 if !names.insert(&package.name) {
742 return Err(format!("duplicate package in registry: {}", package.name));
743 }
744 }
745 registry
746 .packages
747 .into_iter()
748 .find(|p| p.name == name)
749 .ok_or_else(|| format!("main package `{name}` not in .rpi/packages.json"))
750}
751
752pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
753 match read_registry(cwd) {
754 Ok(registry) => registry
755 .packages
756 .into_iter()
757 .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
758 .collect(),
759 Err(error) => {
760 eprintln!("warning: could not load project package registry: {error}");
761 Vec::new()
762 }
763 }
764}
765fn print_extension_help() {
766 println!("Usage: rpi extension <command>\n\nCommands:\n update [name] [--debug] [--locked] Update project and global installed extensions\n\nSee `rpi extension update --help` for options.");
767}
768
769pub fn print_help() {
770 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] Legacy compatibility alias for `rpi extension update`\n unlink <name> Remove a project package");
771}
772fn print_link_help() {
773 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");
774}
775fn print_update_help() {
776 println!("Usage: rpi extension 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 extensions are updated.\nLegacy compatibility alias: rpi package update [name] [options]");
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 fn native_package(name: &str, source: Option<&str>) -> crate::install::InstalledNativePackage {
784 crate::install::InstalledNativePackage {
785 name: name.into(),
786 version: "0.1.11".into(),
787 source: source.map(str::to_owned),
788 prebuilt: None,
789 artifacts: Vec::new(),
790 }
791 }
792
793 fn prebuilt_package(custom_repository: bool) -> crate::install::InstalledNativePackage {
794 serde_json::from_value(serde_json::json!({
795 "name": "rpi-im-message", "version": "0.1.11",
796 "source": "prebuilt:https://github.com/owner/extensions",
797 "prebuilt": {
798 "repository": "https://github.com/owner/extensions",
799 "target": "x86_64-pc-windows-msvc",
800 "manifest_sha256": "a".repeat(64), "source_commit": "b".repeat(40),
801 "sdk_version": "0.4.0", "plugin_abi": 4,
802 "archive_sha256": "c".repeat(64), "library_sha256": "d".repeat(64),
803 "custom_repository": custom_repository
804 }
805 }))
806 .unwrap()
807 }
808
809 fn with_registry(data: &[u8], test: impl FnOnce(&Path)) {
810 struct RestoreConfigDir(Option<std::ffi::OsString>);
811 impl Drop for RestoreConfigDir {
812 fn drop(&mut self) {
813 match self.0.take() {
814 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
815 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
816 }
817 }
818 }
819 let _guard = crate::config::test_support::env_lock()
820 .lock()
821 .unwrap_or_else(|p| p.into_inner());
822 let agent = tempfile::tempdir().unwrap();
823 let project = tempfile::tempdir().unwrap();
824 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
825 std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
826 std::fs::write(agent.path().join("native-packages.json"), data).unwrap();
827 test(project.path());
828 }
829
830 #[test]
831 fn update_includes_prebuilt_packages_and_preserves_install_backends() {
832 let records = vec![
833 prebuilt_package(false),
834 native_package("cargo-extension", None),
835 native_package("local-extension", Some("/dev/local-extension")),
836 ];
837 with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
838 let mut commands = Vec::new();
842 assert_eq!(
843 update_at(cwd, &[], |package, args| {
844 commands.push(global_update_args(package, args));
845 true
846 }),
847 0
848 );
849 assert_eq!(
850 commands,
851 [
852 vec!["install", "rpi-im-message"],
853 vec!["install", "cargo-extension", "--build"],
854 ]
855 );
856
857 for wanted in ["rpi-im-message", "cargo-extension"] {
858 commands.clear();
859 assert_eq!(
860 update_at(cwd, &[wanted.into(), "--locked".into()], |package, args| {
861 commands.push(global_update_args(package, args));
862 true
863 }),
864 0
865 );
866 assert_eq!(commands.len(), 1);
867 assert_eq!(commands[0][1], wanted);
868 assert_eq!(
869 commands[0].iter().any(|arg| arg == "--locked"),
870 wanted == "cargo-extension"
871 );
872 }
873 for wanted in ["local-extension", "missing-extension"] {
874 assert_eq!(
875 update_at(cwd, &[wanted.into()], |_, _| {
876 panic!("local or unknown packages must not trigger a remote install")
877 }),
878 1
879 );
880 }
881 });
882 }
883
884 #[test]
885 fn update_preserves_explicit_github_repository() {
886 let record = prebuilt_package(true);
887 with_registry(&serde_json::to_vec(&[record]).unwrap(), |cwd| {
888 let mut commands = Vec::new();
889 assert_eq!(
890 update_at(cwd, &[], |package, args| {
891 commands.push(global_update_args(package, args));
892 true
893 }),
894 0
895 );
896 assert_eq!(
897 commands,
898 [vec![
899 "install",
900 "rpi-im-message",
901 "--github",
902 "https://github.com/owner/extensions"
903 ]]
904 );
905 });
906 }
907
908 #[test]
909 fn update_continues_after_a_failed_package_and_returns_failure() {
910 let records = vec![
911 prebuilt_package(false),
912 native_package("other-extension", None),
913 ];
914 with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
915 let mut visited = Vec::new();
916 assert_eq!(
917 update_at(cwd, &[], |package, _| {
918 visited.push(package.name.clone());
919 package.name != "rpi-im-message"
920 }),
921 1
922 );
923 assert_eq!(visited, ["rpi-im-message", "other-extension"]);
924 });
925 }
926
927 #[test]
928 fn update_rejects_corrupt_global_metadata_before_building_project_packages() {
929 with_registry(b"invalid json", |cwd| {
930 write_registry(
931 cwd,
932 &PackageRegistry {
933 packages: vec![ProjectPackage {
934 name: "project-extension".into(),
935 version: "0.1.0".into(),
936 source: "/missing/source".into(),
937 cargo_package: "project-extension".into(),
938 artifacts: Vec::new(),
939 }],
940 },
941 )
942 .unwrap();
943 assert_eq!(update_at(cwd, &[], |_, _| panic!("invalid registry")), 1);
944 assert!(!cwd.join(".rpi/packages/project-extension").exists());
945 });
946 }
947
948 #[test]
949 fn package_names_reject_path_traversal() {
950 assert!(valid_name("review-tools"));
951 assert!(!valid_name("../review-tools"));
952 assert!(!valid_name("review/tools"));
953 assert!(!valid_name(""));
954 }
955
956 #[test]
957 fn update_help_is_available_without_a_registry() {
958 print_update_help();
959 }
960
961 #[test]
962 fn dynamic_library_extensions_are_platform_neutral() {
963 assert!(is_dynamic(Path::new("plugin.dll")));
964 assert!(is_dynamic(Path::new("libplugin.so")));
965 assert!(is_dynamic(Path::new("libplugin.dylib")));
966 assert!(!is_dynamic(Path::new("plugin.exe")));
967 }
968}