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, and any
5//! legacy workflow-copy 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. Three trust layers, all enforced by
35    /// `bamboo-server`'s `plugin_source.rs` before this record is written:
36    ///
37    /// 1. **Host allowlist** (source authorization) — was the URL's host
38    ///    fetched from an operator-trusted host (`allow_untrusted_host` opts
39    ///    out).
40    /// 2. **Signature** (publisher authenticity) — did the bundle's `.sig`
41    ///    verify against a trusted ed25519 key (`signed_by`; `allow_unsigned`
42    ///    opts out of requiring one).
43    /// 3. **Checksum** (integrity) — `sha256` is the user-verified hash of
44    ///    the downloaded BUNDLE (the `plugin.json`, or the archive containing
45    ///    it) — `Some` in the normal case, confirmed against a
46    ///    caller-supplied expected hash BEFORE anything was
47    ///    extracted/trusted.
48    ///
49    /// `sha256` is `None` either when the install explicitly opted out of
50    /// checksum verification (`allow_unverified: true`, no hash supplied), OR
51    /// when a verified signature (`signed_by: Some(_)`) already established
52    /// integrity+authenticity more strongly than a pasted checksum could —
53    /// see `plugin_source.rs`'s module docs for why a valid signature
54    /// supersedes the checksum requirement. An install refuses outright
55    /// rather than silently trusting an unpinned/unsigned download from an
56    /// untrusted host, so every `None`/`false` combination here always means
57    /// a deliberate, recorded risk acceptance, never an oversight.
58    ///
59    /// `insecure` is the convenience AGGREGATE over the three per-layer
60    /// opt-outs above: `true` when this install waived all three at once,
61    /// either via a per-install `--insecure` flag / `"insecure": true` on the
62    /// request, or because `plugin_trust.enforcement` was `off` at install
63    /// time (see `bamboo-server`'s `plugin_source.rs` module docs). Recorded
64    /// separately from the three individual `allow_*` fields so `plugin
65    /// list`/audit can tell "an operator deliberately accepted ALL risk for
66    /// this source" apart from "these three flags happened to all be set
67    /// individually" — functionally identical, but a meaningfully different
68    /// signal for review. `#[serde(default)]` so a pre-existing
69    /// `installed.json` row (written before this field existed) loads as
70    /// `false` rather than failing to deserialize.
71    Url {
72        url: String,
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        sha256: Option<String>,
75        /// Recorded for audit: true when this install ran with
76        /// `allow_unverified` and no `sha256`. `#[serde(default)]` so a
77        /// pre-existing `installed.json` row (written before this field
78        /// existed, back when only the per-platform binary artifact was
79        /// pinned) loads as `false` rather than failing to deserialize.
80        #[serde(default, skip_serializing_if = "is_false")]
81        allow_unverified: bool,
82        /// Recorded for audit: true when this install ran with
83        /// `allow_untrusted_host` against a host outside
84        /// `plugin_trust.trusted_hosts`. `#[serde(default)]` for backward
85        /// compat with rows written before this field existed.
86        #[serde(default, skip_serializing_if = "is_false")]
87        allow_untrusted_host: bool,
88        /// Recorded for audit: true when this install ran with
89        /// `allow_unsigned` (no valid signature from a trusted key).
90        /// `#[serde(default)]` for backward compat.
91        #[serde(default, skip_serializing_if = "is_false")]
92        allow_unsigned: bool,
93        /// The label of the `plugin_trust.trusted_keys` entry the bundle's
94        /// `.sig` verified against, or `None` if the install proceeded
95        /// unsigned (`allow_unsigned: true`). `#[serde(default)]` for
96        /// backward compat with rows written before signing existed.
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        signed_by: Option<String>,
99        /// True when this install ran with ALL THREE trust layers waived at
100        /// once via the `--insecure` / `"insecure": true` aggregate opt-out,
101        /// or because `plugin_trust.enforcement` was `off` — see this
102        /// variant's doc comment above. `#[serde(default)]` for backward
103        /// compat with rows written before this field existed.
104        #[serde(default, skip_serializing_if = "is_false")]
105        insecure: bool,
106    },
107}
108
109/// `skip_serializing_if` helper for a `bool` field that should be omitted
110/// from the JSON when `false` (serde has no built-in equivalent of
111/// `std::ops::Not::not` that takes a reference).
112fn is_false(value: &bool) -> bool {
113    !*value
114}
115
116/// Exactly what an installed plugin registered into Bamboo's shared capability
117/// stores. Every id/name here MUST have actually been written by the
118/// installer for THIS plugin — never a superset (that would risk clobbering
119/// or removing a user's own entries on uninstall) and never a subset
120/// (uninstall would leak orphaned registrations).
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
122pub struct RegisteredCapabilities {
123    /// Ids registered into `config.json`'s `mcpServers` map.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub mcp_server_ids: Vec<String>,
126    /// Directory names under `<plugin_dir>/skills/` that are valid skill
127    /// dirs (contain `SKILL.md`) and are therefore discoverable in place.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub skill_dirs: Vec<String>,
130    /// Ids appended into `prompt-presets.json`.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub preset_ids: Vec<String>,
133    /// Files copied into the global workflow directory by pre-#561 installers.
134    /// New plugin workflows remain in place, so new installs leave this empty;
135    /// the field remains for backward-compatible cleanup during upgrade/remove.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub workflow_filenames: Vec<String>,
138    /// Ids started via bamboo-server's `ServiceManager` (issue #479, prereq
139    /// for epic #477). `#[serde(default)]` so an `installed.json` written
140    /// before services existed loads with an empty set.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub service_ids: Vec<String>,
143}
144
145impl RegisteredCapabilities {
146    pub fn is_empty(&self) -> bool {
147        self.mcp_server_ids.is_empty()
148            && self.skill_dirs.is_empty()
149            && self.preset_ids.is_empty()
150            && self.workflow_filenames.is_empty()
151            && self.service_ids.is_empty()
152    }
153
154    /// The capabilities present in `old` (a prior install's registered set)
155    /// but ABSENT from `self` (the set the new/upgraded install will register).
156    ///
157    /// These are exactly the entries an in-place upgrade must DE-register:
158    /// their ids/filenames vanish from provenance across the upgrade, so if
159    /// they are not actively removed here they leak — orphaned forever,
160    /// un-removable because no future uninstall knows they were ours. See the
161    /// upgrade sequence in [`crate::installer`] / `PLUGIN_PLAN.md`.
162    ///
163    /// Order-preserving relative to `old` (stable output for diffing/logging).
164    pub fn removed_since(&self, old: &RegisteredCapabilities) -> RegisteredCapabilities {
165        RegisteredCapabilities {
166            mcp_server_ids: subtract(&old.mcp_server_ids, &self.mcp_server_ids),
167            skill_dirs: subtract(&old.skill_dirs, &self.skill_dirs),
168            preset_ids: subtract(&old.preset_ids, &self.preset_ids),
169            workflow_filenames: subtract(&old.workflow_filenames, &self.workflow_filenames),
170            service_ids: subtract(&old.service_ids, &self.service_ids),
171        }
172    }
173}
174
175/// Elements of `from` not present in `remove`, preserving `from`'s order.
176fn subtract(from: &[String], remove: &[String]) -> Vec<String> {
177    let drop: HashSet<&str> = remove.iter().map(String::as_str).collect();
178    from.iter()
179        .filter(|value| !drop.contains(value.as_str()))
180        .cloned()
181        .collect()
182}
183
184/// Ownership classification of one declared capability id/filename against a
185/// shared store, for the REFUSE-on-conflict capability kinds (MCP servers,
186/// workflow files). Prompt presets do NOT use this — they rename on collision
187/// via bamboo-server's `ensure_unique_preset_id` instead.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum Ownership {
190    /// Not present in the shared store — safe to create AND record as
191    /// plugin-owned/removable.
192    New,
193    /// Present, and registered by THIS plugin's prior install — an upgrade
194    /// re-registering its own entry. Safe; stays recorded as plugin-owned.
195    OwnedReinstall,
196    /// Present and NOT owned by this plugin (a user's own entry, or another
197    /// plugin's). Must block the install — never recorded as removable.
198    ForeignConflict,
199}
200
201/// Classify one id against the shared store's current `existing` ids and the
202/// `owned_previously` ids that THIS plugin's prior install registered.
203pub fn classify_ownership(
204    id: &str,
205    existing: &HashSet<&str>,
206    owned_previously: &HashSet<&str>,
207) -> Ownership {
208    if !existing.contains(id) {
209        Ownership::New
210    } else if owned_previously.contains(id) {
211        Ownership::OwnedReinstall
212    } else {
213        Ownership::ForeignConflict
214    }
215}
216
217/// Result of reconciling a plugin's declared ids/filenames against a shared
218/// store for a REFUSE-on-conflict capability (MCP servers, workflow files).
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct ExclusiveReconciliation {
221    /// Genuinely-new plus this-plugin's-own-from-a-prior-install: register
222    /// these and record them as plugin-owned/removable in provenance.
223    pub to_register: Vec<String>,
224    /// Foreign collisions (exist, not owned by this plugin). If this is
225    /// non-empty the caller MUST refuse the install (return
226    /// [`PluginError::Conflict`]) — do not register or record any of these.
227    pub foreign_conflicts: Vec<String>,
228}
229
230/// Reconcile `declared` ids against the shared store for a REFUSE-on-conflict
231/// capability. `existing` = every id currently in the shared store;
232/// `owned_previously` = the ids THIS plugin's prior install recorded (empty
233/// for a fresh install). Pure — the caller supplies the store state (which,
234/// for MCP/workflows, only the app layer can read).
235///
236/// This is the pre-check that closes BLOCKER 1: a pre-existing collision with
237/// a non-plugin entry lands in `foreign_conflicts`, so it is NEVER registered
238/// and NEVER recorded as removable — uninstall can therefore only ever delete
239/// entries this plugin genuinely created.
240pub fn reconcile_exclusive(
241    declared: &[String],
242    existing: &[String],
243    owned_previously: &[String],
244) -> ExclusiveReconciliation {
245    let existing_set: HashSet<&str> = existing.iter().map(String::as_str).collect();
246    let owned_set: HashSet<&str> = owned_previously.iter().map(String::as_str).collect();
247
248    let mut result = ExclusiveReconciliation::default();
249    for id in declared {
250        match classify_ownership(id, &existing_set, &owned_set) {
251            Ownership::New | Ownership::OwnedReinstall => result.to_register.push(id.clone()),
252            Ownership::ForeignConflict => result.foreign_conflicts.push(id.clone()),
253        }
254    }
255    result
256}
257
258/// Lifecycle status of a provenance row — the crash-safety journal marker.
259///
260/// The installer writes a row as [`Self::Installing`] BEFORE it begins
261/// registering capabilities (MCP into `config.json`, prompts, workflow files),
262/// and flips it to [`Self::Installed`] only after the whole sequence succeeds.
263/// A row left [`Self::Installing`] therefore marks an install that was
264/// interrupted (a hard process kill mid-install): its `registered` set names
265/// what the install INTENDED to own, so on the next install/upgrade of that id
266/// the installer can (a) treat the leftover as this-plugin-owned — so the
267/// ownership pre-check doesn't false-`Conflict` on the plugin's own
268/// half-written entries — and (b) clean it up as an upgrade-over-incomplete.
269/// `uninstall` works on an `Installing` row too, so a user is never stranded
270/// having to hand-edit `config.json`.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum PluginInstallStatus {
274    /// A crash-safety journal marker: capability registration has begun but
275    /// not yet completed. `registered` records the INTENDED ownership set.
276    Installing,
277    /// The steady state: registration completed and provenance is authoritative.
278    /// The [`Default`] so a pre-journal `installed.json` (no `status` field)
279    /// deserializes as a completed install (backward compat).
280    #[default]
281    Installed,
282}
283
284/// A single installed plugin's provenance record.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct InstalledPlugin {
287    pub id: String,
288    /// The manifest `version` at the time of this install/upgrade.
289    pub version: String,
290    pub source: PluginSource,
291    /// `~/.bamboo/plugins/<id>` — where the plugin's own files live.
292    pub plugin_dir: PathBuf,
293    /// Caller-supplied timestamp (NOT computed internally — see module docs
294    /// on why: keeps this crate free of a hidden `Utc::now()` call so tests
295    /// and callers stay in full control of "when").
296    pub installed_at: DateTime<Utc>,
297    /// Crash-safety journal marker (see [`PluginInstallStatus`]). Defaults to
298    /// [`PluginInstallStatus::Installed`] so an `installed.json` written before
299    /// this field existed loads as a completed install.
300    #[serde(default)]
301    pub status: PluginInstallStatus,
302    #[serde(default)]
303    pub registered: RegisteredCapabilities,
304}
305
306/// The full `installed.json` document: `{ "plugins": [ ... ] }`.
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct InstalledPlugins {
309    #[serde(default)]
310    pub plugins: Vec<InstalledPlugin>,
311}
312
313impl InstalledPlugins {
314    /// Load from `path`. A missing file is treated as an empty registry (this
315    /// is the state before any plugin has ever been installed) rather than an
316    /// error.
317    pub async fn load(path: &Path) -> PluginResult<Self> {
318        match fs::try_exists(path).await {
319            Ok(true) => {}
320            Ok(false) => return Ok(Self::default()),
321            Err(error) => return Err(PluginError::Io(error)),
322        }
323
324        let raw = fs::read_to_string(path).await?;
325        if raw.trim().is_empty() {
326            return Ok(Self::default());
327        }
328        let store: Self = serde_json::from_str(&raw)?;
329        Ok(store)
330    }
331
332    /// Persist to `path`, creating parent directories as needed.
333    ///
334    /// Writes to a sibling `<path>.tmp` first, then `rename`s it over `path`
335    /// — `rename` is atomic on the same filesystem (and `<path>.tmp` sits
336    /// right next to `path`, guaranteeing that), so a hard kill mid-write can
337    /// only ever leave a stray, harmless `.tmp` file behind, never a
338    /// truncated/corrupt `installed.json` a later `load` would choke on.
339    pub async fn save(&self, path: &Path) -> PluginResult<()> {
340        if let Some(parent) = path.parent() {
341            fs::create_dir_all(parent).await?;
342        }
343        let serialized = serde_json::to_string_pretty(self)?;
344        let tmp_path = tmp_path_for(path);
345        fs::write(&tmp_path, serialized).await?;
346        fs::rename(&tmp_path, path).await?;
347        Ok(())
348    }
349
350    /// Look up a plugin by id.
351    pub fn get(&self, id: &str) -> Option<&InstalledPlugin> {
352        self.plugins.iter().find(|plugin| plugin.id == id)
353    }
354
355    /// Insert or replace (by id) — an upgrade re-adds the same id with a new
356    /// version/registered set, so this is an upsert rather than an append.
357    pub fn add(&mut self, plugin: InstalledPlugin) {
358        self.remove(&plugin.id);
359        self.plugins.push(plugin);
360    }
361
362    /// Remove and return the entry for `id`, if any.
363    pub fn remove(&mut self, id: &str) -> Option<InstalledPlugin> {
364        let index = self.plugins.iter().position(|plugin| plugin.id == id)?;
365        Some(self.plugins.remove(index))
366    }
367
368    /// All installed plugins, in insertion order.
369    pub fn list(&self) -> &[InstalledPlugin] {
370        &self.plugins
371    }
372}
373
374/// `<path>` with `.tmp` appended to its file name (e.g. `installed.json` ->
375/// `installed.json.tmp`) — a sibling in the SAME directory as `path`, so the
376/// `rename` in [`InstalledPlugins::save`] is guaranteed same-filesystem and
377/// therefore atomic.
378fn tmp_path_for(path: &Path) -> PathBuf {
379    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
380    tmp_name.push(".tmp");
381    path.with_file_name(tmp_name)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    fn sample_plugin(id: &str) -> InstalledPlugin {
389        InstalledPlugin {
390            id: id.to_string(),
391            version: "0.1.0".to_string(),
392            source: PluginSource::LocalDir {
393                path: PathBuf::from("/tmp/source"),
394            },
395            plugin_dir: PathBuf::from(format!("/home/user/.bamboo/plugins/{id}")),
396            installed_at: DateTime::parse_from_rfc3339("2026-07-12T00:00:00Z")
397                .unwrap()
398                .with_timezone(&Utc),
399            status: PluginInstallStatus::Installed,
400            registered: RegisteredCapabilities {
401                mcp_server_ids: vec![],
402                skill_dirs: vec!["hello-world".to_string()],
403                preset_ids: vec!["hello_preset".to_string()],
404                workflow_filenames: vec![],
405                service_ids: vec![],
406            },
407        }
408    }
409
410    #[tokio::test]
411    async fn load_missing_file_returns_empty_registry() {
412        let dir = tempfile::tempdir().expect("tempdir");
413        let path = dir.path().join("plugins").join("installed.json");
414        let loaded = InstalledPlugins::load(&path).await.expect("load");
415        assert!(loaded.plugins.is_empty());
416    }
417
418    #[tokio::test]
419    async fn save_is_atomic_via_tmp_file_rename() {
420        let dir = tempfile::tempdir().expect("tempdir");
421        let path = dir.path().join("installed.json");
422        let tmp_path = tmp_path_for(&path);
423
424        let mut store = InstalledPlugins::default();
425        store.add(sample_plugin("hello-plugin"));
426        store.save(&path).await.expect("save");
427
428        assert!(path.exists(), "installed.json should exist after save");
429        assert!(
430            !tmp_path.exists(),
431            "the .tmp staging file must be renamed over the target, never left behind"
432        );
433
434        // A second save (e.g. an upgrade re-persisting the store) must go
435        // through the same write-tmp-then-rename path and leave no trace
436        // either.
437        let mut reloaded = InstalledPlugins::load(&path).await.expect("load");
438        reloaded.add(sample_plugin("other-plugin"));
439        reloaded.save(&path).await.expect("save again");
440        assert!(!tmp_path.exists());
441
442        let loaded = InstalledPlugins::load(&path).await.expect("load");
443        assert_eq!(loaded.plugins.len(), 2);
444    }
445
446    #[tokio::test]
447    async fn save_then_load_round_trips() {
448        let dir = tempfile::tempdir().expect("tempdir");
449        let path = dir.path().join("plugins").join("installed.json");
450
451        let mut store = InstalledPlugins::default();
452        store.add(sample_plugin("hello-plugin"));
453        store.add(sample_plugin("other-plugin"));
454        store.save(&path).await.expect("save");
455
456        let loaded = InstalledPlugins::load(&path).await.expect("load");
457        assert_eq!(loaded.plugins.len(), 2);
458        let hello = loaded.get("hello-plugin").expect("hello-plugin present");
459        assert_eq!(hello.version, "0.1.0");
460        assert_eq!(hello.registered.skill_dirs, vec!["hello-world".to_string()]);
461        assert_eq!(
462            hello.registered.preset_ids,
463            vec!["hello_preset".to_string()]
464        );
465        assert_eq!(
466            hello.source,
467            PluginSource::LocalDir {
468                path: PathBuf::from("/tmp/source")
469            }
470        );
471    }
472
473    #[tokio::test]
474    async fn add_upserts_by_id() {
475        let dir = tempfile::tempdir().expect("tempdir");
476        let path = dir.path().join("installed.json");
477
478        let mut store = InstalledPlugins::default();
479        store.add(sample_plugin("hello-plugin"));
480
481        let mut upgraded = sample_plugin("hello-plugin");
482        upgraded.version = "0.2.0".to_string();
483        store.add(upgraded);
484
485        assert_eq!(store.plugins.len(), 1);
486        assert_eq!(store.get("hello-plugin").unwrap().version, "0.2.0");
487
488        store.save(&path).await.expect("save");
489        let loaded = InstalledPlugins::load(&path).await.expect("load");
490        assert_eq!(loaded.plugins.len(), 1);
491        assert_eq!(loaded.get("hello-plugin").unwrap().version, "0.2.0");
492    }
493
494    #[tokio::test]
495    async fn remove_deletes_and_returns_entry() {
496        let mut store = InstalledPlugins::default();
497        store.add(sample_plugin("hello-plugin"));
498
499        let removed = store.remove("hello-plugin").expect("present before remove");
500        assert_eq!(removed.id, "hello-plugin");
501        assert!(store.get("hello-plugin").is_none());
502        assert!(store.remove("hello-plugin").is_none());
503    }
504
505    #[test]
506    fn reconcile_exclusive_fresh_install_splits_new_from_foreign() {
507        // Fresh install (no prior ownership): "a" is new, "b" collides with a
508        // user's own entry.
509        let declared = vec!["a".to_string(), "b".to_string()];
510        let existing = vec!["b".to_string(), "user-thing".to_string()];
511        let owned_previously: Vec<String> = vec![];
512
513        let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
514        assert_eq!(reconciliation.to_register, vec!["a".to_string()]);
515        assert_eq!(reconciliation.foreign_conflicts, vec!["b".to_string()]);
516    }
517
518    #[test]
519    fn reconcile_exclusive_upgrade_reregisters_own_but_refuses_new_foreign() {
520        // Upgrade: "a" was ours last time (owned reinstall, fine); "c" is new;
521        // "d" newly collides with a user entry that appeared since → foreign.
522        let declared = vec!["a".to_string(), "c".to_string(), "d".to_string()];
523        let existing = vec!["a".to_string(), "d".to_string()];
524        let owned_previously = vec!["a".to_string()];
525
526        let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
527        assert_eq!(
528            reconciliation.to_register,
529            vec!["a".to_string(), "c".to_string()]
530        );
531        assert_eq!(reconciliation.foreign_conflicts, vec!["d".to_string()]);
532    }
533
534    #[test]
535    fn classify_ownership_three_way() {
536        let existing: HashSet<&str> = ["x", "y"].into_iter().collect();
537        let owned: HashSet<&str> = ["y"].into_iter().collect();
538        assert_eq!(classify_ownership("z", &existing, &owned), Ownership::New);
539        assert_eq!(
540            classify_ownership("y", &existing, &owned),
541            Ownership::OwnedReinstall
542        );
543        assert_eq!(
544            classify_ownership("x", &existing, &owned),
545            Ownership::ForeignConflict
546        );
547    }
548
549    #[test]
550    fn removed_since_computes_dropped_capabilities_per_kind() {
551        let old = RegisteredCapabilities {
552            mcp_server_ids: vec!["srv-a".to_string(), "srv-b".to_string()],
553            skill_dirs: vec!["skill-a".to_string()],
554            preset_ids: vec!["preset-a".to_string(), "preset-b".to_string()],
555            workflow_filenames: vec!["wf-a.md".to_string()],
556            service_ids: vec!["svc-a".to_string(), "svc-b".to_string()],
557        };
558        // New version drops srv-b, preset-a, and svc-b; keeps the rest; adds srv-c.
559        let new = RegisteredCapabilities {
560            mcp_server_ids: vec!["srv-a".to_string(), "srv-c".to_string()],
561            skill_dirs: vec!["skill-a".to_string()],
562            preset_ids: vec!["preset-b".to_string()],
563            workflow_filenames: vec!["wf-a.md".to_string()],
564            service_ids: vec!["svc-a".to_string()],
565        };
566
567        let removed = new.removed_since(&old);
568        assert_eq!(removed.mcp_server_ids, vec!["srv-b".to_string()]);
569        assert!(removed.skill_dirs.is_empty());
570        assert_eq!(removed.preset_ids, vec!["preset-a".to_string()]);
571        assert!(removed.workflow_filenames.is_empty());
572        assert_eq!(removed.service_ids, vec!["svc-b".to_string()]);
573    }
574
575    #[test]
576    fn reconcile_exclusive_covers_service_ids_same_as_other_kinds() {
577        // `reconcile_exclusive` is capability-kind-agnostic (plain `Vec<String>`
578        // in/out), but exercise it explicitly against service ids since
579        // that's a new call site (issue #479's install step).
580        let declared = vec!["svc-a".to_string(), "svc-b".to_string()];
581        let existing = vec!["svc-b".to_string(), "other-plugins-svc".to_string()];
582        let owned_previously: Vec<String> = vec![];
583
584        let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
585        assert_eq!(reconciliation.to_register, vec!["svc-a".to_string()]);
586        assert_eq!(reconciliation.foreign_conflicts, vec!["svc-b".to_string()]);
587    }
588
589    #[tokio::test]
590    async fn load_empty_file_returns_empty_registry() {
591        let dir = tempfile::tempdir().expect("tempdir");
592        let path = dir.path().join("installed.json");
593        tokio::fs::create_dir_all(path.parent().unwrap())
594            .await
595            .unwrap();
596        tokio::fs::write(&path, "").await.unwrap();
597
598        let loaded = InstalledPlugins::load(&path).await.expect("load");
599        assert!(loaded.plugins.is_empty());
600    }
601}