use std::collections::HashSet;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::error::{PluginError, PluginResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginSource {
LocalDir { path: PathBuf },
LocalArchive { path: PathBuf },
Url {
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
allow_unverified: bool,
#[serde(default, skip_serializing_if = "is_false")]
allow_untrusted_host: bool,
#[serde(default, skip_serializing_if = "is_false")]
allow_unsigned: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
signed_by: Option<String>,
},
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisteredCapabilities {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_server_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_dirs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preset_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workflow_filenames: Vec<String>,
}
impl RegisteredCapabilities {
pub fn is_empty(&self) -> bool {
self.mcp_server_ids.is_empty()
&& self.skill_dirs.is_empty()
&& self.preset_ids.is_empty()
&& self.workflow_filenames.is_empty()
}
pub fn removed_since(&self, old: &RegisteredCapabilities) -> RegisteredCapabilities {
RegisteredCapabilities {
mcp_server_ids: subtract(&old.mcp_server_ids, &self.mcp_server_ids),
skill_dirs: subtract(&old.skill_dirs, &self.skill_dirs),
preset_ids: subtract(&old.preset_ids, &self.preset_ids),
workflow_filenames: subtract(&old.workflow_filenames, &self.workflow_filenames),
}
}
}
fn subtract(from: &[String], remove: &[String]) -> Vec<String> {
let drop: HashSet<&str> = remove.iter().map(String::as_str).collect();
from.iter()
.filter(|value| !drop.contains(value.as_str()))
.cloned()
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ownership {
New,
OwnedReinstall,
ForeignConflict,
}
pub fn classify_ownership(
id: &str,
existing: &HashSet<&str>,
owned_previously: &HashSet<&str>,
) -> Ownership {
if !existing.contains(id) {
Ownership::New
} else if owned_previously.contains(id) {
Ownership::OwnedReinstall
} else {
Ownership::ForeignConflict
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExclusiveReconciliation {
pub to_register: Vec<String>,
pub foreign_conflicts: Vec<String>,
}
pub fn reconcile_exclusive(
declared: &[String],
existing: &[String],
owned_previously: &[String],
) -> ExclusiveReconciliation {
let existing_set: HashSet<&str> = existing.iter().map(String::as_str).collect();
let owned_set: HashSet<&str> = owned_previously.iter().map(String::as_str).collect();
let mut result = ExclusiveReconciliation::default();
for id in declared {
match classify_ownership(id, &existing_set, &owned_set) {
Ownership::New | Ownership::OwnedReinstall => result.to_register.push(id.clone()),
Ownership::ForeignConflict => result.foreign_conflicts.push(id.clone()),
}
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginInstallStatus {
Installing,
#[default]
Installed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledPlugin {
pub id: String,
pub version: String,
pub source: PluginSource,
pub plugin_dir: PathBuf,
pub installed_at: DateTime<Utc>,
#[serde(default)]
pub status: PluginInstallStatus,
#[serde(default)]
pub registered: RegisteredCapabilities,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstalledPlugins {
#[serde(default)]
pub plugins: Vec<InstalledPlugin>,
}
impl InstalledPlugins {
pub async fn load(path: &Path) -> PluginResult<Self> {
match fs::try_exists(path).await {
Ok(true) => {}
Ok(false) => return Ok(Self::default()),
Err(error) => return Err(PluginError::Io(error)),
}
let raw = fs::read_to_string(path).await?;
if raw.trim().is_empty() {
return Ok(Self::default());
}
let store: Self = serde_json::from_str(&raw)?;
Ok(store)
}
pub async fn save(&self, path: &Path) -> PluginResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let serialized = serde_json::to_string_pretty(self)?;
let tmp_path = tmp_path_for(path);
fs::write(&tmp_path, serialized).await?;
fs::rename(&tmp_path, path).await?;
Ok(())
}
pub fn get(&self, id: &str) -> Option<&InstalledPlugin> {
self.plugins.iter().find(|plugin| plugin.id == id)
}
pub fn add(&mut self, plugin: InstalledPlugin) {
self.remove(&plugin.id);
self.plugins.push(plugin);
}
pub fn remove(&mut self, id: &str) -> Option<InstalledPlugin> {
let index = self.plugins.iter().position(|plugin| plugin.id == id)?;
Some(self.plugins.remove(index))
}
pub fn list(&self) -> &[InstalledPlugin] {
&self.plugins
}
}
fn tmp_path_for(path: &Path) -> PathBuf {
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(".tmp");
path.with_file_name(tmp_name)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_plugin(id: &str) -> InstalledPlugin {
InstalledPlugin {
id: id.to_string(),
version: "0.1.0".to_string(),
source: PluginSource::LocalDir {
path: PathBuf::from("/tmp/source"),
},
plugin_dir: PathBuf::from(format!("/home/user/.bamboo/plugins/{id}")),
installed_at: DateTime::parse_from_rfc3339("2026-07-12T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
status: PluginInstallStatus::Installed,
registered: RegisteredCapabilities {
mcp_server_ids: vec![],
skill_dirs: vec!["hello-world".to_string()],
preset_ids: vec!["hello_preset".to_string()],
workflow_filenames: vec![],
},
}
}
#[tokio::test]
async fn load_missing_file_returns_empty_registry() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("plugins").join("installed.json");
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert!(loaded.plugins.is_empty());
}
#[tokio::test]
async fn save_is_atomic_via_tmp_file_rename() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("installed.json");
let tmp_path = tmp_path_for(&path);
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
store.save(&path).await.expect("save");
assert!(path.exists(), "installed.json should exist after save");
assert!(
!tmp_path.exists(),
"the .tmp staging file must be renamed over the target, never left behind"
);
let mut reloaded = InstalledPlugins::load(&path).await.expect("load");
reloaded.add(sample_plugin("other-plugin"));
reloaded.save(&path).await.expect("save again");
assert!(!tmp_path.exists());
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert_eq!(loaded.plugins.len(), 2);
}
#[tokio::test]
async fn save_then_load_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("plugins").join("installed.json");
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
store.add(sample_plugin("other-plugin"));
store.save(&path).await.expect("save");
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert_eq!(loaded.plugins.len(), 2);
let hello = loaded.get("hello-plugin").expect("hello-plugin present");
assert_eq!(hello.version, "0.1.0");
assert_eq!(hello.registered.skill_dirs, vec!["hello-world".to_string()]);
assert_eq!(
hello.registered.preset_ids,
vec!["hello_preset".to_string()]
);
assert_eq!(
hello.source,
PluginSource::LocalDir {
path: PathBuf::from("/tmp/source")
}
);
}
#[tokio::test]
async fn add_upserts_by_id() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("installed.json");
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
let mut upgraded = sample_plugin("hello-plugin");
upgraded.version = "0.2.0".to_string();
store.add(upgraded);
assert_eq!(store.plugins.len(), 1);
assert_eq!(store.get("hello-plugin").unwrap().version, "0.2.0");
store.save(&path).await.expect("save");
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert_eq!(loaded.plugins.len(), 1);
assert_eq!(loaded.get("hello-plugin").unwrap().version, "0.2.0");
}
#[tokio::test]
async fn remove_deletes_and_returns_entry() {
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
let removed = store.remove("hello-plugin").expect("present before remove");
assert_eq!(removed.id, "hello-plugin");
assert!(store.get("hello-plugin").is_none());
assert!(store.remove("hello-plugin").is_none());
}
#[test]
fn reconcile_exclusive_fresh_install_splits_new_from_foreign() {
let declared = vec!["a".to_string(), "b".to_string()];
let existing = vec!["b".to_string(), "user-thing".to_string()];
let owned_previously: Vec<String> = vec![];
let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
assert_eq!(reconciliation.to_register, vec!["a".to_string()]);
assert_eq!(reconciliation.foreign_conflicts, vec!["b".to_string()]);
}
#[test]
fn reconcile_exclusive_upgrade_reregisters_own_but_refuses_new_foreign() {
let declared = vec!["a".to_string(), "c".to_string(), "d".to_string()];
let existing = vec!["a".to_string(), "d".to_string()];
let owned_previously = vec!["a".to_string()];
let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
assert_eq!(
reconciliation.to_register,
vec!["a".to_string(), "c".to_string()]
);
assert_eq!(reconciliation.foreign_conflicts, vec!["d".to_string()]);
}
#[test]
fn classify_ownership_three_way() {
let existing: HashSet<&str> = ["x", "y"].into_iter().collect();
let owned: HashSet<&str> = ["y"].into_iter().collect();
assert_eq!(classify_ownership("z", &existing, &owned), Ownership::New);
assert_eq!(
classify_ownership("y", &existing, &owned),
Ownership::OwnedReinstall
);
assert_eq!(
classify_ownership("x", &existing, &owned),
Ownership::ForeignConflict
);
}
#[test]
fn removed_since_computes_dropped_capabilities_per_kind() {
let old = RegisteredCapabilities {
mcp_server_ids: vec!["srv-a".to_string(), "srv-b".to_string()],
skill_dirs: vec!["skill-a".to_string()],
preset_ids: vec!["preset-a".to_string(), "preset-b".to_string()],
workflow_filenames: vec!["wf-a.md".to_string()],
};
let new = RegisteredCapabilities {
mcp_server_ids: vec!["srv-a".to_string(), "srv-c".to_string()],
skill_dirs: vec!["skill-a".to_string()],
preset_ids: vec!["preset-b".to_string()],
workflow_filenames: vec!["wf-a.md".to_string()],
};
let removed = new.removed_since(&old);
assert_eq!(removed.mcp_server_ids, vec!["srv-b".to_string()]);
assert!(removed.skill_dirs.is_empty());
assert_eq!(removed.preset_ids, vec!["preset-a".to_string()]);
assert!(removed.workflow_filenames.is_empty());
}
#[tokio::test]
async fn load_empty_file_returns_empty_registry() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("installed.json");
tokio::fs::create_dir_all(path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&path, "").await.unwrap();
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert!(loaded.plugins.is_empty());
}
}