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        // Cargo returns normalized manifest paths. --path may contain `..` or
491        // a Windows extended-length prefix; compare normalized paths on both
492        // sides when choosing among several provider packages in a workspace.
493        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            // Reproduce the empty project registry from the bug report. Neither
689            // the prebuilt source marker nor the lack of project links should
690            // hide an installed remote package.
691            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}