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 cwd = match std::env::current_dir() {
224 Ok(path) => path,
225 Err(error) => {
226 eprintln!("error: {error}");
227 return 1;
228 }
229 };
230 update_at(&cwd, args, update_global_package)
231}
232
233fn update_at(
234 cwd: &Path,
235 args: &[String],
236 mut update_global: impl FnMut(&crate::install::InstalledNativePackage, &[String]) -> bool,
237) -> i32 {
238 let mut name = None;
239 let mut link_args = Vec::new();
240 let mut i = 0;
241 while i < args.len() {
242 match args[i].as_str() {
243 "--debug" | "--locked" => link_args.push(args[i].clone()),
244 "--help" | "-h" => {
245 print_update_help();
246 return 0;
247 }
248 value if value.starts_with('-') => {
249 eprintln!("error: unknown update option `{value}`");
250 return 2;
251 }
252 value => {
253 if name.replace(value.to_string()).is_some() {
254 eprintln!("error: update accepts at most one package name");
255 return 2;
256 }
257 }
258 }
259 i += 1;
260 }
261
262 let registry = match read_registry(cwd) {
263 Ok(r) => r,
264 Err(e) => {
265 eprintln!("error: {e}");
266 return 1;
267 }
268 };
269 let packages = registry
270 .packages
271 .iter()
272 .filter(|package| {
273 name.as_deref()
274 .map_or(true, |wanted| wanted == package.name)
275 })
276 .cloned()
277 .collect::<Vec<_>>();
278
279 let global_packages = match crate::install::installed_native_packages_strict() {
284 Ok(packages) => packages
285 .into_iter()
286 .filter(|package| {
287 package.supports_registry_update()
288 && name
289 .as_deref()
290 .map_or(true, |wanted| wanted == package.name)
291 })
292 .collect::<Vec<_>>(),
293 Err(error) => {
294 eprintln!("error: {error}");
295 return 1;
296 }
297 };
298 if let Some(wanted) = name.as_deref() {
299 let project_found = registry
300 .packages
301 .iter()
302 .any(|package| package.name == wanted);
303 let global_found = global_packages.iter().any(|package| package.name == wanted);
304 if !project_found && !global_found {
305 eprintln!("error: package `{wanted}` is not installed");
306 return 1;
307 }
308 }
309
310 if packages.is_empty() && global_packages.is_empty() {
311 println!("no project or global packages to update");
312 return 0;
313 }
314
315 let mut status = 0;
316 for package in packages {
317 let package_name = package.name.clone();
318 eprintln!("package: updating project package `{package_name}`...");
319 let mut args = vec![
320 "--path".to_string(),
321 package.source,
322 "--package".to_string(),
323 package.cargo_package,
324 "--name".to_string(),
325 package.name,
326 "--force".to_string(),
327 ];
328 args.extend(link_args.iter().cloned());
329 if link(&args) != 0 {
330 status = 1;
331 }
332 }
333 for package in global_packages {
334 eprintln!("package: updating global package `{}`...", package.name);
335 if !update_global(&package, &link_args) {
336 status = 1;
337 }
338 }
339
340 status
341}
342
343fn global_update_args(
344 package: &crate::install::InstalledNativePackage,
345 args: &[String],
346) -> Vec<String> {
347 let mut install_args = vec!["install".into(), package.name.clone()];
348 if let Some(source) = &package.prebuilt {
349 if source.custom_repository {
350 install_args.extend(["--github".into(), source.repository.clone()]);
351 }
352 } else {
353 install_args.push("--build".into());
354 }
355 if package.prebuilt.is_none() && args.iter().any(|arg| arg == "--locked") {
356 install_args.push("--locked".into());
357 }
358 install_args
359}
360
361fn update_global_package(
362 package: &crate::install::InstalledNativePackage,
363 args: &[String],
364) -> bool {
365 let name = &package.name;
366 let executable = match std::env::current_exe() {
367 Ok(path) => path,
368 Err(error) => {
369 eprintln!("error: could not resolve rpi executable: {error}");
370 return false;
371 }
372 };
373 let mut command = std::process::Command::new(executable);
374 command.args(global_update_args(package, args));
375 match command.status() {
376 Ok(exit) if exit.success() => true,
377 Ok(exit) => {
378 eprintln!("error: updating global package `{name}` failed with {exit}");
379 false
380 }
381 Err(error) => {
382 eprintln!("error: could not run rpi install for `{name}`: {error}");
383 false
384 }
385 }
386}
387
388fn unlink(args: &[String]) -> i32 {
389 let Some(name) = args.first() else {
390 eprintln!("error: missing package name");
391 return 2;
392 };
393 if args.len() != 1 || !valid_name(name) {
394 eprintln!("error: invalid package name or arguments");
395 return 2;
396 }
397 let cwd = match std::env::current_dir() {
398 Ok(p) => p,
399 Err(e) => {
400 eprintln!("error: {e}");
401 return 1;
402 }
403 };
404 let mut registry = match read_registry(&cwd) {
405 Ok(r) => r,
406 Err(e) => {
407 eprintln!("error: {e}");
408 return 1;
409 }
410 };
411 if !registry.packages.iter().any(|p| p.name == *name) {
412 eprintln!("error: project package `{name}` is not installed");
413 return 1;
414 }
415 let dir = cwd.join(".rpi/packages").join(name);
416 if let Err(e) = std::fs::remove_dir_all(&dir) {
417 eprintln!("error: could not remove package: {e}");
418 return 1;
419 }
420 registry.packages.retain(|p| p.name != *name);
421 if let Err(e) = write_registry(&cwd, ®istry) {
422 eprintln!("error: {e}");
423 return 1;
424 }
425 println!("unlinked project package `{name}`");
426 0
427}
428
429#[derive(Debug, Clone)]
430struct CargoPackage {
431 name: String,
432 version: String,
433 manifest: PathBuf,
434 target: String,
435}
436#[derive(Deserialize)]
437struct Metadata {
438 packages: Vec<MetadataPackage>,
439}
440#[derive(Deserialize)]
441struct MetadataPackage {
442 name: String,
443 version: String,
444 manifest_path: PathBuf,
445 targets: Vec<Target>,
446}
447#[derive(Deserialize)]
448struct Target {
449 name: String,
450 crate_types: Vec<String>,
451}
452
453fn cargo_metadata(cwd: &Path) -> Result<Metadata, String> {
454 let out = Command::new("cargo")
455 .args(["metadata", "--format-version", "1", "--no-deps"])
456 .current_dir(cwd)
457 .stdin(Stdio::null())
458 .output()
459 .map_err(|e| format!("could not execute cargo metadata: {e}"))?;
460 if !out.status.success() {
461 return Err(format!(
462 "cargo metadata failed:\n{}",
463 String::from_utf8_lossy(&out.stderr).trim()
464 ));
465 }
466 serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid cargo metadata output: {e}"))
467}
468fn select_package(
469 m: &Metadata,
470 requested: Option<&str>,
471 cwd: &Path,
472) -> Result<CargoPackage, String> {
473 let mut found = Vec::new();
474 for p in &m.packages {
475 if requested.is_some_and(|n| n != p.name) {
476 continue;
477 }
478 for t in &p.targets {
479 if t.crate_types.iter().any(|k| k == "cdylib") {
480 found.push(CargoPackage {
481 name: p.name.clone(),
482 version: p.version.clone(),
483 manifest: p.manifest_path.clone(),
484 target: t.name.clone(),
485 });
486 }
487 }
488 }
489 if requested.is_none() {
490 let source = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
494 let mut local = found
495 .iter()
496 .filter(|p| {
497 p.manifest.parent().is_some_and(|root| {
498 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
499 source.starts_with(root)
500 })
501 })
502 .cloned()
503 .collect::<Vec<_>>();
504 if local.len() == 1 {
505 return Ok(local.remove(0));
506 }
507 }
508 match found.len() {
509 1 => Ok(found.remove(0)),
510 0 => Err("no cdylib package found; use --package to select one".into()),
511 _ => Err(format!(
512 "multiple cdylib packages found: {}; use --package",
513 found
514 .iter()
515 .map(|p| p.name.as_str())
516 .collect::<Vec<_>>()
517 .join(", ")
518 )),
519 }
520}
521fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
522 let mut c = Command::new("cargo");
523 c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
524 "--package",
525 &p.name,
526 "--message-format=json-render-diagnostics",
527 ]);
528 if release {
529 c.arg("--release");
530 }
531 if locked {
532 c.arg("--locked");
533 }
534 c.stdout(Stdio::piped()).stderr(Stdio::piped());
535 let out = c
536 .output()
537 .map_err(|e| format!("could not execute cargo build: {e}"))?;
538 let mut artifact = None;
539 for line in String::from_utf8_lossy(&out.stdout).lines() {
540 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
541 if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
542 && v.get("target")
543 .and_then(|v| v.get("name"))
544 .and_then(|v| v.as_str())
545 == Some(&p.target)
546 {
547 artifact = v
548 .get("filenames")
549 .and_then(|v| v.as_array())
550 .into_iter()
551 .flatten()
552 .filter_map(|v| v.as_str())
553 .map(PathBuf::from)
554 .find(|p| is_dynamic(p));
555 }
556 }
557 }
558 eprint!("{}", String::from_utf8_lossy(&out.stderr));
559 if !out.status.success() {
560 return Err(format!("cargo build exited with {}", out.status));
561 }
562 artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
563}
564fn is_dynamic(p: &Path) -> bool {
565 matches!(
566 p.extension()
567 .and_then(|x| x.to_str())
568 .map(|x| x.to_ascii_lowercase())
569 .as_deref(),
570 Some("dll" | "so" | "dylib")
571 )
572}
573fn valid_name(s: &str) -> bool {
574 !s.is_empty()
575 && s.bytes()
576 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
577}
578fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
579 match args.get(i) {
580 Some(v) if !v.starts_with('-') => Some(v.clone()),
581 _ => {
582 eprintln!("error: {flag} requires a value");
583 None
584 }
585 }
586}
587fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
588 let path = cwd.join(".rpi").join(REGISTRY);
589 match std::fs::read_to_string(path) {
590 Ok(s) => {
591 serde_json::from_str(&s).map_err(|e| format!("invalid project package registry: {e}"))
592 }
593 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PackageRegistry::default()),
594 Err(e) => Err(e.to_string()),
595 }
596}
597fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
598 let dir = cwd.join(".rpi");
599 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
600 let path = dir.join(REGISTRY);
601 let tmp = dir.join(".packages.json.tmp");
602 let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
603 std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
604 std::fs::rename(tmp, path).map_err(|e| e.to_string())
605}
606pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
607 match read_registry(cwd) {
608 Ok(registry) => registry
609 .packages
610 .into_iter()
611 .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
612 .collect(),
613 Err(error) => {
614 eprintln!("warning: could not load project package registry: {error}");
615 Vec::new()
616 }
617 }
618}
619pub fn print_help() {
620 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");
621}
622fn print_link_help() {
623 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");
624}
625fn print_update_help() {
626 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.");
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 fn native_package(name: &str, source: Option<&str>) -> crate::install::InstalledNativePackage {
634 crate::install::InstalledNativePackage {
635 name: name.into(),
636 version: "0.1.11".into(),
637 source: source.map(str::to_owned),
638 prebuilt: None,
639 artifacts: Vec::new(),
640 }
641 }
642
643 fn prebuilt_package(custom_repository: bool) -> crate::install::InstalledNativePackage {
644 serde_json::from_value(serde_json::json!({
645 "name": "rpi-im-message", "version": "0.1.11",
646 "source": "prebuilt:https://github.com/owner/extensions",
647 "prebuilt": {
648 "repository": "https://github.com/owner/extensions",
649 "target": "x86_64-pc-windows-msvc",
650 "manifest_sha256": "a".repeat(64), "source_commit": "b".repeat(40),
651 "sdk_version": "0.4.0", "plugin_abi": 4,
652 "archive_sha256": "c".repeat(64), "library_sha256": "d".repeat(64),
653 "custom_repository": custom_repository
654 }
655 }))
656 .unwrap()
657 }
658
659 fn with_registry(data: &[u8], test: impl FnOnce(&Path)) {
660 struct RestoreConfigDir(Option<std::ffi::OsString>);
661 impl Drop for RestoreConfigDir {
662 fn drop(&mut self) {
663 match self.0.take() {
664 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
665 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
666 }
667 }
668 }
669 let _guard = crate::config::test_support::env_lock()
670 .lock()
671 .unwrap_or_else(|p| p.into_inner());
672 let agent = tempfile::tempdir().unwrap();
673 let project = tempfile::tempdir().unwrap();
674 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
675 std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
676 std::fs::write(agent.path().join("native-packages.json"), data).unwrap();
677 test(project.path());
678 }
679
680 #[test]
681 fn update_includes_prebuilt_packages_and_preserves_install_backends() {
682 let records = vec![
683 prebuilt_package(false),
684 native_package("cargo-extension", None),
685 native_package("local-extension", Some("/dev/local-extension")),
686 ];
687 with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
688 let mut commands = Vec::new();
692 assert_eq!(
693 update_at(cwd, &[], |package, args| {
694 commands.push(global_update_args(package, args));
695 true
696 }),
697 0
698 );
699 assert_eq!(
700 commands,
701 [
702 vec!["install", "rpi-im-message"],
703 vec!["install", "cargo-extension", "--build"],
704 ]
705 );
706
707 for wanted in ["rpi-im-message", "cargo-extension"] {
708 commands.clear();
709 assert_eq!(
710 update_at(cwd, &[wanted.into(), "--locked".into()], |package, args| {
711 commands.push(global_update_args(package, args));
712 true
713 }),
714 0
715 );
716 assert_eq!(commands.len(), 1);
717 assert_eq!(commands[0][1], wanted);
718 assert_eq!(
719 commands[0].iter().any(|arg| arg == "--locked"),
720 wanted == "cargo-extension"
721 );
722 }
723 for wanted in ["local-extension", "missing-extension"] {
724 assert_eq!(
725 update_at(cwd, &[wanted.into()], |_, _| {
726 panic!("local or unknown packages must not trigger a remote install")
727 }),
728 1
729 );
730 }
731 });
732 }
733
734 #[test]
735 fn update_preserves_explicit_github_repository() {
736 let record = prebuilt_package(true);
737 with_registry(&serde_json::to_vec(&[record]).unwrap(), |cwd| {
738 let mut commands = Vec::new();
739 assert_eq!(
740 update_at(cwd, &[], |package, args| {
741 commands.push(global_update_args(package, args));
742 true
743 }),
744 0
745 );
746 assert_eq!(
747 commands,
748 [vec![
749 "install",
750 "rpi-im-message",
751 "--github",
752 "https://github.com/owner/extensions"
753 ]]
754 );
755 });
756 }
757
758 #[test]
759 fn update_continues_after_a_failed_package_and_returns_failure() {
760 let records = vec![
761 prebuilt_package(false),
762 native_package("other-extension", None),
763 ];
764 with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
765 let mut visited = Vec::new();
766 assert_eq!(
767 update_at(cwd, &[], |package, _| {
768 visited.push(package.name.clone());
769 package.name != "rpi-im-message"
770 }),
771 1
772 );
773 assert_eq!(visited, ["rpi-im-message", "other-extension"]);
774 });
775 }
776
777 #[test]
778 fn update_rejects_corrupt_global_metadata_before_building_project_packages() {
779 with_registry(b"invalid json", |cwd| {
780 write_registry(
781 cwd,
782 &PackageRegistry {
783 packages: vec![ProjectPackage {
784 name: "project-extension".into(),
785 version: "0.1.0".into(),
786 source: "/missing/source".into(),
787 cargo_package: "project-extension".into(),
788 artifacts: Vec::new(),
789 }],
790 },
791 )
792 .unwrap();
793 assert_eq!(update_at(cwd, &[], |_, _| panic!("invalid registry")), 1);
794 assert!(!cwd.join(".rpi/packages/project-extension").exists());
795 });
796 }
797
798 #[test]
799 fn package_names_reject_path_traversal() {
800 assert!(valid_name("review-tools"));
801 assert!(!valid_name("../review-tools"));
802 assert!(!valid_name("review/tools"));
803 assert!(!valid_name(""));
804 }
805
806 #[test]
807 fn update_help_is_available_without_a_registry() {
808 print_update_help();
809 }
810
811 #[test]
812 fn dynamic_library_extensions_are_platform_neutral() {
813 assert!(is_dynamic(Path::new("plugin.dll")));
814 assert!(is_dynamic(Path::new("libplugin.so")));
815 assert!(is_dynamic(Path::new("libplugin.dylib")));
816 assert!(!is_dynamic(Path::new("plugin.exe")));
817 }
818}