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 mut name = None;
224    let mut link_args = Vec::new();
225    let mut i = 0;
226    while i < args.len() {
227        match args[i].as_str() {
228            "--debug" | "--locked" => link_args.push(args[i].clone()),
229            "--help" | "-h" => {
230                print_update_help();
231                return 0;
232            }
233            value if value.starts_with('-') => {
234                eprintln!("error: unknown update option `{value}`");
235                return 2;
236            }
237            value => {
238                if name.replace(value.to_string()).is_some() {
239                    eprintln!("error: update accepts at most one package name");
240                    return 2;
241                }
242            }
243        }
244        i += 1;
245    }
246
247    let cwd = match std::env::current_dir() {
248        Ok(p) => p,
249        Err(e) => {
250            eprintln!("error: {e}");
251            return 1;
252        }
253    };
254    let registry = match read_registry(&cwd) {
255        Ok(r) => r,
256        Err(e) => {
257            eprintln!("error: {e}");
258            return 1;
259        }
260    };
261    let packages = registry
262        .packages
263        .iter()
264        .filter(|package| {
265            name.as_deref()
266                .map_or(true, |wanted| wanted == package.name)
267        })
268        .cloned()
269        .collect::<Vec<_>>();
270
271    let mut status = 0;
272    for package in packages {
273        let package_name = package.name.clone();
274        eprintln!("package: updating project package `{package_name}`...");
275        let mut args = vec![
276            "--path".to_string(),
277            package.source,
278            "--package".to_string(),
279            package.cargo_package,
280            "--name".to_string(),
281            package.name,
282            "--force".to_string(),
283        ];
284        args.extend(link_args.iter().cloned());
285        if link(&args) != 0 {
286            status = 1;
287        }
288    }
289
290    // Global extensions installed by `rpi install` are also packages. This is
291    // the path used by crates.io extensions such as rpi-im-message; delegate to
292    // the existing transactional installer so artifact and registry updates
293    // remain atomic and version resolution stays in one place.
294    let global_packages = match crate::install::installed_native_packages_strict() {
295        Ok(packages) => packages
296            .into_iter()
297            .filter(|package| {
298                package.source.is_none()
299                    && name
300                        .as_deref()
301                        .map_or(true, |wanted| wanted == package.name)
302            })
303            .collect::<Vec<_>>(),
304        Err(error) => {
305            eprintln!("error: {error}");
306            return 1;
307        }
308    };
309    if let Some(wanted) = name.as_deref() {
310        let project_found = registry
311            .packages
312            .iter()
313            .any(|package| package.name == wanted);
314        let global_found = global_packages.iter().any(|package| package.name == wanted);
315        if !project_found && !global_found {
316            eprintln!("error: package `{wanted}` is not installed");
317            return 1;
318        }
319    }
320    for package in global_packages {
321        eprintln!("package: updating global package `{}`...", package.name);
322        if !update_global_package(&package.name, &link_args) {
323            status = 1;
324        }
325    }
326
327    if status == 0 && registry.packages.is_empty() && name.is_none() {
328        let has_global = crate::install::installed_native_packages()
329            .into_iter()
330            .any(|package| package.source.is_none());
331        if !has_global {
332            println!("no project or global packages to update");
333        }
334    }
335    status
336}
337
338fn update_global_package(name: &str, args: &[String]) -> bool {
339    let executable = match std::env::current_exe() {
340        Ok(path) => path,
341        Err(error) => {
342            eprintln!("error: could not resolve rpi executable: {error}");
343            return false;
344        }
345    };
346    let mut command = std::process::Command::new(executable);
347    command.args(["install", name, "--force"]);
348    if args.iter().any(|arg| arg == "--locked") {
349        command.arg("--locked");
350    }
351    match command.status() {
352        Ok(exit) if exit.success() => true,
353        Ok(exit) => {
354            eprintln!("error: updating global package `{name}` failed with {exit}");
355            false
356        }
357        Err(error) => {
358            eprintln!("error: could not run rpi install for `{name}`: {error}");
359            false
360        }
361    }
362}
363
364fn unlink(args: &[String]) -> i32 {
365    let Some(name) = args.first() else {
366        eprintln!("error: missing package name");
367        return 2;
368    };
369    if args.len() != 1 || !valid_name(name) {
370        eprintln!("error: invalid package name or arguments");
371        return 2;
372    }
373    let cwd = match std::env::current_dir() {
374        Ok(p) => p,
375        Err(e) => {
376            eprintln!("error: {e}");
377            return 1;
378        }
379    };
380    let mut registry = match read_registry(&cwd) {
381        Ok(r) => r,
382        Err(e) => {
383            eprintln!("error: {e}");
384            return 1;
385        }
386    };
387    if !registry.packages.iter().any(|p| p.name == *name) {
388        eprintln!("error: project package `{name}` is not installed");
389        return 1;
390    }
391    let dir = cwd.join(".rpi/packages").join(name);
392    if let Err(e) = std::fs::remove_dir_all(&dir) {
393        eprintln!("error: could not remove package: {e}");
394        return 1;
395    }
396    registry.packages.retain(|p| p.name != *name);
397    if let Err(e) = write_registry(&cwd, &registry) {
398        eprintln!("error: {e}");
399        return 1;
400    }
401    println!("unlinked project package `{name}`");
402    0
403}
404
405#[derive(Debug, Clone)]
406struct CargoPackage {
407    name: String,
408    version: String,
409    manifest: PathBuf,
410    target: String,
411}
412#[derive(Deserialize)]
413struct Metadata {
414    packages: Vec<MetadataPackage>,
415}
416#[derive(Deserialize)]
417struct MetadataPackage {
418    name: String,
419    version: String,
420    manifest_path: PathBuf,
421    targets: Vec<Target>,
422}
423#[derive(Deserialize)]
424struct Target {
425    name: String,
426    crate_types: Vec<String>,
427}
428
429fn cargo_metadata(cwd: &Path) -> Result<Metadata, String> {
430    let out = Command::new("cargo")
431        .args(["metadata", "--format-version", "1", "--no-deps"])
432        .current_dir(cwd)
433        .stdin(Stdio::null())
434        .output()
435        .map_err(|e| format!("could not execute cargo metadata: {e}"))?;
436    if !out.status.success() {
437        return Err(format!(
438            "cargo metadata failed:\n{}",
439            String::from_utf8_lossy(&out.stderr).trim()
440        ));
441    }
442    serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid cargo metadata output: {e}"))
443}
444fn select_package(
445    m: &Metadata,
446    requested: Option<&str>,
447    cwd: &Path,
448) -> Result<CargoPackage, String> {
449    let mut found = Vec::new();
450    for p in &m.packages {
451        if requested.is_some_and(|n| n != p.name) {
452            continue;
453        }
454        for t in &p.targets {
455            if t.crate_types.iter().any(|k| k == "cdylib") {
456                found.push(CargoPackage {
457                    name: p.name.clone(),
458                    version: p.version.clone(),
459                    manifest: p.manifest_path.clone(),
460                    target: t.name.clone(),
461                });
462            }
463        }
464    }
465    if requested.is_none() {
466        let mut local = found
467            .iter()
468            .filter(|p| p.manifest.parent().is_some_and(|r| cwd.starts_with(r)))
469            .cloned()
470            .collect::<Vec<_>>();
471        if local.len() == 1 {
472            return Ok(local.remove(0));
473        }
474    }
475    match found.len() {
476        1 => Ok(found.remove(0)),
477        0 => Err("no cdylib package found; use --package to select one".into()),
478        _ => Err(format!(
479            "multiple cdylib packages found: {}; use --package",
480            found
481                .iter()
482                .map(|p| p.name.as_str())
483                .collect::<Vec<_>>()
484                .join(", ")
485        )),
486    }
487}
488fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
489    let mut c = Command::new("cargo");
490    c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
491        "--package",
492        &p.name,
493        "--message-format=json-render-diagnostics",
494    ]);
495    if release {
496        c.arg("--release");
497    }
498    if locked {
499        c.arg("--locked");
500    }
501    c.stdout(Stdio::piped()).stderr(Stdio::piped());
502    let out = c
503        .output()
504        .map_err(|e| format!("could not execute cargo build: {e}"))?;
505    let mut artifact = None;
506    for line in String::from_utf8_lossy(&out.stdout).lines() {
507        if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
508            if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
509                && v.get("target")
510                    .and_then(|v| v.get("name"))
511                    .and_then(|v| v.as_str())
512                    == Some(&p.target)
513            {
514                artifact = v
515                    .get("filenames")
516                    .and_then(|v| v.as_array())
517                    .into_iter()
518                    .flatten()
519                    .filter_map(|v| v.as_str())
520                    .map(PathBuf::from)
521                    .find(|p| is_dynamic(p));
522            }
523        }
524    }
525    eprint!("{}", String::from_utf8_lossy(&out.stderr));
526    if !out.status.success() {
527        return Err(format!("cargo build exited with {}", out.status));
528    }
529    artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
530}
531fn is_dynamic(p: &Path) -> bool {
532    matches!(
533        p.extension()
534            .and_then(|x| x.to_str())
535            .map(|x| x.to_ascii_lowercase())
536            .as_deref(),
537        Some("dll" | "so" | "dylib")
538    )
539}
540fn valid_name(s: &str) -> bool {
541    !s.is_empty()
542        && s.bytes()
543            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
544}
545fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
546    match args.get(i) {
547        Some(v) if !v.starts_with('-') => Some(v.clone()),
548        _ => {
549            eprintln!("error: {flag} requires a value");
550            None
551        }
552    }
553}
554fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
555    let path = cwd.join(".rpi").join(REGISTRY);
556    match std::fs::read_to_string(path) {
557        Ok(s) => {
558            serde_json::from_str(&s).map_err(|e| format!("invalid project package registry: {e}"))
559        }
560        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PackageRegistry::default()),
561        Err(e) => Err(e.to_string()),
562    }
563}
564fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
565    let dir = cwd.join(".rpi");
566    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
567    let path = dir.join(REGISTRY);
568    let tmp = dir.join(".packages.json.tmp");
569    let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
570    std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
571    std::fs::rename(tmp, path).map_err(|e| e.to_string())
572}
573pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
574    match read_registry(cwd) {
575        Ok(registry) => registry
576            .packages
577            .into_iter()
578            .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
579            .collect(),
580        Err(error) => {
581            eprintln!("warning: could not load project package registry: {error}");
582            Vec::new()
583        }
584    }
585}
586pub fn print_help() {
587    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");
588}
589fn print_link_help() {
590    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");
591}
592fn print_update_help() {
593    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.");
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn package_names_reject_path_traversal() {
602        assert!(valid_name("review-tools"));
603        assert!(!valid_name("../review-tools"));
604        assert!(!valid_name("review/tools"));
605        assert!(!valid_name(""));
606    }
607
608    #[test]
609    fn update_help_is_available_without_a_registry() {
610        print_update_help();
611    }
612
613    #[test]
614    fn dynamic_library_extensions_are_platform_neutral() {
615        assert!(is_dynamic(Path::new("plugin.dll")));
616        assert!(is_dynamic(Path::new("libplugin.so")));
617        assert!(is_dynamic(Path::new("libplugin.dylib")));
618        assert!(!is_dynamic(Path::new("plugin.exe")));
619    }
620}