Skip to main content

rpi_cli/
packages.rs

1//! Project-local Rust extension packages.
2//!
3//! A package is a built cdylib plus optional `skills`, `prompts`, and `themes`
4//! directories under `.rpi/packages/<name>`. The registry is deliberately
5//! separate from the global native package registry.
6
7use 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, &registry) {
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    // Global extensions installed by `rpi install` are also packages. This is
280    // the path used by crates.io extensions such as rpi-im-message; delegate to
281    // the existing transactional installer so artifact and registry updates
282    // remain atomic and version resolution stays in one place.
283    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, &registry) {
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 mut local = found
491            .iter()
492            .filter(|p| p.manifest.parent().is_some_and(|r| cwd.starts_with(r)))
493            .cloned()
494            .collect::<Vec<_>>();
495        if local.len() == 1 {
496            return Ok(local.remove(0));
497        }
498    }
499    match found.len() {
500        1 => Ok(found.remove(0)),
501        0 => Err("no cdylib package found; use --package to select one".into()),
502        _ => Err(format!(
503            "multiple cdylib packages found: {}; use --package",
504            found
505                .iter()
506                .map(|p| p.name.as_str())
507                .collect::<Vec<_>>()
508                .join(", ")
509        )),
510    }
511}
512fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
513    let mut c = Command::new("cargo");
514    c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
515        "--package",
516        &p.name,
517        "--message-format=json-render-diagnostics",
518    ]);
519    if release {
520        c.arg("--release");
521    }
522    if locked {
523        c.arg("--locked");
524    }
525    c.stdout(Stdio::piped()).stderr(Stdio::piped());
526    let out = c
527        .output()
528        .map_err(|e| format!("could not execute cargo build: {e}"))?;
529    let mut artifact = None;
530    for line in String::from_utf8_lossy(&out.stdout).lines() {
531        if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
532            if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
533                && v.get("target")
534                    .and_then(|v| v.get("name"))
535                    .and_then(|v| v.as_str())
536                    == Some(&p.target)
537            {
538                artifact = v
539                    .get("filenames")
540                    .and_then(|v| v.as_array())
541                    .into_iter()
542                    .flatten()
543                    .filter_map(|v| v.as_str())
544                    .map(PathBuf::from)
545                    .find(|p| is_dynamic(p));
546            }
547        }
548    }
549    eprint!("{}", String::from_utf8_lossy(&out.stderr));
550    if !out.status.success() {
551        return Err(format!("cargo build exited with {}", out.status));
552    }
553    artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
554}
555fn is_dynamic(p: &Path) -> bool {
556    matches!(
557        p.extension()
558            .and_then(|x| x.to_str())
559            .map(|x| x.to_ascii_lowercase())
560            .as_deref(),
561        Some("dll" | "so" | "dylib")
562    )
563}
564fn valid_name(s: &str) -> bool {
565    !s.is_empty()
566        && s.bytes()
567            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
568}
569fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
570    match args.get(i) {
571        Some(v) if !v.starts_with('-') => Some(v.clone()),
572        _ => {
573            eprintln!("error: {flag} requires a value");
574            None
575        }
576    }
577}
578fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
579    let path = cwd.join(".rpi").join(REGISTRY);
580    match std::fs::read_to_string(path) {
581        Ok(s) => {
582            serde_json::from_str(&s).map_err(|e| format!("invalid project package registry: {e}"))
583        }
584        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PackageRegistry::default()),
585        Err(e) => Err(e.to_string()),
586    }
587}
588fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
589    let dir = cwd.join(".rpi");
590    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
591    let path = dir.join(REGISTRY);
592    let tmp = dir.join(".packages.json.tmp");
593    let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
594    std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
595    std::fs::rename(tmp, path).map_err(|e| e.to_string())
596}
597pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
598    match read_registry(cwd) {
599        Ok(registry) => registry
600            .packages
601            .into_iter()
602            .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
603            .collect(),
604        Err(error) => {
605            eprintln!("warning: could not load project package registry: {error}");
606            Vec::new()
607        }
608    }
609}
610pub fn print_help() {
611    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");
612}
613fn print_link_help() {
614    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");
615}
616fn print_update_help() {
617    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.");
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    fn native_package(name: &str, source: Option<&str>) -> crate::install::InstalledNativePackage {
625        crate::install::InstalledNativePackage {
626            name: name.into(),
627            version: "0.1.11".into(),
628            source: source.map(str::to_owned),
629            prebuilt: None,
630            artifacts: Vec::new(),
631        }
632    }
633
634    fn prebuilt_package(custom_repository: bool) -> crate::install::InstalledNativePackage {
635        serde_json::from_value(serde_json::json!({
636            "name": "rpi-im-message", "version": "0.1.11",
637            "source": "prebuilt:https://github.com/owner/extensions",
638            "prebuilt": {
639                "repository": "https://github.com/owner/extensions",
640                "target": "x86_64-pc-windows-msvc",
641                "manifest_sha256": "a".repeat(64), "source_commit": "b".repeat(40),
642                "sdk_version": "0.4.0", "plugin_abi": 4,
643                "archive_sha256": "c".repeat(64), "library_sha256": "d".repeat(64),
644                "custom_repository": custom_repository
645            }
646        }))
647        .unwrap()
648    }
649
650    fn with_registry(data: &[u8], test: impl FnOnce(&Path)) {
651        struct RestoreConfigDir(Option<std::ffi::OsString>);
652        impl Drop for RestoreConfigDir {
653            fn drop(&mut self) {
654                match self.0.take() {
655                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
656                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
657                }
658            }
659        }
660        let _guard = crate::config::test_support::env_lock()
661            .lock()
662            .unwrap_or_else(|p| p.into_inner());
663        let agent = tempfile::tempdir().unwrap();
664        let project = tempfile::tempdir().unwrap();
665        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
666        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
667        std::fs::write(agent.path().join("native-packages.json"), data).unwrap();
668        test(project.path());
669    }
670
671    #[test]
672    fn update_includes_prebuilt_packages_and_preserves_install_backends() {
673        let records = vec![
674            prebuilt_package(false),
675            native_package("cargo-extension", None),
676            native_package("local-extension", Some("/dev/local-extension")),
677        ];
678        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
679            // Reproduce the empty project registry from the bug report. Neither
680            // the prebuilt source marker nor the lack of project links should
681            // hide an installed remote package.
682            let mut commands = Vec::new();
683            assert_eq!(
684                update_at(cwd, &[], |package, args| {
685                    commands.push(global_update_args(package, args));
686                    true
687                }),
688                0
689            );
690            assert_eq!(
691                commands,
692                [
693                    vec!["install", "rpi-im-message"],
694                    vec!["install", "cargo-extension", "--build"],
695                ]
696            );
697
698            for wanted in ["rpi-im-message", "cargo-extension"] {
699                commands.clear();
700                assert_eq!(
701                    update_at(cwd, &[wanted.into(), "--locked".into()], |package, args| {
702                        commands.push(global_update_args(package, args));
703                        true
704                    }),
705                    0
706                );
707                assert_eq!(commands.len(), 1);
708                assert_eq!(commands[0][1], wanted);
709                assert_eq!(
710                    commands[0].iter().any(|arg| arg == "--locked"),
711                    wanted == "cargo-extension"
712                );
713            }
714            for wanted in ["local-extension", "missing-extension"] {
715                assert_eq!(
716                    update_at(cwd, &[wanted.into()], |_, _| {
717                        panic!("local or unknown packages must not trigger a remote install")
718                    }),
719                    1
720                );
721            }
722        });
723    }
724
725    #[test]
726    fn update_preserves_explicit_github_repository() {
727        let record = prebuilt_package(true);
728        with_registry(&serde_json::to_vec(&[record]).unwrap(), |cwd| {
729            let mut commands = Vec::new();
730            assert_eq!(
731                update_at(cwd, &[], |package, args| {
732                    commands.push(global_update_args(package, args));
733                    true
734                }),
735                0
736            );
737            assert_eq!(
738                commands,
739                [vec![
740                    "install",
741                    "rpi-im-message",
742                    "--github",
743                    "https://github.com/owner/extensions"
744                ]]
745            );
746        });
747    }
748
749    #[test]
750    fn update_continues_after_a_failed_package_and_returns_failure() {
751        let records = vec![
752            prebuilt_package(false),
753            native_package("other-extension", None),
754        ];
755        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
756            let mut visited = Vec::new();
757            assert_eq!(
758                update_at(cwd, &[], |package, _| {
759                    visited.push(package.name.clone());
760                    package.name != "rpi-im-message"
761                }),
762                1
763            );
764            assert_eq!(visited, ["rpi-im-message", "other-extension"]);
765        });
766    }
767
768    #[test]
769    fn update_rejects_corrupt_global_metadata_before_building_project_packages() {
770        with_registry(b"invalid json", |cwd| {
771            write_registry(
772                cwd,
773                &PackageRegistry {
774                    packages: vec![ProjectPackage {
775                        name: "project-extension".into(),
776                        version: "0.1.0".into(),
777                        source: "/missing/source".into(),
778                        cargo_package: "project-extension".into(),
779                        artifacts: Vec::new(),
780                    }],
781                },
782            )
783            .unwrap();
784            assert_eq!(update_at(cwd, &[], |_, _| panic!("invalid registry")), 1);
785            assert!(!cwd.join(".rpi/packages/project-extension").exists());
786        });
787    }
788
789    #[test]
790    fn package_names_reject_path_traversal() {
791        assert!(valid_name("review-tools"));
792        assert!(!valid_name("../review-tools"));
793        assert!(!valid_name("review/tools"));
794        assert!(!valid_name(""));
795    }
796
797    #[test]
798    fn update_help_is_available_without_a_registry() {
799        print_update_help();
800    }
801
802    #[test]
803    fn dynamic_library_extensions_are_platform_neutral() {
804        assert!(is_dynamic(Path::new("plugin.dll")));
805        assert!(is_dynamic(Path::new("libplugin.so")));
806        assert!(is_dynamic(Path::new("libplugin.dylib")));
807        assert!(!is_dynamic(Path::new("plugin.exe")));
808    }
809}