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)]
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
31/// Extension management deliberately exposes only the shared update operation.
32pub 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    // Check an explicit identity before Cargo can execute a build script.
136    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    // Cargo can execute build scripts. Recheck the addressed write paths after
192    // it returns, before creating directories or removing/copying any artifact.
193    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    // Never follow/truncate a pre-existing fixed staging path (including a
215    // hardlink). Only clean up a temporary file this invocation created.
216    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, &registry) {
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    // Global extensions installed by `rpi install` are also packages. This is
378    // the path used by crates.io extensions such as rpi-im-message; delegate to
379    // the existing transactional installer so artifact and registry updates
380    // remain atomic and version resolution stays in one place.
381    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, &registry) {
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        // Cargo returns normalized manifest paths. --path may contain `..` or
601        // a Windows extended-length prefix; compare normalized paths on both
602        // sides when choosing among several provider packages in a workspace.
603        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 &registry.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            // Reproduce the empty project registry from the bug report. Neither
839            // the prebuilt source marker nor the lack of project links should
840            // hide an installed remote package.
841            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}