1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11pub const BUILTIN_PLUGINS: &[(&str, &str)] = &[
18 ("agent", "Agent spawn/stop/list/logs, /spawn personalities"),
19 ("queue", "FIFO message queue (push/pop/peek/list/clear)"),
20 ("stats", "Room statistics (message counts, uptime)"),
21 (
22 "taskboard",
23 "Task lifecycle management (post/claim/plan/approve/finish)",
24 ),
25];
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct PluginMeta {
34 pub name: String,
36 pub crate_name: String,
38 pub version: String,
40 pub min_protocol: String,
42 pub lib_file: String,
44}
45
46pub fn resolve_crate_name(name: &str) -> String {
53 if name.starts_with("room-plugin-") {
54 name.to_owned()
55 } else {
56 format!("room-plugin-{name}")
57 }
58}
59
60pub fn short_name(crate_name: &str) -> String {
64 crate_name
65 .strip_prefix("room-plugin-")
66 .unwrap_or(crate_name)
67 .to_owned()
68}
69
70pub fn lib_filename(crate_name: &str) -> String {
75 let stem = crate_name.replace('-', "_");
76 let ext = if cfg!(target_os = "macos") {
77 "dylib"
78 } else {
79 "so"
80 };
81 format!("lib{stem}.{ext}")
82}
83
84pub fn meta_path(plugins_dir: &Path, name: &str) -> PathBuf {
86 plugins_dir.join(format!("{name}.meta.json"))
87}
88
89pub fn scan_installed(plugins_dir: &Path) -> Vec<PluginMeta> {
93 let entries = match std::fs::read_dir(plugins_dir) {
94 Ok(e) => e,
95 Err(_) => return vec![],
96 };
97 let mut metas = Vec::new();
98 for entry in entries.flatten() {
99 let path = entry.path();
100 if path.extension().and_then(|e| e.to_str()) == Some("json")
101 && path
102 .file_name()
103 .and_then(|n| n.to_str())
104 .is_some_and(|n| n.ends_with(".meta.json"))
105 {
106 if let Ok(data) = std::fs::read_to_string(&path) {
107 if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
108 metas.push(meta);
109 }
110 }
111 }
112 }
113 metas.sort_by(|a, b| a.name.cmp(&b.name));
114 metas
115}
116
117pub fn cmd_install(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
127 let crate_name = resolve_crate_name(name);
128 let short = short_name(&crate_name);
129
130 let existing_meta = meta_path(plugins_dir, &short);
132 if existing_meta.exists() {
133 if let Ok(data) = std::fs::read_to_string(&existing_meta) {
134 if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
135 eprintln!(
136 "plugin '{}' v{} is already installed — use `room plugin update {}` to upgrade",
137 short, meta.version, short
138 );
139 return Ok(());
140 }
141 }
142 }
143
144 std::fs::create_dir_all(plugins_dir)?;
146
147 let build_dir = tempfile::TempDir::new()?;
149 eprintln!("installing {crate_name}...");
150
151 let mut cmd = std::process::Command::new("cargo");
152 cmd.args(["install", "--root"])
153 .arg(build_dir.path())
154 .args(["--target-dir"])
155 .arg(build_dir.path().join("target"))
156 .arg(&crate_name);
157
158 if let Some(v) = version {
159 cmd.args(["--version", v]);
160 }
161
162 let output = cmd
163 .output()
164 .map_err(|e| anyhow::anyhow!("failed to run cargo install: {e} — is cargo on PATH?"))?;
165
166 if !output.status.success() {
167 let stderr = String::from_utf8_lossy(&output.stderr);
168 anyhow::bail!("cargo install failed:\n{stderr}");
169 }
170
171 let lib_name = lib_filename(&crate_name);
173 let built_lib = find_built_lib(build_dir.path(), &lib_name)?;
174
175 let dest_lib = plugins_dir.join(&lib_name);
177 std::fs::copy(&built_lib, &dest_lib).map_err(|e| {
178 anyhow::anyhow!(
179 "failed to copy {} to {}: {e}",
180 built_lib.display(),
181 dest_lib.display()
182 )
183 })?;
184
185 let installed_version = version.unwrap_or("latest").to_owned();
187
188 let meta = PluginMeta {
190 name: short.to_owned(),
191 crate_name: crate_name.clone(),
192 version: installed_version,
193 min_protocol: "0.0.0".to_owned(),
194 lib_file: lib_name,
195 };
196 let meta_file = meta_path(plugins_dir, &short);
197 std::fs::write(&meta_file, serde_json::to_string_pretty(&meta)?)?;
198
199 eprintln!("installed plugin '{}' to {}", short, dest_lib.display());
200 Ok(())
201}
202
203pub fn cmd_list(plugins_dir: &Path) -> anyhow::Result<()> {
205 let externals = scan_installed(plugins_dir);
206
207 let header = format!(
208 "{:<16} {:<12} {:<10} {}",
209 "NAME", "VERSION", "SOURCE", "DESCRIPTION"
210 );
211 println!("{header}");
212
213 let version = env!("CARGO_PKG_VERSION");
215 for (name, description) in BUILTIN_PLUGINS {
216 println!(
217 "{:<16} {:<12} {:<10} {}",
218 name, version, "[builtin]", description
219 );
220 }
221
222 for m in &externals {
224 println!(
225 "{:<16} {:<12} {:<10} {}",
226 m.name, m.version, "[external]", m.crate_name
227 );
228 }
229
230 Ok(())
231}
232
233pub fn cmd_remove(plugins_dir: &Path, name: &str) -> anyhow::Result<()> {
235 let short = short_name(&resolve_crate_name(name));
236 let meta_file = meta_path(plugins_dir, &short);
237
238 if !meta_file.exists() {
239 anyhow::bail!("plugin '{}' is not installed", short);
240 }
241
242 let data = std::fs::read_to_string(&meta_file)?;
244 let meta: PluginMeta = serde_json::from_str(&data)?;
245
246 let lib_path = plugins_dir.join(&meta.lib_file);
248 if lib_path.exists() {
249 std::fs::remove_file(&lib_path)?;
250 }
251
252 std::fs::remove_file(&meta_file)?;
254
255 eprintln!("removed plugin '{}'", short);
256 Ok(())
257}
258
259pub fn cmd_update(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
261 let short = short_name(&resolve_crate_name(name));
262 let meta_file = meta_path(plugins_dir, &short);
263
264 if !meta_file.exists() {
265 anyhow::bail!(
266 "plugin '{}' is not installed — use `room plugin install {}` first",
267 short,
268 short
269 );
270 }
271
272 cmd_remove(plugins_dir, name)?;
274 cmd_install(plugins_dir, name, version)?;
275 eprintln!("updated plugin '{}'", short);
276 Ok(())
277}
278
279fn find_built_lib(build_dir: &Path, lib_name: &str) -> anyhow::Result<PathBuf> {
283 for entry in walkdir(build_dir) {
286 if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
287 if name == lib_name {
288 return Ok(entry);
289 }
290 }
291 }
292 anyhow::bail!(
293 "built library '{}' not found in {}",
294 lib_name,
295 build_dir.display()
296 )
297}
298
299fn walkdir(dir: &Path) -> Vec<PathBuf> {
301 let mut results = Vec::new();
302 if let Ok(entries) = std::fs::read_dir(dir) {
303 for entry in entries.flatten() {
304 let path = entry.path();
305 if path.is_dir() {
306 results.extend(walkdir(&path));
307 } else {
308 results.push(path);
309 }
310 }
311 }
312 results
313}
314
315#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
324 fn resolve_short_name_prepends_prefix() {
325 assert_eq!(resolve_crate_name("agent"), "room-plugin-agent");
326 }
327
328 #[test]
329 fn resolve_full_name_unchanged() {
330 assert_eq!(
331 resolve_crate_name("room-plugin-taskboard"),
332 "room-plugin-taskboard"
333 );
334 }
335
336 #[test]
337 fn resolve_hyphenated_name() {
338 assert_eq!(resolve_crate_name("my-custom"), "room-plugin-my-custom");
339 }
340
341 #[test]
344 fn short_name_strips_prefix() {
345 assert_eq!(short_name("room-plugin-agent"), "agent");
346 }
347
348 #[test]
349 fn short_name_no_prefix() {
350 assert_eq!(short_name("custom"), "custom");
351 }
352
353 #[test]
356 fn lib_filename_replaces_hyphens() {
357 let name = lib_filename("room-plugin-agent");
358 assert!(name.starts_with("libroom_plugin_agent."));
359 assert!(name.ends_with(".so") || name.ends_with(".dylib"));
361 }
362
363 #[test]
366 fn meta_roundtrip() {
367 let meta = PluginMeta {
368 name: "agent".to_owned(),
369 crate_name: "room-plugin-agent".to_owned(),
370 version: "3.4.0".to_owned(),
371 min_protocol: "3.0.0".to_owned(),
372 lib_file: "libroom_plugin_agent.so".to_owned(),
373 };
374 let json = serde_json::to_string(&meta).unwrap();
375 let parsed: PluginMeta = serde_json::from_str(&json).unwrap();
376 assert_eq!(parsed, meta);
377 }
378
379 #[test]
380 fn meta_pretty_print() {
381 let meta = PluginMeta {
382 name: "taskboard".to_owned(),
383 crate_name: "room-plugin-taskboard".to_owned(),
384 version: "1.0.0".to_owned(),
385 min_protocol: "0.0.0".to_owned(),
386 lib_file: "libroom_plugin_taskboard.so".to_owned(),
387 };
388 let json = serde_json::to_string_pretty(&meta).unwrap();
389 assert!(json.contains("\"name\": \"taskboard\""));
390 assert!(json.contains("\"version\": \"1.0.0\""));
391 }
392
393 #[test]
396 fn scan_empty_dir() {
397 let dir = tempfile::TempDir::new().unwrap();
398 let result = scan_installed(dir.path());
399 assert!(result.is_empty());
400 }
401
402 #[test]
403 fn scan_nonexistent_dir() {
404 let result = scan_installed(Path::new("/nonexistent/plugins"));
405 assert!(result.is_empty());
406 }
407
408 #[test]
409 fn scan_finds_valid_meta_files() {
410 let dir = tempfile::TempDir::new().unwrap();
411 let meta = PluginMeta {
412 name: "test".to_owned(),
413 crate_name: "room-plugin-test".to_owned(),
414 version: "0.1.0".to_owned(),
415 min_protocol: "0.0.0".to_owned(),
416 lib_file: "libroom_plugin_test.so".to_owned(),
417 };
418 let meta_file = dir.path().join("test.meta.json");
419 std::fs::write(&meta_file, serde_json::to_string(&meta).unwrap()).unwrap();
420
421 let result = scan_installed(dir.path());
422 assert_eq!(result.len(), 1);
423 assert_eq!(result[0].name, "test");
424 }
425
426 #[test]
427 fn scan_skips_invalid_json() {
428 let dir = tempfile::TempDir::new().unwrap();
429 std::fs::write(dir.path().join("bad.meta.json"), "not json").unwrap();
430 let result = scan_installed(dir.path());
431 assert!(result.is_empty());
432 }
433
434 #[test]
435 fn scan_skips_non_meta_json() {
436 let dir = tempfile::TempDir::new().unwrap();
437 std::fs::write(dir.path().join("config.json"), "{}").unwrap();
438 let result = scan_installed(dir.path());
439 assert!(result.is_empty());
440 }
441
442 #[test]
443 fn scan_sorts_by_name() {
444 let dir = tempfile::TempDir::new().unwrap();
445 for name in &["zebra", "alpha", "mid"] {
446 let meta = PluginMeta {
447 name: name.to_string(),
448 crate_name: format!("room-plugin-{name}"),
449 version: "0.1.0".to_owned(),
450 min_protocol: "0.0.0".to_owned(),
451 lib_file: format!("libroom_plugin_{name}.so"),
452 };
453 std::fs::write(
454 dir.path().join(format!("{name}.meta.json")),
455 serde_json::to_string(&meta).unwrap(),
456 )
457 .unwrap();
458 }
459 let result = scan_installed(dir.path());
460 let names: Vec<&str> = result.iter().map(|m| m.name.as_str()).collect();
461 assert_eq!(names, vec!["alpha", "mid", "zebra"]);
462 }
463
464 #[test]
467 fn meta_path_format() {
468 let p = meta_path(Path::new("/home/user/.room/plugins"), "agent");
469 assert_eq!(p, PathBuf::from("/home/user/.room/plugins/agent.meta.json"));
470 }
471
472 #[test]
475 fn remove_nonexistent_plugin_fails() {
476 let dir = tempfile::TempDir::new().unwrap();
477 let result = cmd_remove(dir.path(), "nonexistent");
478 assert!(result.is_err());
479 assert!(result.unwrap_err().to_string().contains("not installed"));
480 }
481
482 #[test]
483 fn remove_deletes_lib_and_meta() {
484 let dir = tempfile::TempDir::new().unwrap();
485 let meta = PluginMeta {
486 name: "test".to_owned(),
487 crate_name: "room-plugin-test".to_owned(),
488 version: "0.1.0".to_owned(),
489 min_protocol: "0.0.0".to_owned(),
490 lib_file: "libroom_plugin_test.so".to_owned(),
491 };
492 std::fs::write(
493 dir.path().join("test.meta.json"),
494 serde_json::to_string(&meta).unwrap(),
495 )
496 .unwrap();
497 std::fs::write(dir.path().join("libroom_plugin_test.so"), b"fake").unwrap();
498
499 cmd_remove(dir.path(), "test").unwrap();
500 assert!(!dir.path().join("test.meta.json").exists());
501 assert!(!dir.path().join("libroom_plugin_test.so").exists());
502 }
503
504 #[test]
507 fn walkdir_finds_nested_files() {
508 let dir = tempfile::TempDir::new().unwrap();
509 let nested = dir.path().join("a").join("b");
510 std::fs::create_dir_all(&nested).unwrap();
511 std::fs::write(nested.join("target.so"), b"lib").unwrap();
512 std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
513
514 let files = walkdir(dir.path());
515 assert!(files.iter().any(|p| p.ends_with("target.so")));
516 assert!(files.iter().any(|p| p.ends_with("top.txt")));
517 }
518
519 #[test]
520 fn walkdir_empty_dir() {
521 let dir = tempfile::TempDir::new().unwrap();
522 let files = walkdir(dir.path());
523 assert!(files.is_empty());
524 }
525
526 #[test]
529 fn find_built_lib_success() {
530 let dir = tempfile::TempDir::new().unwrap();
531 let release = dir.path().join("target").join("release");
532 std::fs::create_dir_all(&release).unwrap();
533 std::fs::write(release.join("libroom_plugin_test.so"), b"elf").unwrap();
534
535 let result = find_built_lib(dir.path(), "libroom_plugin_test.so");
536 assert!(result.is_ok());
537 assert!(result.unwrap().ends_with("libroom_plugin_test.so"));
538 }
539
540 #[test]
541 fn find_built_lib_not_found() {
542 let dir = tempfile::TempDir::new().unwrap();
543 let result = find_built_lib(dir.path(), "nonexistent.so");
544 assert!(result.is_err());
545 assert!(result.unwrap_err().to_string().contains("not found"));
546 }
547
548 #[test]
551 fn install_skips_when_already_installed() {
552 let dir = tempfile::TempDir::new().unwrap();
553 let meta = PluginMeta {
554 name: "existing".to_owned(),
555 crate_name: "room-plugin-existing".to_owned(),
556 version: "1.0.0".to_owned(),
557 min_protocol: "0.0.0".to_owned(),
558 lib_file: "libroom_plugin_existing.so".to_owned(),
559 };
560 std::fs::write(
561 dir.path().join("existing.meta.json"),
562 serde_json::to_string(&meta).unwrap(),
563 )
564 .unwrap();
565
566 let result = cmd_install(dir.path(), "existing", None);
568 assert!(result.is_ok());
569 }
570
571 #[test]
574 fn update_nonexistent_plugin_fails() {
575 let dir = tempfile::TempDir::new().unwrap();
576 let result = cmd_update(dir.path(), "nonexistent", None);
577 assert!(result.is_err());
578 assert!(result.unwrap_err().to_string().contains("not installed"));
579 }
580
581 #[test]
584 fn builtin_plugins_has_four_entries() {
585 assert_eq!(BUILTIN_PLUGINS.len(), 4);
586 }
587
588 #[test]
589 fn builtin_plugins_includes_expected_names() {
590 let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
591 assert!(names.contains(&"agent"));
592 assert!(names.contains(&"taskboard"));
593 assert!(names.contains(&"queue"));
594 assert!(names.contains(&"stats"));
595 }
596
597 #[test]
598 fn builtin_plugins_sorted_alphabetically() {
599 let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
600 let mut sorted = names.clone();
601 sorted.sort();
602 assert_eq!(names, sorted);
603 }
604}