Skip to main content

room_cli/
plugin_cmd.rs

1//! Plugin management commands: install, list, remove, update.
2//!
3//! These are client-side operations that manage the `~/.room/plugins/`
4//! directory. The daemon loads plugins from this directory on startup
5//! using `libloading`.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11// ── Plugin metadata ─────────────────────────────────────────────────────────
12
13/// Metadata written alongside each installed plugin shared library.
14///
15/// Stored as `<name>.meta.json` in `~/.room/plugins/`.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct PluginMeta {
18    /// Short plugin name (e.g. `"agent"`, `"taskboard"`).
19    pub name: String,
20    /// Crate name on crates.io (e.g. `"room-plugin-agent"`).
21    pub crate_name: String,
22    /// Installed version (semver).
23    pub version: String,
24    /// Minimum compatible `room-protocol` version (semver).
25    pub min_protocol: String,
26    /// Shared library filename (e.g. `"libroom_plugin_agent.so"`).
27    pub lib_file: String,
28}
29
30// ── Name resolution ─────────────────────────────────────────────────────────
31
32/// Resolve a user-supplied plugin name to the crate name on crates.io.
33///
34/// If the name already starts with `room-plugin-`, it is returned as-is.
35/// Otherwise, `room-plugin-` is prepended.
36pub fn resolve_crate_name(name: &str) -> String {
37    if name.starts_with("room-plugin-") {
38        name.to_owned()
39    } else {
40        format!("room-plugin-{name}")
41    }
42}
43
44/// Derive the short plugin name from a crate name.
45///
46/// Strips the `room-plugin-` prefix if present.
47pub fn short_name(crate_name: &str) -> String {
48    crate_name
49        .strip_prefix("room-plugin-")
50        .unwrap_or(crate_name)
51        .to_owned()
52}
53
54/// Compute the expected shared library filename for a plugin crate.
55///
56/// Cargo produces `lib<crate_name_underscored>.so` on Linux and
57/// `lib<crate_name_underscored>.dylib` on macOS.
58pub fn lib_filename(crate_name: &str) -> String {
59    let stem = crate_name.replace('-', "_");
60    let ext = if cfg!(target_os = "macos") {
61        "dylib"
62    } else {
63        "so"
64    };
65    format!("lib{stem}.{ext}")
66}
67
68/// Path to the `.meta.json` file for a plugin.
69pub fn meta_path(plugins_dir: &Path, name: &str) -> PathBuf {
70    plugins_dir.join(format!("{name}.meta.json"))
71}
72
73// ── Scanning installed plugins ──────────────────────────────────────────────
74
75/// Scan the plugins directory and return metadata for all installed plugins.
76pub fn scan_installed(plugins_dir: &Path) -> Vec<PluginMeta> {
77    let entries = match std::fs::read_dir(plugins_dir) {
78        Ok(e) => e,
79        Err(_) => return vec![],
80    };
81    let mut metas = Vec::new();
82    for entry in entries.flatten() {
83        let path = entry.path();
84        if path.extension().and_then(|e| e.to_str()) == Some("json")
85            && path
86                .file_name()
87                .and_then(|n| n.to_str())
88                .is_some_and(|n| n.ends_with(".meta.json"))
89        {
90            if let Ok(data) = std::fs::read_to_string(&path) {
91                if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
92                    metas.push(meta);
93                }
94            }
95        }
96    }
97    metas.sort_by(|a, b| a.name.cmp(&b.name));
98    metas
99}
100
101// ── Commands ────────────────────────────────────────────────────────────────
102
103/// Install a plugin from crates.io.
104///
105/// 1. Resolves the crate name from the short name.
106/// 2. Creates a temporary directory and runs `cargo install` with
107///    `--target-dir` to build the cdylib.
108/// 3. Copies the shared library to `~/.room/plugins/`.
109/// 4. Writes `.meta.json` alongside it.
110pub fn cmd_install(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
111    let crate_name = resolve_crate_name(name);
112    let short = short_name(&crate_name);
113
114    // Check if already installed.
115    let existing_meta = meta_path(plugins_dir, &short);
116    if existing_meta.exists() {
117        if let Ok(data) = std::fs::read_to_string(&existing_meta) {
118            if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
119                eprintln!(
120                    "plugin '{}' v{} is already installed — use `room plugin update {}` to upgrade",
121                    short, meta.version, short
122                );
123                return Ok(());
124            }
125        }
126    }
127
128    // Ensure plugins directory exists.
129    std::fs::create_dir_all(plugins_dir)?;
130
131    // Build in a temp directory.
132    let build_dir = tempfile::TempDir::new()?;
133    eprintln!("installing {crate_name}...");
134
135    let mut cmd = std::process::Command::new("cargo");
136    cmd.args(["install", "--root"])
137        .arg(build_dir.path())
138        .args(["--target-dir"])
139        .arg(build_dir.path().join("target"))
140        .arg(&crate_name);
141
142    if let Some(v) = version {
143        cmd.args(["--version", v]);
144    }
145
146    let output = cmd
147        .output()
148        .map_err(|e| anyhow::anyhow!("failed to run cargo install: {e} — is cargo on PATH?"))?;
149
150    if !output.status.success() {
151        let stderr = String::from_utf8_lossy(&output.stderr);
152        anyhow::bail!("cargo install failed:\n{stderr}");
153    }
154
155    // Find the built shared library.
156    let lib_name = lib_filename(&crate_name);
157    let built_lib = find_built_lib(build_dir.path(), &lib_name)?;
158
159    // Copy to plugins directory.
160    let dest_lib = plugins_dir.join(&lib_name);
161    std::fs::copy(&built_lib, &dest_lib).map_err(|e| {
162        anyhow::anyhow!(
163            "failed to copy {} to {}: {e}",
164            built_lib.display(),
165            dest_lib.display()
166        )
167    })?;
168
169    // Extract version from cargo output or default.
170    let installed_version = version.unwrap_or("latest").to_owned();
171
172    // Write metadata.
173    let meta = PluginMeta {
174        name: short.to_owned(),
175        crate_name: crate_name.clone(),
176        version: installed_version,
177        min_protocol: "0.0.0".to_owned(),
178        lib_file: lib_name,
179    };
180    let meta_file = meta_path(plugins_dir, &short);
181    std::fs::write(&meta_file, serde_json::to_string_pretty(&meta)?)?;
182
183    eprintln!("installed plugin '{}' to {}", short, dest_lib.display());
184    Ok(())
185}
186
187/// List installed plugins.
188pub fn cmd_list(plugins_dir: &Path) -> anyhow::Result<()> {
189    let metas = scan_installed(plugins_dir);
190    if metas.is_empty() {
191        println!("no plugins installed");
192        return Ok(());
193    }
194    println!("{:<16} {:<12} {:<28} LIB", "NAME", "VERSION", "CRATE");
195    for m in &metas {
196        println!(
197            "{:<16} {:<12} {:<28} {}",
198            m.name, m.version, m.crate_name, m.lib_file
199        );
200    }
201    Ok(())
202}
203
204/// Remove an installed plugin.
205pub fn cmd_remove(plugins_dir: &Path, name: &str) -> anyhow::Result<()> {
206    let short = short_name(&resolve_crate_name(name));
207    let meta_file = meta_path(plugins_dir, &short);
208
209    if !meta_file.exists() {
210        anyhow::bail!("plugin '{}' is not installed", short);
211    }
212
213    // Read meta to find the lib file.
214    let data = std::fs::read_to_string(&meta_file)?;
215    let meta: PluginMeta = serde_json::from_str(&data)?;
216
217    // Remove the shared library.
218    let lib_path = plugins_dir.join(&meta.lib_file);
219    if lib_path.exists() {
220        std::fs::remove_file(&lib_path)?;
221    }
222
223    // Remove the meta file.
224    std::fs::remove_file(&meta_file)?;
225
226    eprintln!("removed plugin '{}'", short);
227    Ok(())
228}
229
230/// Update an installed plugin to the latest (or specified) version.
231pub fn cmd_update(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
232    let short = short_name(&resolve_crate_name(name));
233    let meta_file = meta_path(plugins_dir, &short);
234
235    if !meta_file.exists() {
236        anyhow::bail!(
237            "plugin '{}' is not installed — use `room plugin install {}` first",
238            short,
239            short
240        );
241    }
242
243    // Remove old installation, then reinstall.
244    cmd_remove(plugins_dir, name)?;
245    cmd_install(plugins_dir, name, version)?;
246    eprintln!("updated plugin '{}'", short);
247    Ok(())
248}
249
250// ── Helpers ─────────────────────────────────────────────────────────────────
251
252/// Search a build directory tree for a shared library matching the expected name.
253fn find_built_lib(build_dir: &Path, lib_name: &str) -> anyhow::Result<PathBuf> {
254    // cargo install --root puts the library in target/release/deps/ or similar.
255    // Walk the tree looking for the file.
256    for entry in walkdir(build_dir) {
257        if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
258            if name == lib_name {
259                return Ok(entry);
260            }
261        }
262    }
263    anyhow::bail!(
264        "built library '{}' not found in {}",
265        lib_name,
266        build_dir.display()
267    )
268}
269
270/// Simple recursive directory walker (avoids adding a `walkdir` dependency).
271fn walkdir(dir: &Path) -> Vec<PathBuf> {
272    let mut results = Vec::new();
273    if let Ok(entries) = std::fs::read_dir(dir) {
274        for entry in entries.flatten() {
275            let path = entry.path();
276            if path.is_dir() {
277                results.extend(walkdir(&path));
278            } else {
279                results.push(path);
280            }
281        }
282    }
283    results
284}
285
286// ── Tests ───────────────────────────────────────────────────────────────────
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    // ── resolve_crate_name ──────────────────────────────────────────────
293
294    #[test]
295    fn resolve_short_name_prepends_prefix() {
296        assert_eq!(resolve_crate_name("agent"), "room-plugin-agent");
297    }
298
299    #[test]
300    fn resolve_full_name_unchanged() {
301        assert_eq!(
302            resolve_crate_name("room-plugin-taskboard"),
303            "room-plugin-taskboard"
304        );
305    }
306
307    #[test]
308    fn resolve_hyphenated_name() {
309        assert_eq!(resolve_crate_name("my-custom"), "room-plugin-my-custom");
310    }
311
312    // ── short_name ──────────────────────────────────────────────────────
313
314    #[test]
315    fn short_name_strips_prefix() {
316        assert_eq!(short_name("room-plugin-agent"), "agent");
317    }
318
319    #[test]
320    fn short_name_no_prefix() {
321        assert_eq!(short_name("custom"), "custom");
322    }
323
324    // ── lib_filename ────────────────────────────────────────────────────
325
326    #[test]
327    fn lib_filename_replaces_hyphens() {
328        let name = lib_filename("room-plugin-agent");
329        assert!(name.starts_with("libroom_plugin_agent."));
330        // Extension is platform-dependent.
331        assert!(name.ends_with(".so") || name.ends_with(".dylib"));
332    }
333
334    // ── PluginMeta serialization ────────────────────────────────────────
335
336    #[test]
337    fn meta_roundtrip() {
338        let meta = PluginMeta {
339            name: "agent".to_owned(),
340            crate_name: "room-plugin-agent".to_owned(),
341            version: "3.4.0".to_owned(),
342            min_protocol: "3.0.0".to_owned(),
343            lib_file: "libroom_plugin_agent.so".to_owned(),
344        };
345        let json = serde_json::to_string(&meta).unwrap();
346        let parsed: PluginMeta = serde_json::from_str(&json).unwrap();
347        assert_eq!(parsed, meta);
348    }
349
350    #[test]
351    fn meta_pretty_print() {
352        let meta = PluginMeta {
353            name: "taskboard".to_owned(),
354            crate_name: "room-plugin-taskboard".to_owned(),
355            version: "1.0.0".to_owned(),
356            min_protocol: "0.0.0".to_owned(),
357            lib_file: "libroom_plugin_taskboard.so".to_owned(),
358        };
359        let json = serde_json::to_string_pretty(&meta).unwrap();
360        assert!(json.contains("\"name\": \"taskboard\""));
361        assert!(json.contains("\"version\": \"1.0.0\""));
362    }
363
364    // ── scan_installed ──────────────────────────────────────────────────
365
366    #[test]
367    fn scan_empty_dir() {
368        let dir = tempfile::TempDir::new().unwrap();
369        let result = scan_installed(dir.path());
370        assert!(result.is_empty());
371    }
372
373    #[test]
374    fn scan_nonexistent_dir() {
375        let result = scan_installed(Path::new("/nonexistent/plugins"));
376        assert!(result.is_empty());
377    }
378
379    #[test]
380    fn scan_finds_valid_meta_files() {
381        let dir = tempfile::TempDir::new().unwrap();
382        let meta = PluginMeta {
383            name: "test".to_owned(),
384            crate_name: "room-plugin-test".to_owned(),
385            version: "0.1.0".to_owned(),
386            min_protocol: "0.0.0".to_owned(),
387            lib_file: "libroom_plugin_test.so".to_owned(),
388        };
389        let meta_file = dir.path().join("test.meta.json");
390        std::fs::write(&meta_file, serde_json::to_string(&meta).unwrap()).unwrap();
391
392        let result = scan_installed(dir.path());
393        assert_eq!(result.len(), 1);
394        assert_eq!(result[0].name, "test");
395    }
396
397    #[test]
398    fn scan_skips_invalid_json() {
399        let dir = tempfile::TempDir::new().unwrap();
400        std::fs::write(dir.path().join("bad.meta.json"), "not json").unwrap();
401        let result = scan_installed(dir.path());
402        assert!(result.is_empty());
403    }
404
405    #[test]
406    fn scan_skips_non_meta_json() {
407        let dir = tempfile::TempDir::new().unwrap();
408        std::fs::write(dir.path().join("config.json"), "{}").unwrap();
409        let result = scan_installed(dir.path());
410        assert!(result.is_empty());
411    }
412
413    #[test]
414    fn scan_sorts_by_name() {
415        let dir = tempfile::TempDir::new().unwrap();
416        for name in &["zebra", "alpha", "mid"] {
417            let meta = PluginMeta {
418                name: name.to_string(),
419                crate_name: format!("room-plugin-{name}"),
420                version: "0.1.0".to_owned(),
421                min_protocol: "0.0.0".to_owned(),
422                lib_file: format!("libroom_plugin_{name}.so"),
423            };
424            std::fs::write(
425                dir.path().join(format!("{name}.meta.json")),
426                serde_json::to_string(&meta).unwrap(),
427            )
428            .unwrap();
429        }
430        let result = scan_installed(dir.path());
431        let names: Vec<&str> = result.iter().map(|m| m.name.as_str()).collect();
432        assert_eq!(names, vec!["alpha", "mid", "zebra"]);
433    }
434
435    // ── meta_path ───────────────────────────────────────────────────────
436
437    #[test]
438    fn meta_path_format() {
439        let p = meta_path(Path::new("/home/user/.room/plugins"), "agent");
440        assert_eq!(p, PathBuf::from("/home/user/.room/plugins/agent.meta.json"));
441    }
442
443    // ── cmd_remove ──────────────────────────────────────────────────────
444
445    #[test]
446    fn remove_nonexistent_plugin_fails() {
447        let dir = tempfile::TempDir::new().unwrap();
448        let result = cmd_remove(dir.path(), "nonexistent");
449        assert!(result.is_err());
450        assert!(result.unwrap_err().to_string().contains("not installed"));
451    }
452
453    #[test]
454    fn remove_deletes_lib_and_meta() {
455        let dir = tempfile::TempDir::new().unwrap();
456        let meta = PluginMeta {
457            name: "test".to_owned(),
458            crate_name: "room-plugin-test".to_owned(),
459            version: "0.1.0".to_owned(),
460            min_protocol: "0.0.0".to_owned(),
461            lib_file: "libroom_plugin_test.so".to_owned(),
462        };
463        std::fs::write(
464            dir.path().join("test.meta.json"),
465            serde_json::to_string(&meta).unwrap(),
466        )
467        .unwrap();
468        std::fs::write(dir.path().join("libroom_plugin_test.so"), b"fake").unwrap();
469
470        cmd_remove(dir.path(), "test").unwrap();
471        assert!(!dir.path().join("test.meta.json").exists());
472        assert!(!dir.path().join("libroom_plugin_test.so").exists());
473    }
474
475    // ── walkdir ─────────────────────────────────────────────────────────
476
477    #[test]
478    fn walkdir_finds_nested_files() {
479        let dir = tempfile::TempDir::new().unwrap();
480        let nested = dir.path().join("a").join("b");
481        std::fs::create_dir_all(&nested).unwrap();
482        std::fs::write(nested.join("target.so"), b"lib").unwrap();
483        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
484
485        let files = walkdir(dir.path());
486        assert!(files.iter().any(|p| p.ends_with("target.so")));
487        assert!(files.iter().any(|p| p.ends_with("top.txt")));
488    }
489
490    #[test]
491    fn walkdir_empty_dir() {
492        let dir = tempfile::TempDir::new().unwrap();
493        let files = walkdir(dir.path());
494        assert!(files.is_empty());
495    }
496
497    // ── find_built_lib ──────────────────────────────────────────────────
498
499    #[test]
500    fn find_built_lib_success() {
501        let dir = tempfile::TempDir::new().unwrap();
502        let release = dir.path().join("target").join("release");
503        std::fs::create_dir_all(&release).unwrap();
504        std::fs::write(release.join("libroom_plugin_test.so"), b"elf").unwrap();
505
506        let result = find_built_lib(dir.path(), "libroom_plugin_test.so");
507        assert!(result.is_ok());
508        assert!(result.unwrap().ends_with("libroom_plugin_test.so"));
509    }
510
511    #[test]
512    fn find_built_lib_not_found() {
513        let dir = tempfile::TempDir::new().unwrap();
514        let result = find_built_lib(dir.path(), "nonexistent.so");
515        assert!(result.is_err());
516        assert!(result.unwrap_err().to_string().contains("not found"));
517    }
518
519    // ── cmd_install duplicate check ─────────────────────────────────────
520
521    #[test]
522    fn install_skips_when_already_installed() {
523        let dir = tempfile::TempDir::new().unwrap();
524        let meta = PluginMeta {
525            name: "existing".to_owned(),
526            crate_name: "room-plugin-existing".to_owned(),
527            version: "1.0.0".to_owned(),
528            min_protocol: "0.0.0".to_owned(),
529            lib_file: "libroom_plugin_existing.so".to_owned(),
530        };
531        std::fs::write(
532            dir.path().join("existing.meta.json"),
533            serde_json::to_string(&meta).unwrap(),
534        )
535        .unwrap();
536
537        // Should succeed without error (prints "already installed").
538        let result = cmd_install(dir.path(), "existing", None);
539        assert!(result.is_ok());
540    }
541
542    // ── cmd_update when not installed ───────────────────────────────────
543
544    #[test]
545    fn update_nonexistent_plugin_fails() {
546        let dir = tempfile::TempDir::new().unwrap();
547        let result = cmd_update(dir.path(), "nonexistent", None);
548        assert!(result.is_err());
549        assert!(result.unwrap_err().to_string().contains("not installed"));
550    }
551}