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. 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 /// Filenames copied into `bamboo_config::paths::workflows_dir()`.
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub workflow_filenames: Vec<String>,
136 /// Ids started via bamboo-server's `ServiceManager` (issue #479, prereq
137 /// for epic #477). `#[serde(default)]` so an `installed.json` written
138 /// before services existed loads with an empty set.
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub service_ids: Vec<String>,
141}
142
143impl RegisteredCapabilities {
144 pub fn is_empty(&self) -> bool {
145 self.mcp_server_ids.is_empty()
146 && self.skill_dirs.is_empty()
147 && self.preset_ids.is_empty()
148 && self.workflow_filenames.is_empty()
149 && self.service_ids.is_empty()
150 }
151
152 /// The capabilities present in `old` (a prior install's registered set)
153 /// but ABSENT from `self` (the set the new/upgraded install will register).
154 ///
155 /// These are exactly the entries an in-place upgrade must DE-register:
156 /// their ids/filenames vanish from provenance across the upgrade, so if
157 /// they are not actively removed here they leak — orphaned forever,
158 /// un-removable because no future uninstall knows they were ours. See the
159 /// upgrade sequence in [`crate::installer`] / `PLUGIN_PLAN.md`.
160 ///
161 /// Order-preserving relative to `old` (stable output for diffing/logging).
162 pub fn removed_since(&self, old: &RegisteredCapabilities) -> RegisteredCapabilities {
163 RegisteredCapabilities {
164 mcp_server_ids: subtract(&old.mcp_server_ids, &self.mcp_server_ids),
165 skill_dirs: subtract(&old.skill_dirs, &self.skill_dirs),
166 preset_ids: subtract(&old.preset_ids, &self.preset_ids),
167 workflow_filenames: subtract(&old.workflow_filenames, &self.workflow_filenames),
168 service_ids: subtract(&old.service_ids, &self.service_ids),
169 }
170 }
171}
172
173/// Elements of `from` not present in `remove`, preserving `from`'s order.
174fn subtract(from: &[String], remove: &[String]) -> Vec<String> {
175 let drop: HashSet<&str> = remove.iter().map(String::as_str).collect();
176 from.iter()
177 .filter(|value| !drop.contains(value.as_str()))
178 .cloned()
179 .collect()
180}
181
182/// Ownership classification of one declared capability id/filename against a
183/// shared store, for the REFUSE-on-conflict capability kinds (MCP servers,
184/// workflow files). Prompt presets do NOT use this — they rename on collision
185/// via bamboo-server's `ensure_unique_preset_id` instead.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Ownership {
188 /// Not present in the shared store — safe to create AND record as
189 /// plugin-owned/removable.
190 New,
191 /// Present, and registered by THIS plugin's prior install — an upgrade
192 /// re-registering its own entry. Safe; stays recorded as plugin-owned.
193 OwnedReinstall,
194 /// Present and NOT owned by this plugin (a user's own entry, or another
195 /// plugin's). Must block the install — never recorded as removable.
196 ForeignConflict,
197}
198
199/// Classify one id against the shared store's current `existing` ids and the
200/// `owned_previously` ids that THIS plugin's prior install registered.
201pub fn classify_ownership(
202 id: &str,
203 existing: &HashSet<&str>,
204 owned_previously: &HashSet<&str>,
205) -> Ownership {
206 if !existing.contains(id) {
207 Ownership::New
208 } else if owned_previously.contains(id) {
209 Ownership::OwnedReinstall
210 } else {
211 Ownership::ForeignConflict
212 }
213}
214
215/// Result of reconciling a plugin's declared ids/filenames against a shared
216/// store for a REFUSE-on-conflict capability (MCP servers, workflow files).
217#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub struct ExclusiveReconciliation {
219 /// Genuinely-new plus this-plugin's-own-from-a-prior-install: register
220 /// these and record them as plugin-owned/removable in provenance.
221 pub to_register: Vec<String>,
222 /// Foreign collisions (exist, not owned by this plugin). If this is
223 /// non-empty the caller MUST refuse the install (return
224 /// [`PluginError::Conflict`]) — do not register or record any of these.
225 pub foreign_conflicts: Vec<String>,
226}
227
228/// Reconcile `declared` ids against the shared store for a REFUSE-on-conflict
229/// capability. `existing` = every id currently in the shared store;
230/// `owned_previously` = the ids THIS plugin's prior install recorded (empty
231/// for a fresh install). Pure — the caller supplies the store state (which,
232/// for MCP/workflows, only the app layer can read).
233///
234/// This is the pre-check that closes BLOCKER 1: a pre-existing collision with
235/// a non-plugin entry lands in `foreign_conflicts`, so it is NEVER registered
236/// and NEVER recorded as removable — uninstall can therefore only ever delete
237/// entries this plugin genuinely created.
238pub fn reconcile_exclusive(
239 declared: &[String],
240 existing: &[String],
241 owned_previously: &[String],
242) -> ExclusiveReconciliation {
243 let existing_set: HashSet<&str> = existing.iter().map(String::as_str).collect();
244 let owned_set: HashSet<&str> = owned_previously.iter().map(String::as_str).collect();
245
246 let mut result = ExclusiveReconciliation::default();
247 for id in declared {
248 match classify_ownership(id, &existing_set, &owned_set) {
249 Ownership::New | Ownership::OwnedReinstall => result.to_register.push(id.clone()),
250 Ownership::ForeignConflict => result.foreign_conflicts.push(id.clone()),
251 }
252 }
253 result
254}
255
256/// Lifecycle status of a provenance row — the crash-safety journal marker.
257///
258/// The installer writes a row as [`Self::Installing`] BEFORE it begins
259/// registering capabilities (MCP into `config.json`, prompts, workflow files),
260/// and flips it to [`Self::Installed`] only after the whole sequence succeeds.
261/// A row left [`Self::Installing`] therefore marks an install that was
262/// interrupted (a hard process kill mid-install): its `registered` set names
263/// what the install INTENDED to own, so on the next install/upgrade of that id
264/// the installer can (a) treat the leftover as this-plugin-owned — so the
265/// ownership pre-check doesn't false-`Conflict` on the plugin's own
266/// half-written entries — and (b) clean it up as an upgrade-over-incomplete.
267/// `uninstall` works on an `Installing` row too, so a user is never stranded
268/// having to hand-edit `config.json`.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
270#[serde(rename_all = "snake_case")]
271pub enum PluginInstallStatus {
272 /// A crash-safety journal marker: capability registration has begun but
273 /// not yet completed. `registered` records the INTENDED ownership set.
274 Installing,
275 /// The steady state: registration completed and provenance is authoritative.
276 /// The [`Default`] so a pre-journal `installed.json` (no `status` field)
277 /// deserializes as a completed install (backward compat).
278 #[default]
279 Installed,
280}
281
282/// A single installed plugin's provenance record.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct InstalledPlugin {
285 pub id: String,
286 /// The manifest `version` at the time of this install/upgrade.
287 pub version: String,
288 pub source: PluginSource,
289 /// `~/.bamboo/plugins/<id>` — where the plugin's own files live.
290 pub plugin_dir: PathBuf,
291 /// Caller-supplied timestamp (NOT computed internally — see module docs
292 /// on why: keeps this crate free of a hidden `Utc::now()` call so tests
293 /// and callers stay in full control of "when").
294 pub installed_at: DateTime<Utc>,
295 /// Crash-safety journal marker (see [`PluginInstallStatus`]). Defaults to
296 /// [`PluginInstallStatus::Installed`] so an `installed.json` written before
297 /// this field existed loads as a completed install.
298 #[serde(default)]
299 pub status: PluginInstallStatus,
300 #[serde(default)]
301 pub registered: RegisteredCapabilities,
302}
303
304/// The full `installed.json` document: `{ "plugins": [ ... ] }`.
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct InstalledPlugins {
307 #[serde(default)]
308 pub plugins: Vec<InstalledPlugin>,
309}
310
311impl InstalledPlugins {
312 /// Load from `path`. A missing file is treated as an empty registry (this
313 /// is the state before any plugin has ever been installed) rather than an
314 /// error.
315 pub async fn load(path: &Path) -> PluginResult<Self> {
316 match fs::try_exists(path).await {
317 Ok(true) => {}
318 Ok(false) => return Ok(Self::default()),
319 Err(error) => return Err(PluginError::Io(error)),
320 }
321
322 let raw = fs::read_to_string(path).await?;
323 if raw.trim().is_empty() {
324 return Ok(Self::default());
325 }
326 let store: Self = serde_json::from_str(&raw)?;
327 Ok(store)
328 }
329
330 /// Persist to `path`, creating parent directories as needed.
331 ///
332 /// Writes to a sibling `<path>.tmp` first, then `rename`s it over `path`
333 /// — `rename` is atomic on the same filesystem (and `<path>.tmp` sits
334 /// right next to `path`, guaranteeing that), so a hard kill mid-write can
335 /// only ever leave a stray, harmless `.tmp` file behind, never a
336 /// truncated/corrupt `installed.json` a later `load` would choke on.
337 pub async fn save(&self, path: &Path) -> PluginResult<()> {
338 if let Some(parent) = path.parent() {
339 fs::create_dir_all(parent).await?;
340 }
341 let serialized = serde_json::to_string_pretty(self)?;
342 let tmp_path = tmp_path_for(path);
343 fs::write(&tmp_path, serialized).await?;
344 fs::rename(&tmp_path, path).await?;
345 Ok(())
346 }
347
348 /// Look up a plugin by id.
349 pub fn get(&self, id: &str) -> Option<&InstalledPlugin> {
350 self.plugins.iter().find(|plugin| plugin.id == id)
351 }
352
353 /// Insert or replace (by id) — an upgrade re-adds the same id with a new
354 /// version/registered set, so this is an upsert rather than an append.
355 pub fn add(&mut self, plugin: InstalledPlugin) {
356 self.remove(&plugin.id);
357 self.plugins.push(plugin);
358 }
359
360 /// Remove and return the entry for `id`, if any.
361 pub fn remove(&mut self, id: &str) -> Option<InstalledPlugin> {
362 let index = self.plugins.iter().position(|plugin| plugin.id == id)?;
363 Some(self.plugins.remove(index))
364 }
365
366 /// All installed plugins, in insertion order.
367 pub fn list(&self) -> &[InstalledPlugin] {
368 &self.plugins
369 }
370}
371
372/// `<path>` with `.tmp` appended to its file name (e.g. `installed.json` ->
373/// `installed.json.tmp`) — a sibling in the SAME directory as `path`, so the
374/// `rename` in [`InstalledPlugins::save`] is guaranteed same-filesystem and
375/// therefore atomic.
376fn tmp_path_for(path: &Path) -> PathBuf {
377 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
378 tmp_name.push(".tmp");
379 path.with_file_name(tmp_name)
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn sample_plugin(id: &str) -> InstalledPlugin {
387 InstalledPlugin {
388 id: id.to_string(),
389 version: "0.1.0".to_string(),
390 source: PluginSource::LocalDir {
391 path: PathBuf::from("/tmp/source"),
392 },
393 plugin_dir: PathBuf::from(format!("/home/user/.bamboo/plugins/{id}")),
394 installed_at: DateTime::parse_from_rfc3339("2026-07-12T00:00:00Z")
395 .unwrap()
396 .with_timezone(&Utc),
397 status: PluginInstallStatus::Installed,
398 registered: RegisteredCapabilities {
399 mcp_server_ids: vec![],
400 skill_dirs: vec!["hello-world".to_string()],
401 preset_ids: vec!["hello_preset".to_string()],
402 workflow_filenames: vec![],
403 service_ids: vec![],
404 },
405 }
406 }
407
408 #[tokio::test]
409 async fn load_missing_file_returns_empty_registry() {
410 let dir = tempfile::tempdir().expect("tempdir");
411 let path = dir.path().join("plugins").join("installed.json");
412 let loaded = InstalledPlugins::load(&path).await.expect("load");
413 assert!(loaded.plugins.is_empty());
414 }
415
416 #[tokio::test]
417 async fn save_is_atomic_via_tmp_file_rename() {
418 let dir = tempfile::tempdir().expect("tempdir");
419 let path = dir.path().join("installed.json");
420 let tmp_path = tmp_path_for(&path);
421
422 let mut store = InstalledPlugins::default();
423 store.add(sample_plugin("hello-plugin"));
424 store.save(&path).await.expect("save");
425
426 assert!(path.exists(), "installed.json should exist after save");
427 assert!(
428 !tmp_path.exists(),
429 "the .tmp staging file must be renamed over the target, never left behind"
430 );
431
432 // A second save (e.g. an upgrade re-persisting the store) must go
433 // through the same write-tmp-then-rename path and leave no trace
434 // either.
435 let mut reloaded = InstalledPlugins::load(&path).await.expect("load");
436 reloaded.add(sample_plugin("other-plugin"));
437 reloaded.save(&path).await.expect("save again");
438 assert!(!tmp_path.exists());
439
440 let loaded = InstalledPlugins::load(&path).await.expect("load");
441 assert_eq!(loaded.plugins.len(), 2);
442 }
443
444 #[tokio::test]
445 async fn save_then_load_round_trips() {
446 let dir = tempfile::tempdir().expect("tempdir");
447 let path = dir.path().join("plugins").join("installed.json");
448
449 let mut store = InstalledPlugins::default();
450 store.add(sample_plugin("hello-plugin"));
451 store.add(sample_plugin("other-plugin"));
452 store.save(&path).await.expect("save");
453
454 let loaded = InstalledPlugins::load(&path).await.expect("load");
455 assert_eq!(loaded.plugins.len(), 2);
456 let hello = loaded.get("hello-plugin").expect("hello-plugin present");
457 assert_eq!(hello.version, "0.1.0");
458 assert_eq!(hello.registered.skill_dirs, vec!["hello-world".to_string()]);
459 assert_eq!(
460 hello.registered.preset_ids,
461 vec!["hello_preset".to_string()]
462 );
463 assert_eq!(
464 hello.source,
465 PluginSource::LocalDir {
466 path: PathBuf::from("/tmp/source")
467 }
468 );
469 }
470
471 #[tokio::test]
472 async fn add_upserts_by_id() {
473 let dir = tempfile::tempdir().expect("tempdir");
474 let path = dir.path().join("installed.json");
475
476 let mut store = InstalledPlugins::default();
477 store.add(sample_plugin("hello-plugin"));
478
479 let mut upgraded = sample_plugin("hello-plugin");
480 upgraded.version = "0.2.0".to_string();
481 store.add(upgraded);
482
483 assert_eq!(store.plugins.len(), 1);
484 assert_eq!(store.get("hello-plugin").unwrap().version, "0.2.0");
485
486 store.save(&path).await.expect("save");
487 let loaded = InstalledPlugins::load(&path).await.expect("load");
488 assert_eq!(loaded.plugins.len(), 1);
489 assert_eq!(loaded.get("hello-plugin").unwrap().version, "0.2.0");
490 }
491
492 #[tokio::test]
493 async fn remove_deletes_and_returns_entry() {
494 let mut store = InstalledPlugins::default();
495 store.add(sample_plugin("hello-plugin"));
496
497 let removed = store.remove("hello-plugin").expect("present before remove");
498 assert_eq!(removed.id, "hello-plugin");
499 assert!(store.get("hello-plugin").is_none());
500 assert!(store.remove("hello-plugin").is_none());
501 }
502
503 #[test]
504 fn reconcile_exclusive_fresh_install_splits_new_from_foreign() {
505 // Fresh install (no prior ownership): "a" is new, "b" collides with a
506 // user's own entry.
507 let declared = vec!["a".to_string(), "b".to_string()];
508 let existing = vec!["b".to_string(), "user-thing".to_string()];
509 let owned_previously: Vec<String> = vec![];
510
511 let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
512 assert_eq!(reconciliation.to_register, vec!["a".to_string()]);
513 assert_eq!(reconciliation.foreign_conflicts, vec!["b".to_string()]);
514 }
515
516 #[test]
517 fn reconcile_exclusive_upgrade_reregisters_own_but_refuses_new_foreign() {
518 // Upgrade: "a" was ours last time (owned reinstall, fine); "c" is new;
519 // "d" newly collides with a user entry that appeared since → foreign.
520 let declared = vec!["a".to_string(), "c".to_string(), "d".to_string()];
521 let existing = vec!["a".to_string(), "d".to_string()];
522 let owned_previously = vec!["a".to_string()];
523
524 let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
525 assert_eq!(
526 reconciliation.to_register,
527 vec!["a".to_string(), "c".to_string()]
528 );
529 assert_eq!(reconciliation.foreign_conflicts, vec!["d".to_string()]);
530 }
531
532 #[test]
533 fn classify_ownership_three_way() {
534 let existing: HashSet<&str> = ["x", "y"].into_iter().collect();
535 let owned: HashSet<&str> = ["y"].into_iter().collect();
536 assert_eq!(classify_ownership("z", &existing, &owned), Ownership::New);
537 assert_eq!(
538 classify_ownership("y", &existing, &owned),
539 Ownership::OwnedReinstall
540 );
541 assert_eq!(
542 classify_ownership("x", &existing, &owned),
543 Ownership::ForeignConflict
544 );
545 }
546
547 #[test]
548 fn removed_since_computes_dropped_capabilities_per_kind() {
549 let old = RegisteredCapabilities {
550 mcp_server_ids: vec!["srv-a".to_string(), "srv-b".to_string()],
551 skill_dirs: vec!["skill-a".to_string()],
552 preset_ids: vec!["preset-a".to_string(), "preset-b".to_string()],
553 workflow_filenames: vec!["wf-a.md".to_string()],
554 service_ids: vec!["svc-a".to_string(), "svc-b".to_string()],
555 };
556 // New version drops srv-b, preset-a, and svc-b; keeps the rest; adds srv-c.
557 let new = RegisteredCapabilities {
558 mcp_server_ids: vec!["srv-a".to_string(), "srv-c".to_string()],
559 skill_dirs: vec!["skill-a".to_string()],
560 preset_ids: vec!["preset-b".to_string()],
561 workflow_filenames: vec!["wf-a.md".to_string()],
562 service_ids: vec!["svc-a".to_string()],
563 };
564
565 let removed = new.removed_since(&old);
566 assert_eq!(removed.mcp_server_ids, vec!["srv-b".to_string()]);
567 assert!(removed.skill_dirs.is_empty());
568 assert_eq!(removed.preset_ids, vec!["preset-a".to_string()]);
569 assert!(removed.workflow_filenames.is_empty());
570 assert_eq!(removed.service_ids, vec!["svc-b".to_string()]);
571 }
572
573 #[test]
574 fn reconcile_exclusive_covers_service_ids_same_as_other_kinds() {
575 // `reconcile_exclusive` is capability-kind-agnostic (plain `Vec<String>`
576 // in/out), but exercise it explicitly against service ids since
577 // that's a new call site (issue #479's install step).
578 let declared = vec!["svc-a".to_string(), "svc-b".to_string()];
579 let existing = vec!["svc-b".to_string(), "other-plugins-svc".to_string()];
580 let owned_previously: Vec<String> = vec![];
581
582 let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
583 assert_eq!(reconciliation.to_register, vec!["svc-a".to_string()]);
584 assert_eq!(reconciliation.foreign_conflicts, vec!["svc-b".to_string()]);
585 }
586
587 #[tokio::test]
588 async fn load_empty_file_returns_empty_registry() {
589 let dir = tempfile::tempdir().expect("tempdir");
590 let path = dir.path().join("installed.json");
591 tokio::fs::create_dir_all(path.parent().unwrap())
592 .await
593 .unwrap();
594 tokio::fs::write(&path, "").await.unwrap();
595
596 let loaded = InstalledPlugins::load(&path).await.expect("load");
597 assert!(loaded.plugins.is_empty());
598 }
599}