Skip to main content

bamboo_plugin/
registry.rs

1//! Provenance registry: `~/.bamboo/plugins/installed.json`.
2//!
3//! Records, for each installed plugin, EXACTLY what it registered (which
4//! `mcpServers` ids, which skill dir names, which prompt preset ids, which
5//! workflow filenames) so uninstall/upgrade can precisely undo only what a
6//! given plugin added — never touching a user's own hand-added entries that
7//! happen to share a config file with plugin-registered ones.
8//!
9//! This module only defines the schema + load/save/add/remove helpers. Wiring
10//! *when* to call `add`/`remove` relative to actually registering/
11//! deregistering capabilities (MCP servers, prompt presets, workflow files)
12//! is the installer's job (see [`crate::installer`] and `PLUGIN_PLAN.md`).
13
14use 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/// Where a plugin's installed bundle came from. Recorded verbatim so
24/// `update`/reinstall can re-fetch from the same place.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum PluginSource {
28    /// Installed from a local directory (copied or referenced in place — the
29    /// installer decides which; either way this records the ORIGINAL path the
30    /// user pointed at, not necessarily `plugin_dir`).
31    LocalDir { path: PathBuf },
32    /// Installed by unpacking a local `.tar.gz` archive.
33    LocalArchive { path: PathBuf },
34    /// Installed by fetching a URL (optionally sha256-verified).
35    Url {
36        url: String,
37        #[serde(default, skip_serializing_if = "Option::is_none")]
38        sha256: Option<String>,
39    },
40}
41
42/// Exactly what an installed plugin registered into Bamboo's shared capability
43/// stores. Every id/name here MUST have actually been written by the
44/// installer for THIS plugin — never a superset (that would risk clobbering
45/// or removing a user's own entries on uninstall) and never a subset
46/// (uninstall would leak orphaned registrations).
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct RegisteredCapabilities {
49    /// Ids registered into `config.json`'s `mcpServers` map.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub mcp_server_ids: Vec<String>,
52    /// Directory names under `<plugin_dir>/skills/` that are valid skill
53    /// dirs (contain `SKILL.md`) and are therefore discoverable in place.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub skill_dirs: Vec<String>,
56    /// Ids appended into `prompt-presets.json`.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub preset_ids: Vec<String>,
59    /// Filenames copied into `bamboo_config::paths::workflows_dir()`.
60    #[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    /// The capabilities present in `old` (a prior install's registered set)
73    /// but ABSENT from `self` (the set the new/upgraded install will register).
74    ///
75    /// These are exactly the entries an in-place upgrade must DE-register:
76    /// their ids/filenames vanish from provenance across the upgrade, so if
77    /// they are not actively removed here they leak — orphaned forever,
78    /// un-removable because no future uninstall knows they were ours. See the
79    /// upgrade sequence in [`crate::installer`] / `PLUGIN_PLAN.md`.
80    ///
81    /// Order-preserving relative to `old` (stable output for diffing/logging).
82    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
92/// Elements of `from` not present in `remove`, preserving `from`'s order.
93fn 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/// Ownership classification of one declared capability id/filename against a
102/// shared store, for the REFUSE-on-conflict capability kinds (MCP servers,
103/// workflow files). Prompt presets do NOT use this — they rename on collision
104/// via bamboo-server's `ensure_unique_preset_id` instead.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Ownership {
107    /// Not present in the shared store — safe to create AND record as
108    /// plugin-owned/removable.
109    New,
110    /// Present, and registered by THIS plugin's prior install — an upgrade
111    /// re-registering its own entry. Safe; stays recorded as plugin-owned.
112    OwnedReinstall,
113    /// Present and NOT owned by this plugin (a user's own entry, or another
114    /// plugin's). Must block the install — never recorded as removable.
115    ForeignConflict,
116}
117
118/// Classify one id against the shared store's current `existing` ids and the
119/// `owned_previously` ids that THIS plugin's prior install registered.
120pub 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/// Result of reconciling a plugin's declared ids/filenames against a shared
135/// store for a REFUSE-on-conflict capability (MCP servers, workflow files).
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct ExclusiveReconciliation {
138    /// Genuinely-new plus this-plugin's-own-from-a-prior-install: register
139    /// these and record them as plugin-owned/removable in provenance.
140    pub to_register: Vec<String>,
141    /// Foreign collisions (exist, not owned by this plugin). If this is
142    /// non-empty the caller MUST refuse the install (return
143    /// [`PluginError::Conflict`]) — do not register or record any of these.
144    pub foreign_conflicts: Vec<String>,
145}
146
147/// Reconcile `declared` ids against the shared store for a REFUSE-on-conflict
148/// capability. `existing` = every id currently in the shared store;
149/// `owned_previously` = the ids THIS plugin's prior install recorded (empty
150/// for a fresh install). Pure — the caller supplies the store state (which,
151/// for MCP/workflows, only the app layer can read).
152///
153/// This is the pre-check that closes BLOCKER 1: a pre-existing collision with
154/// a non-plugin entry lands in `foreign_conflicts`, so it is NEVER registered
155/// and NEVER recorded as removable — uninstall can therefore only ever delete
156/// entries this plugin genuinely created.
157pub 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/// Lifecycle status of a provenance row — the crash-safety journal marker.
176///
177/// The installer writes a row as [`Self::Installing`] BEFORE it begins
178/// registering capabilities (MCP into `config.json`, prompts, workflow files),
179/// and flips it to [`Self::Installed`] only after the whole sequence succeeds.
180/// A row left [`Self::Installing`] therefore marks an install that was
181/// interrupted (a hard process kill mid-install): its `registered` set names
182/// what the install INTENDED to own, so on the next install/upgrade of that id
183/// the installer can (a) treat the leftover as this-plugin-owned — so the
184/// ownership pre-check doesn't false-`Conflict` on the plugin's own
185/// half-written entries — and (b) clean it up as an upgrade-over-incomplete.
186/// `uninstall` works on an `Installing` row too, so a user is never stranded
187/// having to hand-edit `config.json`.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum PluginInstallStatus {
191    /// A crash-safety journal marker: capability registration has begun but
192    /// not yet completed. `registered` records the INTENDED ownership set.
193    Installing,
194    /// The steady state: registration completed and provenance is authoritative.
195    /// The [`Default`] so a pre-journal `installed.json` (no `status` field)
196    /// deserializes as a completed install (backward compat).
197    #[default]
198    Installed,
199}
200
201/// A single installed plugin's provenance record.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct InstalledPlugin {
204    pub id: String,
205    /// The manifest `version` at the time of this install/upgrade.
206    pub version: String,
207    pub source: PluginSource,
208    /// `~/.bamboo/plugins/<id>` — where the plugin's own files live.
209    pub plugin_dir: PathBuf,
210    /// Caller-supplied timestamp (NOT computed internally — see module docs
211    /// on why: keeps this crate free of a hidden `Utc::now()` call so tests
212    /// and callers stay in full control of "when").
213    pub installed_at: DateTime<Utc>,
214    /// Crash-safety journal marker (see [`PluginInstallStatus`]). Defaults to
215    /// [`PluginInstallStatus::Installed`] so an `installed.json` written before
216    /// this field existed loads as a completed install.
217    #[serde(default)]
218    pub status: PluginInstallStatus,
219    #[serde(default)]
220    pub registered: RegisteredCapabilities,
221}
222
223/// The full `installed.json` document: `{ "plugins": [ ... ] }`.
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
225pub struct InstalledPlugins {
226    #[serde(default)]
227    pub plugins: Vec<InstalledPlugin>,
228}
229
230impl InstalledPlugins {
231    /// Load from `path`. A missing file is treated as an empty registry (this
232    /// is the state before any plugin has ever been installed) rather than an
233    /// error.
234    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    /// Persist to `path`, creating parent directories as needed.
250    ///
251    /// Writes to a sibling `<path>.tmp` first, then `rename`s it over `path`
252    /// — `rename` is atomic on the same filesystem (and `<path>.tmp` sits
253    /// right next to `path`, guaranteeing that), so a hard kill mid-write can
254    /// only ever leave a stray, harmless `.tmp` file behind, never a
255    /// truncated/corrupt `installed.json` a later `load` would choke on.
256    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    /// Look up a plugin by id.
268    pub fn get(&self, id: &str) -> Option<&InstalledPlugin> {
269        self.plugins.iter().find(|plugin| plugin.id == id)
270    }
271
272    /// Insert or replace (by id) — an upgrade re-adds the same id with a new
273    /// version/registered set, so this is an upsert rather than an append.
274    pub fn add(&mut self, plugin: InstalledPlugin) {
275        self.remove(&plugin.id);
276        self.plugins.push(plugin);
277    }
278
279    /// Remove and return the entry for `id`, if any.
280    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    /// All installed plugins, in insertion order.
286    pub fn list(&self) -> &[InstalledPlugin] {
287        &self.plugins
288    }
289}
290
291/// `<path>` with `.tmp` appended to its file name (e.g. `installed.json` ->
292/// `installed.json.tmp`) — a sibling in the SAME directory as `path`, so the
293/// `rename` in [`InstalledPlugins::save`] is guaranteed same-filesystem and
294/// therefore atomic.
295fn 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        // A second save (e.g. an upgrade re-persisting the store) must go
351        // through the same write-tmp-then-rename path and leave no trace
352        // either.
353        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        // Fresh install (no prior ownership): "a" is new, "b" collides with a
424        // user's own entry.
425        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        // Upgrade: "a" was ours last time (owned reinstall, fine); "c" is new;
437        // "d" newly collides with a user entry that appeared since → foreign.
438        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        // New version drops srv-b and preset-a, keeps the rest, adds srv-c.
474        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}