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 println!(
208 "{:<16} {:<12} {:<10} {}",
209 "NAME", "VERSION", "SOURCE", "DESCRIPTION"
210 );
211
212 let version = env!("CARGO_PKG_VERSION");
214 for (name, description) in BUILTIN_PLUGINS {
215 println!(
216 "{:<16} {:<12} {:<10} {}",
217 name, version, "[builtin]", description
218 );
219 }
220
221 for m in &externals {
223 println!(
224 "{:<16} {:<12} {:<10} {}",
225 m.name, m.version, "[external]", m.crate_name
226 );
227 }
228
229 Ok(())
230}
231
232pub fn cmd_remove(plugins_dir: &Path, name: &str) -> anyhow::Result<()> {
234 let short = short_name(&resolve_crate_name(name));
235 let meta_file = meta_path(plugins_dir, &short);
236
237 if !meta_file.exists() {
238 anyhow::bail!("plugin '{}' is not installed", short);
239 }
240
241 let data = std::fs::read_to_string(&meta_file)?;
243 let meta: PluginMeta = serde_json::from_str(&data)?;
244
245 let lib_path = plugins_dir.join(&meta.lib_file);
247 if lib_path.exists() {
248 std::fs::remove_file(&lib_path)?;
249 }
250
251 std::fs::remove_file(&meta_file)?;
253
254 eprintln!("removed plugin '{}'", short);
255 Ok(())
256}
257
258pub fn cmd_update(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
260 let short = short_name(&resolve_crate_name(name));
261 let meta_file = meta_path(plugins_dir, &short);
262
263 if !meta_file.exists() {
264 anyhow::bail!(
265 "plugin '{}' is not installed — use `room plugin install {}` first",
266 short,
267 short
268 );
269 }
270
271 cmd_remove(plugins_dir, name)?;
273 cmd_install(plugins_dir, name, version)?;
274 eprintln!("updated plugin '{}'", short);
275 Ok(())
276}
277
278fn find_built_lib(build_dir: &Path, lib_name: &str) -> anyhow::Result<PathBuf> {
282 for entry in walkdir(build_dir) {
285 if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
286 if name == lib_name {
287 return Ok(entry);
288 }
289 }
290 }
291 anyhow::bail!(
292 "built library '{}' not found in {}",
293 lib_name,
294 build_dir.display()
295 )
296}
297
298fn walkdir(dir: &Path) -> Vec<PathBuf> {
300 let mut results = Vec::new();
301 if let Ok(entries) = std::fs::read_dir(dir) {
302 for entry in entries.flatten() {
303 let path = entry.path();
304 if path.is_dir() {
305 results.extend(walkdir(&path));
306 } else {
307 results.push(path);
308 }
309 }
310 }
311 results
312}
313
314#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
323 fn resolve_short_name_prepends_prefix() {
324 assert_eq!(resolve_crate_name("agent"), "room-plugin-agent");
325 }
326
327 #[test]
328 fn resolve_full_name_unchanged() {
329 assert_eq!(
330 resolve_crate_name("room-plugin-taskboard"),
331 "room-plugin-taskboard"
332 );
333 }
334
335 #[test]
336 fn resolve_hyphenated_name() {
337 assert_eq!(resolve_crate_name("my-custom"), "room-plugin-my-custom");
338 }
339
340 #[test]
343 fn short_name_strips_prefix() {
344 assert_eq!(short_name("room-plugin-agent"), "agent");
345 }
346
347 #[test]
348 fn short_name_no_prefix() {
349 assert_eq!(short_name("custom"), "custom");
350 }
351
352 #[test]
355 fn lib_filename_replaces_hyphens() {
356 let name = lib_filename("room-plugin-agent");
357 assert!(name.starts_with("libroom_plugin_agent."));
358 assert!(name.ends_with(".so") || name.ends_with(".dylib"));
360 }
361
362 #[test]
365 fn meta_roundtrip() {
366 let meta = PluginMeta {
367 name: "agent".to_owned(),
368 crate_name: "room-plugin-agent".to_owned(),
369 version: "3.4.0".to_owned(),
370 min_protocol: "3.0.0".to_owned(),
371 lib_file: "libroom_plugin_agent.so".to_owned(),
372 };
373 let json = serde_json::to_string(&meta).unwrap();
374 let parsed: PluginMeta = serde_json::from_str(&json).unwrap();
375 assert_eq!(parsed, meta);
376 }
377
378 #[test]
379 fn meta_pretty_print() {
380 let meta = PluginMeta {
381 name: "taskboard".to_owned(),
382 crate_name: "room-plugin-taskboard".to_owned(),
383 version: "1.0.0".to_owned(),
384 min_protocol: "0.0.0".to_owned(),
385 lib_file: "libroom_plugin_taskboard.so".to_owned(),
386 };
387 let json = serde_json::to_string_pretty(&meta).unwrap();
388 assert!(json.contains("\"name\": \"taskboard\""));
389 assert!(json.contains("\"version\": \"1.0.0\""));
390 }
391
392 #[test]
395 fn scan_empty_dir() {
396 let dir = tempfile::TempDir::new().unwrap();
397 let result = scan_installed(dir.path());
398 assert!(result.is_empty());
399 }
400
401 #[test]
402 fn scan_nonexistent_dir() {
403 let result = scan_installed(Path::new("/nonexistent/plugins"));
404 assert!(result.is_empty());
405 }
406
407 #[test]
408 fn scan_finds_valid_meta_files() {
409 let dir = tempfile::TempDir::new().unwrap();
410 let meta = PluginMeta {
411 name: "test".to_owned(),
412 crate_name: "room-plugin-test".to_owned(),
413 version: "0.1.0".to_owned(),
414 min_protocol: "0.0.0".to_owned(),
415 lib_file: "libroom_plugin_test.so".to_owned(),
416 };
417 let meta_file = dir.path().join("test.meta.json");
418 std::fs::write(&meta_file, serde_json::to_string(&meta).unwrap()).unwrap();
419
420 let result = scan_installed(dir.path());
421 assert_eq!(result.len(), 1);
422 assert_eq!(result[0].name, "test");
423 }
424
425 #[test]
426 fn scan_skips_invalid_json() {
427 let dir = tempfile::TempDir::new().unwrap();
428 std::fs::write(dir.path().join("bad.meta.json"), "not json").unwrap();
429 let result = scan_installed(dir.path());
430 assert!(result.is_empty());
431 }
432
433 #[test]
434 fn scan_skips_non_meta_json() {
435 let dir = tempfile::TempDir::new().unwrap();
436 std::fs::write(dir.path().join("config.json"), "{}").unwrap();
437 let result = scan_installed(dir.path());
438 assert!(result.is_empty());
439 }
440
441 #[test]
442 fn scan_sorts_by_name() {
443 let dir = tempfile::TempDir::new().unwrap();
444 for name in &["zebra", "alpha", "mid"] {
445 let meta = PluginMeta {
446 name: name.to_string(),
447 crate_name: format!("room-plugin-{name}"),
448 version: "0.1.0".to_owned(),
449 min_protocol: "0.0.0".to_owned(),
450 lib_file: format!("libroom_plugin_{name}.so"),
451 };
452 std::fs::write(
453 dir.path().join(format!("{name}.meta.json")),
454 serde_json::to_string(&meta).unwrap(),
455 )
456 .unwrap();
457 }
458 let result = scan_installed(dir.path());
459 let names: Vec<&str> = result.iter().map(|m| m.name.as_str()).collect();
460 assert_eq!(names, vec!["alpha", "mid", "zebra"]);
461 }
462
463 #[test]
466 fn meta_path_format() {
467 let p = meta_path(Path::new("/home/user/.room/plugins"), "agent");
468 assert_eq!(p, PathBuf::from("/home/user/.room/plugins/agent.meta.json"));
469 }
470
471 #[test]
474 fn remove_nonexistent_plugin_fails() {
475 let dir = tempfile::TempDir::new().unwrap();
476 let result = cmd_remove(dir.path(), "nonexistent");
477 assert!(result.is_err());
478 assert!(result.unwrap_err().to_string().contains("not installed"));
479 }
480
481 #[test]
482 fn remove_deletes_lib_and_meta() {
483 let dir = tempfile::TempDir::new().unwrap();
484 let meta = PluginMeta {
485 name: "test".to_owned(),
486 crate_name: "room-plugin-test".to_owned(),
487 version: "0.1.0".to_owned(),
488 min_protocol: "0.0.0".to_owned(),
489 lib_file: "libroom_plugin_test.so".to_owned(),
490 };
491 std::fs::write(
492 dir.path().join("test.meta.json"),
493 serde_json::to_string(&meta).unwrap(),
494 )
495 .unwrap();
496 std::fs::write(dir.path().join("libroom_plugin_test.so"), b"fake").unwrap();
497
498 cmd_remove(dir.path(), "test").unwrap();
499 assert!(!dir.path().join("test.meta.json").exists());
500 assert!(!dir.path().join("libroom_plugin_test.so").exists());
501 }
502
503 #[test]
506 fn walkdir_finds_nested_files() {
507 let dir = tempfile::TempDir::new().unwrap();
508 let nested = dir.path().join("a").join("b");
509 std::fs::create_dir_all(&nested).unwrap();
510 std::fs::write(nested.join("target.so"), b"lib").unwrap();
511 std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
512
513 let files = walkdir(dir.path());
514 assert!(files.iter().any(|p| p.ends_with("target.so")));
515 assert!(files.iter().any(|p| p.ends_with("top.txt")));
516 }
517
518 #[test]
519 fn walkdir_empty_dir() {
520 let dir = tempfile::TempDir::new().unwrap();
521 let files = walkdir(dir.path());
522 assert!(files.is_empty());
523 }
524
525 #[test]
528 fn find_built_lib_success() {
529 let dir = tempfile::TempDir::new().unwrap();
530 let release = dir.path().join("target").join("release");
531 std::fs::create_dir_all(&release).unwrap();
532 std::fs::write(release.join("libroom_plugin_test.so"), b"elf").unwrap();
533
534 let result = find_built_lib(dir.path(), "libroom_plugin_test.so");
535 assert!(result.is_ok());
536 assert!(result.unwrap().ends_with("libroom_plugin_test.so"));
537 }
538
539 #[test]
540 fn find_built_lib_not_found() {
541 let dir = tempfile::TempDir::new().unwrap();
542 let result = find_built_lib(dir.path(), "nonexistent.so");
543 assert!(result.is_err());
544 assert!(result.unwrap_err().to_string().contains("not found"));
545 }
546
547 #[test]
550 fn install_skips_when_already_installed() {
551 let dir = tempfile::TempDir::new().unwrap();
552 let meta = PluginMeta {
553 name: "existing".to_owned(),
554 crate_name: "room-plugin-existing".to_owned(),
555 version: "1.0.0".to_owned(),
556 min_protocol: "0.0.0".to_owned(),
557 lib_file: "libroom_plugin_existing.so".to_owned(),
558 };
559 std::fs::write(
560 dir.path().join("existing.meta.json"),
561 serde_json::to_string(&meta).unwrap(),
562 )
563 .unwrap();
564
565 let result = cmd_install(dir.path(), "existing", None);
567 assert!(result.is_ok());
568 }
569
570 #[test]
573 fn update_nonexistent_plugin_fails() {
574 let dir = tempfile::TempDir::new().unwrap();
575 let result = cmd_update(dir.path(), "nonexistent", None);
576 assert!(result.is_err());
577 assert!(result.unwrap_err().to_string().contains("not installed"));
578 }
579
580 #[test]
583 fn builtin_plugins_has_four_entries() {
584 assert_eq!(BUILTIN_PLUGINS.len(), 4);
585 }
586
587 #[test]
588 fn builtin_plugins_includes_expected_names() {
589 let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
590 assert!(names.contains(&"agent"));
591 assert!(names.contains(&"taskboard"));
592 assert!(names.contains(&"queue"));
593 assert!(names.contains(&"stats"));
594 }
595
596 #[test]
597 fn builtin_plugins_sorted_alphabetically() {
598 let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
599 let mut sorted = names.clone();
600 sorted.sort();
601 assert_eq!(names, sorted);
602 }
603}