1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct PluginMeta {
18 pub name: String,
20 pub crate_name: String,
22 pub version: String,
24 pub min_protocol: String,
26 pub lib_file: String,
28}
29
30pub 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
44pub 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
54pub 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
68pub fn meta_path(plugins_dir: &Path, name: &str) -> PathBuf {
70 plugins_dir.join(format!("{name}.meta.json"))
71}
72
73pub 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
101pub 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 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 std::fs::create_dir_all(plugins_dir)?;
130
131 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 let lib_name = lib_filename(&crate_name);
157 let built_lib = find_built_lib(build_dir.path(), &lib_name)?;
158
159 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 let installed_version = version.unwrap_or("latest").to_owned();
171
172 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
187pub 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
204pub 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 let data = std::fs::read_to_string(&meta_file)?;
215 let meta: PluginMeta = serde_json::from_str(&data)?;
216
217 let lib_path = plugins_dir.join(&meta.lib_file);
219 if lib_path.exists() {
220 std::fs::remove_file(&lib_path)?;
221 }
222
223 std::fs::remove_file(&meta_file)?;
225
226 eprintln!("removed plugin '{}'", short);
227 Ok(())
228}
229
230pub 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 cmd_remove(plugins_dir, name)?;
245 cmd_install(plugins_dir, name, version)?;
246 eprintln!("updated plugin '{}'", short);
247 Ok(())
248}
249
250fn find_built_lib(build_dir: &Path, lib_name: &str) -> anyhow::Result<PathBuf> {
254 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
270fn 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#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[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 #[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 #[test]
327 fn lib_filename_replaces_hyphens() {
328 let name = lib_filename("room-plugin-agent");
329 assert!(name.starts_with("libroom_plugin_agent."));
330 assert!(name.ends_with(".so") || name.ends_with(".dylib"));
332 }
333
334 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let result = cmd_install(dir.path(), "existing", None);
539 assert!(result.is_ok());
540 }
541
542 #[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}