1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use tokio::fs;
20
21use crate::error::{PluginError, PluginResult};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum PluginSource {
28 LocalDir { path: PathBuf },
32 LocalArchive { path: PathBuf },
34 Url {
36 url: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 sha256: Option<String>,
39 },
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct RegisteredCapabilities {
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub mcp_server_ids: Vec<String>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub skill_dirs: Vec<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub preset_ids: Vec<String>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub workflow_filenames: Vec<String>,
62}
63
64impl RegisteredCapabilities {
65 pub fn is_empty(&self) -> bool {
66 self.mcp_server_ids.is_empty()
67 && self.skill_dirs.is_empty()
68 && self.preset_ids.is_empty()
69 && self.workflow_filenames.is_empty()
70 }
71
72 pub fn removed_since(&self, old: &RegisteredCapabilities) -> RegisteredCapabilities {
83 RegisteredCapabilities {
84 mcp_server_ids: subtract(&old.mcp_server_ids, &self.mcp_server_ids),
85 skill_dirs: subtract(&old.skill_dirs, &self.skill_dirs),
86 preset_ids: subtract(&old.preset_ids, &self.preset_ids),
87 workflow_filenames: subtract(&old.workflow_filenames, &self.workflow_filenames),
88 }
89 }
90}
91
92fn subtract(from: &[String], remove: &[String]) -> Vec<String> {
94 let drop: HashSet<&str> = remove.iter().map(String::as_str).collect();
95 from.iter()
96 .filter(|value| !drop.contains(value.as_str()))
97 .cloned()
98 .collect()
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Ownership {
107 New,
110 OwnedReinstall,
113 ForeignConflict,
116}
117
118pub fn classify_ownership(
121 id: &str,
122 existing: &HashSet<&str>,
123 owned_previously: &HashSet<&str>,
124) -> Ownership {
125 if !existing.contains(id) {
126 Ownership::New
127 } else if owned_previously.contains(id) {
128 Ownership::OwnedReinstall
129 } else {
130 Ownership::ForeignConflict
131 }
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct ExclusiveReconciliation {
138 pub to_register: Vec<String>,
141 pub foreign_conflicts: Vec<String>,
145}
146
147pub fn reconcile_exclusive(
158 declared: &[String],
159 existing: &[String],
160 owned_previously: &[String],
161) -> ExclusiveReconciliation {
162 let existing_set: HashSet<&str> = existing.iter().map(String::as_str).collect();
163 let owned_set: HashSet<&str> = owned_previously.iter().map(String::as_str).collect();
164
165 let mut result = ExclusiveReconciliation::default();
166 for id in declared {
167 match classify_ownership(id, &existing_set, &owned_set) {
168 Ownership::New | Ownership::OwnedReinstall => result.to_register.push(id.clone()),
169 Ownership::ForeignConflict => result.foreign_conflicts.push(id.clone()),
170 }
171 }
172 result
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum PluginInstallStatus {
191 Installing,
194 #[default]
198 Installed,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct InstalledPlugin {
204 pub id: String,
205 pub version: String,
207 pub source: PluginSource,
208 pub plugin_dir: PathBuf,
210 pub installed_at: DateTime<Utc>,
214 #[serde(default)]
218 pub status: PluginInstallStatus,
219 #[serde(default)]
220 pub registered: RegisteredCapabilities,
221}
222
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
225pub struct InstalledPlugins {
226 #[serde(default)]
227 pub plugins: Vec<InstalledPlugin>,
228}
229
230impl InstalledPlugins {
231 pub async fn load(path: &Path) -> PluginResult<Self> {
235 match fs::try_exists(path).await {
236 Ok(true) => {}
237 Ok(false) => return Ok(Self::default()),
238 Err(error) => return Err(PluginError::Io(error)),
239 }
240
241 let raw = fs::read_to_string(path).await?;
242 if raw.trim().is_empty() {
243 return Ok(Self::default());
244 }
245 let store: Self = serde_json::from_str(&raw)?;
246 Ok(store)
247 }
248
249 pub async fn save(&self, path: &Path) -> PluginResult<()> {
257 if let Some(parent) = path.parent() {
258 fs::create_dir_all(parent).await?;
259 }
260 let serialized = serde_json::to_string_pretty(self)?;
261 let tmp_path = tmp_path_for(path);
262 fs::write(&tmp_path, serialized).await?;
263 fs::rename(&tmp_path, path).await?;
264 Ok(())
265 }
266
267 pub fn get(&self, id: &str) -> Option<&InstalledPlugin> {
269 self.plugins.iter().find(|plugin| plugin.id == id)
270 }
271
272 pub fn add(&mut self, plugin: InstalledPlugin) {
275 self.remove(&plugin.id);
276 self.plugins.push(plugin);
277 }
278
279 pub fn remove(&mut self, id: &str) -> Option<InstalledPlugin> {
281 let index = self.plugins.iter().position(|plugin| plugin.id == id)?;
282 Some(self.plugins.remove(index))
283 }
284
285 pub fn list(&self) -> &[InstalledPlugin] {
287 &self.plugins
288 }
289}
290
291fn tmp_path_for(path: &Path) -> PathBuf {
296 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
297 tmp_name.push(".tmp");
298 path.with_file_name(tmp_name)
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn sample_plugin(id: &str) -> InstalledPlugin {
306 InstalledPlugin {
307 id: id.to_string(),
308 version: "0.1.0".to_string(),
309 source: PluginSource::LocalDir {
310 path: PathBuf::from("/tmp/source"),
311 },
312 plugin_dir: PathBuf::from(format!("/home/user/.bamboo/plugins/{id}")),
313 installed_at: DateTime::parse_from_rfc3339("2026-07-12T00:00:00Z")
314 .unwrap()
315 .with_timezone(&Utc),
316 status: PluginInstallStatus::Installed,
317 registered: RegisteredCapabilities {
318 mcp_server_ids: vec![],
319 skill_dirs: vec!["hello-world".to_string()],
320 preset_ids: vec!["hello_preset".to_string()],
321 workflow_filenames: vec![],
322 },
323 }
324 }
325
326 #[tokio::test]
327 async fn load_missing_file_returns_empty_registry() {
328 let dir = tempfile::tempdir().expect("tempdir");
329 let path = dir.path().join("plugins").join("installed.json");
330 let loaded = InstalledPlugins::load(&path).await.expect("load");
331 assert!(loaded.plugins.is_empty());
332 }
333
334 #[tokio::test]
335 async fn save_is_atomic_via_tmp_file_rename() {
336 let dir = tempfile::tempdir().expect("tempdir");
337 let path = dir.path().join("installed.json");
338 let tmp_path = tmp_path_for(&path);
339
340 let mut store = InstalledPlugins::default();
341 store.add(sample_plugin("hello-plugin"));
342 store.save(&path).await.expect("save");
343
344 assert!(path.exists(), "installed.json should exist after save");
345 assert!(
346 !tmp_path.exists(),
347 "the .tmp staging file must be renamed over the target, never left behind"
348 );
349
350 let mut reloaded = InstalledPlugins::load(&path).await.expect("load");
354 reloaded.add(sample_plugin("other-plugin"));
355 reloaded.save(&path).await.expect("save again");
356 assert!(!tmp_path.exists());
357
358 let loaded = InstalledPlugins::load(&path).await.expect("load");
359 assert_eq!(loaded.plugins.len(), 2);
360 }
361
362 #[tokio::test]
363 async fn save_then_load_round_trips() {
364 let dir = tempfile::tempdir().expect("tempdir");
365 let path = dir.path().join("plugins").join("installed.json");
366
367 let mut store = InstalledPlugins::default();
368 store.add(sample_plugin("hello-plugin"));
369 store.add(sample_plugin("other-plugin"));
370 store.save(&path).await.expect("save");
371
372 let loaded = InstalledPlugins::load(&path).await.expect("load");
373 assert_eq!(loaded.plugins.len(), 2);
374 let hello = loaded.get("hello-plugin").expect("hello-plugin present");
375 assert_eq!(hello.version, "0.1.0");
376 assert_eq!(hello.registered.skill_dirs, vec!["hello-world".to_string()]);
377 assert_eq!(
378 hello.registered.preset_ids,
379 vec!["hello_preset".to_string()]
380 );
381 assert_eq!(
382 hello.source,
383 PluginSource::LocalDir {
384 path: PathBuf::from("/tmp/source")
385 }
386 );
387 }
388
389 #[tokio::test]
390 async fn add_upserts_by_id() {
391 let dir = tempfile::tempdir().expect("tempdir");
392 let path = dir.path().join("installed.json");
393
394 let mut store = InstalledPlugins::default();
395 store.add(sample_plugin("hello-plugin"));
396
397 let mut upgraded = sample_plugin("hello-plugin");
398 upgraded.version = "0.2.0".to_string();
399 store.add(upgraded);
400
401 assert_eq!(store.plugins.len(), 1);
402 assert_eq!(store.get("hello-plugin").unwrap().version, "0.2.0");
403
404 store.save(&path).await.expect("save");
405 let loaded = InstalledPlugins::load(&path).await.expect("load");
406 assert_eq!(loaded.plugins.len(), 1);
407 assert_eq!(loaded.get("hello-plugin").unwrap().version, "0.2.0");
408 }
409
410 #[tokio::test]
411 async fn remove_deletes_and_returns_entry() {
412 let mut store = InstalledPlugins::default();
413 store.add(sample_plugin("hello-plugin"));
414
415 let removed = store.remove("hello-plugin").expect("present before remove");
416 assert_eq!(removed.id, "hello-plugin");
417 assert!(store.get("hello-plugin").is_none());
418 assert!(store.remove("hello-plugin").is_none());
419 }
420
421 #[test]
422 fn reconcile_exclusive_fresh_install_splits_new_from_foreign() {
423 let declared = vec!["a".to_string(), "b".to_string()];
426 let existing = vec!["b".to_string(), "user-thing".to_string()];
427 let owned_previously: Vec<String> = vec![];
428
429 let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
430 assert_eq!(reconciliation.to_register, vec!["a".to_string()]);
431 assert_eq!(reconciliation.foreign_conflicts, vec!["b".to_string()]);
432 }
433
434 #[test]
435 fn reconcile_exclusive_upgrade_reregisters_own_but_refuses_new_foreign() {
436 let declared = vec!["a".to_string(), "c".to_string(), "d".to_string()];
439 let existing = vec!["a".to_string(), "d".to_string()];
440 let owned_previously = vec!["a".to_string()];
441
442 let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
443 assert_eq!(
444 reconciliation.to_register,
445 vec!["a".to_string(), "c".to_string()]
446 );
447 assert_eq!(reconciliation.foreign_conflicts, vec!["d".to_string()]);
448 }
449
450 #[test]
451 fn classify_ownership_three_way() {
452 let existing: HashSet<&str> = ["x", "y"].into_iter().collect();
453 let owned: HashSet<&str> = ["y"].into_iter().collect();
454 assert_eq!(classify_ownership("z", &existing, &owned), Ownership::New);
455 assert_eq!(
456 classify_ownership("y", &existing, &owned),
457 Ownership::OwnedReinstall
458 );
459 assert_eq!(
460 classify_ownership("x", &existing, &owned),
461 Ownership::ForeignConflict
462 );
463 }
464
465 #[test]
466 fn removed_since_computes_dropped_capabilities_per_kind() {
467 let old = RegisteredCapabilities {
468 mcp_server_ids: vec!["srv-a".to_string(), "srv-b".to_string()],
469 skill_dirs: vec!["skill-a".to_string()],
470 preset_ids: vec!["preset-a".to_string(), "preset-b".to_string()],
471 workflow_filenames: vec!["wf-a.md".to_string()],
472 };
473 let new = RegisteredCapabilities {
475 mcp_server_ids: vec!["srv-a".to_string(), "srv-c".to_string()],
476 skill_dirs: vec!["skill-a".to_string()],
477 preset_ids: vec!["preset-b".to_string()],
478 workflow_filenames: vec!["wf-a.md".to_string()],
479 };
480
481 let removed = new.removed_since(&old);
482 assert_eq!(removed.mcp_server_ids, vec!["srv-b".to_string()]);
483 assert!(removed.skill_dirs.is_empty());
484 assert_eq!(removed.preset_ids, vec!["preset-a".to_string()]);
485 assert!(removed.workflow_filenames.is_empty());
486 }
487
488 #[tokio::test]
489 async fn load_empty_file_returns_empty_registry() {
490 let dir = tempfile::tempdir().expect("tempdir");
491 let path = dir.path().join("installed.json");
492 tokio::fs::create_dir_all(path.parent().unwrap())
493 .await
494 .unwrap();
495 tokio::fs::write(&path, "").await.unwrap();
496
497 let loaded = InstalledPlugins::load(&path).await.expect("load");
498 assert!(loaded.plugins.is_empty());
499 }
500}