Files
IdeA/crates/application/src/plugin/mod.rs
Blomios bb35641715 feat(backend): système de plugins — domaine, application, infrastructure (#43)
Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP),
port et registre applicatif, chargement/validation en infrastructure, exposition DTO
et commandes Tauri. Tests cargo verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:37:03 +02:00

1715 lines
55 KiB
Rust

//! Plugin application use cases.
use std::collections::HashSet;
use std::sync::Arc;
use domain::ports::{
EventBus, LocalPath, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
PluginRegistryStore, PluginStoreError,
};
use domain::{
ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId,
PluginInstallSource, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
PluginRegistryEntry, PluginTrustLevel, RemovalOutcome, StagedPluginPackage,
};
use serde::{Deserialize, Serialize};
use crate::AppError;
/// Contribution counts for admin display.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContributionSummary {
/// Top-level menu count.
pub top_level_menus: usize,
/// Menu item count.
pub menu_items: usize,
/// Layout count.
pub layouts: usize,
/// MCP server count.
pub mcp_servers: usize,
}
impl From<&PluginContributionSet> for PluginContributionSummary {
fn from(c: &PluginContributionSet) -> Self {
Self {
top_level_menus: c.menus.len(),
menu_items: c.menu_items.len(),
layouts: c.layouts.len(),
mcp_servers: c.mcp_servers.len(),
}
}
}
/// Admin plugin view.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginAdmin {
/// Plugin id.
pub id: String,
/// Display name.
pub display_name: String,
/// Publisher.
pub publisher: Option<String>,
/// Version.
pub version: String,
/// Description.
pub description: Option<String>,
/// Icon URL.
pub icon_url: Option<String>,
/// Source kind.
pub source_kind: String,
/// Source label.
pub source_label: Option<String>,
/// Lifecycle state.
pub lifecycle_state: PluginLifecycleState,
/// Enabled projection.
pub enabled: bool,
/// Pending enable state.
pub pending_enable_state: Option<bool>,
/// Pending uninstall flag.
pub pending_uninstall: bool,
/// Restart required flag.
pub restart_required: bool,
/// Trust level.
pub trust_level: PluginTrustLevel,
/// Summary.
pub contribution_summary: PluginContributionSummary,
/// Error.
pub error: Option<String>,
}
/// Pre-install package review.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginReview {
/// Manifest.
pub manifest: PluginManifest,
/// Source.
pub source: PluginInstallSource,
/// Content hash.
pub content_hash: String,
/// Summary.
pub contribution_summary: PluginContributionSummary,
/// Full-trust marker.
pub trust_level: PluginTrustLevel,
}
/// Install result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginInstallResult {
/// Installed plugin.
pub plugin: PluginAdmin,
/// Review used for installation.
pub review: PluginReview,
/// Restart required flag.
pub restart_required: bool,
}
/// Uninstall result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UninstallPluginResult {
/// Plugin id.
pub plugin_id: String,
/// Removal outcome.
pub removal_outcome: RemovalOutcome,
/// Restart required flag.
pub restart_required: bool,
}
/// Runtime catalog.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimeCatalog {
/// Runtime plugins.
pub plugins: Vec<PluginRuntimePlugin>,
}
/// Runtime plugin entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimePlugin {
/// Plugin id.
pub id: String,
/// Display name.
pub display_name: String,
/// Publisher.
pub publisher: Option<String>,
/// Version.
pub version: String,
/// Bundle URL.
pub bundle_url: String,
/// Icon URL.
pub icon_url: Option<String>,
/// Content hash.
pub content_hash: String,
/// Contributions.
pub contributes: PluginContributionSet,
}
/// Input for reviewing a package.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPluginPackageInput {
/// Review local archive.
Archive {
/// Path.
path: String,
},
/// Review local directory.
Directory {
/// Path.
path: String,
},
}
fn map_store(e: PluginStoreError) -> AppError {
match e {
PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()),
PluginStoreError::Invalid(m) | PluginStoreError::Format(m) => AppError::Invalid(m),
PluginStoreError::Io(m) => AppError::FileSystem(m),
}
}
fn map_registry(e: PluginRegistryError) -> AppError {
match e {
PluginRegistryError::Io(m) => AppError::Store(m),
PluginRegistryError::Serialization(m) => AppError::Store(m),
}
}
fn map_manifest(e: PluginManifestError) -> AppError {
match e {
PluginManifestError::Json(m)
| PluginManifestError::Invalid(m)
| PluginManifestError::IncompatibleEngine(m) => AppError::Invalid(m),
}
}
fn map_mcp(e: PluginMcpError) -> AppError {
AppError::Process(e.to_string())
}
fn plugin_package_ref(id: &PluginId) -> domain::PluginPackageRef {
domain::PluginPackageRef {
plugin_id: Some(id.clone()),
root: id.as_str().to_owned(),
}
}
fn admin_from_descriptor(
d: PluginDescriptor,
_packages: &dyn PluginPackageStore,
) -> Result<PluginAdmin, AppError> {
let icon_url = match &d.manifest.icon {
Some(icon) => Some(plugin_asset_url(
&d.manifest.id,
d.manifest.version.as_str(),
&d.registry.content_hash,
icon,
)),
None => None,
};
Ok(PluginAdmin {
id: d.manifest.id.as_str().to_owned(),
display_name: d.manifest.display_name,
publisher: d.manifest.publisher,
version: d.manifest.version.as_str().to_owned(),
description: d.manifest.description,
icon_url,
source_kind: d.registry.source.kind().to_owned(),
source_label: Some(d.registry.source.label().to_owned()),
lifecycle_state: d.registry.lifecycle_state,
enabled: matches!(
d.registry.lifecycle_state,
PluginLifecycleState::Enabled | PluginLifecycleState::PendingEnable
),
pending_enable_state: match d.registry.lifecycle_state {
PluginLifecycleState::PendingEnable => Some(true),
PluginLifecycleState::PendingDisable => Some(false),
_ => None,
},
pending_uninstall: d.registry.lifecycle_state == PluginLifecycleState::PendingUninstall,
restart_required: d.registry.restart_required,
trust_level: d.manifest.trust_level,
contribution_summary: PluginContributionSummary::from(&d.manifest.contributes),
error: d.registry.error,
})
}
fn plugin_asset_url(
plugin_id: &PluginId,
version: &str,
hash: &ContentHash,
path: &domain::RelativePath,
) -> String {
format!(
"idea-plugin://{}/{}/{}/{}",
plugin_id.as_str(),
version,
hash.as_str(),
path.as_str()
)
}
async fn descriptor_for(
packages: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
entry: PluginRegistryEntry,
) -> Result<PluginDescriptor, AppError> {
let bytes = packages
.read_manifest(&plugin_package_ref(&entry.id))
.await
.map_err(map_store)?;
let manifest = validator
.validate(&bytes.bytes, &plugin_package_ref(&entry.id))
.map_err(map_manifest)?;
Ok(PluginDescriptor {
manifest,
registry: entry,
})
}
/// Lists admin plugins.
pub struct ListPlugins {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl ListPlugins {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
packages,
registry,
validator,
}
}
/// Executes the use case.
pub async fn execute(&self) -> Result<Vec<PluginAdmin>, AppError> {
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let mut out = Vec::new();
for entry in registry.plugins {
match descriptor_for(
self.packages.as_ref(),
self.validator.as_ref(),
entry.clone(),
)
.await
{
Ok(d) => out.push(admin_from_descriptor(d, self.packages.as_ref())?),
Err(err) => {
let invalid = PluginRegistryEntry {
lifecycle_state: PluginLifecycleState::Invalid,
error: Some(err.to_string()),
..entry
};
let placeholder = PluginManifest {
idea_plugin_manifest_version: 1,
id: invalid.id.clone(),
display_name: invalid.id.as_str().to_owned(),
publisher: None,
version: domain::PluginVersion::new("0.0.0")
.expect("literal semver is valid"),
description: None,
engine_idea: None,
main: domain::RelativePath::new("dist/index.js")
.expect("literal path is valid"),
icon: None,
trust_level: PluginTrustLevel::Full,
capabilities: Vec::new(),
contributes: PluginContributionSet::default(),
};
out.push(admin_from_descriptor(
PluginDescriptor {
manifest: placeholder,
registry: invalid,
},
self.packages.as_ref(),
)?);
}
}
}
out.sort_by(|a, b| a.display_name.cmp(&b.display_name).then(a.id.cmp(&b.id)));
Ok(out)
}
}
/// Reviews a plugin package without committing it.
pub struct ReviewPluginPackage {
packages: Arc<dyn PluginPackageStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl ReviewPluginPackage {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
packages,
validator,
}
}
/// Executes the use case.
pub async fn execute(&self, input: ReviewPluginPackageInput) -> Result<PluginReview, AppError> {
let staged = match input {
ReviewPluginPackageInput::Archive { path } => self
.packages
.install_from_archive(&LocalPath::new(path))
.await
.map_err(map_store)?,
ReviewPluginPackageInput::Directory { path } => self
.packages
.install_from_directory(&LocalPath::new(path))
.await
.map_err(map_store)?,
};
review_staged(self.packages.as_ref(), self.validator.as_ref(), &staged).await
}
}
async fn review_staged(
packages: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
staged: &StagedPluginPackage,
) -> Result<PluginReview, AppError> {
let package = domain::PluginPackageRef {
plugin_id: None,
root: staged.root.clone(),
};
let PluginManifestBytes { bytes } =
packages.read_manifest(&package).await.map_err(map_store)?;
let manifest = validator.validate(&bytes, &package).map_err(map_manifest)?;
Ok(PluginReview {
contribution_summary: PluginContributionSummary::from(&manifest.contributes),
trust_level: manifest.trust_level,
manifest,
source: staged.source.clone(),
content_hash: staged.content_hash.as_str().to_owned(),
})
}
/// Installs from archive.
pub struct InstallPluginFromArchive {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl InstallPluginFromArchive {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
validator,
events,
mcp,
}
}
/// Executes the use case.
pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
install_from_staged(
self.packages.as_ref(),
self.registry.as_ref(),
self.validator.as_ref(),
self.events.as_ref(),
self.mcp.as_ref(),
self.packages
.install_from_archive(&LocalPath::new(path))
.await
.map_err(map_store)?,
)
.await
}
}
/// Installs from directory.
pub struct InstallPluginFromDirectory {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl InstallPluginFromDirectory {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
validator,
events,
mcp,
}
}
/// Executes the use case.
pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
install_from_staged(
self.packages.as_ref(),
self.registry.as_ref(),
self.validator.as_ref(),
self.events.as_ref(),
self.mcp.as_ref(),
self.packages
.install_from_directory(&LocalPath::new(path))
.await
.map_err(map_store)?,
)
.await
}
}
async fn install_from_staged(
packages: &dyn PluginPackageStore,
registry_store: &dyn PluginRegistryStore,
validator: &dyn PluginManifestValidator,
events: &dyn EventBus,
mcp: &dyn PluginMcpSupervisor,
staged: StagedPluginPackage,
) -> Result<PluginInstallResult, AppError> {
let review = review_staged(packages, validator, &staged).await?;
let plugin_id = review.manifest.id.clone();
packages
.commit_install(staged, &plugin_id)
.await
.map_err(map_store)?;
let mut registry = registry_store.load_registry().await.map_err(map_registry)?;
let entry = PluginRegistryEntry {
id: plugin_id.clone(),
lifecycle_state: PluginLifecycleState::Enabled,
source: review.source.clone(),
content_hash: ContentHash::new(review.content_hash.clone())
.map_err(|e| AppError::Invalid(e.to_string()))?,
restart_required: true,
error: None,
};
registry.upsert(entry.clone());
registry_store
.save_registry(&registry)
.await
.map_err(map_registry)?;
events.publish(DomainEvent::PluginInstalled {
plugin_id: plugin_id.clone(),
version: review.manifest.version.clone(),
});
let _ = mcp
.reconcile(active_mcp_specs(packages, validator, &registry).await?)
.await;
let admin = admin_from_descriptor(
PluginDescriptor {
manifest: review.manifest.clone(),
registry: entry,
},
packages,
)?;
Ok(PluginInstallResult {
plugin: admin,
review,
restart_required: true,
})
}
/// Enable/disable input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetPluginEnabledInput {
/// Plugin id.
pub plugin_id: String,
/// Desired enabled state.
pub enabled: bool,
}
/// Enables or disables a plugin.
pub struct SetPluginEnabled {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl SetPluginEnabled {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
validator,
events,
mcp,
}
}
/// Executes the use case.
pub async fn execute(&self, input: SetPluginEnabledInput) -> Result<PluginAdmin, AppError> {
let plugin_id =
PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?;
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
let entry = registry
.plugins
.iter_mut()
.find(|p| p.id == plugin_id)
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
entry.lifecycle_state = if input.enabled {
PluginLifecycleState::Enabled
} else {
PluginLifecycleState::Disabled
};
entry.restart_required = true;
let saved = entry.clone();
self.registry
.save_registry(&registry)
.await
.map_err(map_registry)?;
if input.enabled {
self.events.publish(DomainEvent::PluginEnabled {
plugin_id: plugin_id.clone(),
});
} else {
self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?;
self.events.publish(DomainEvent::PluginDisabled {
plugin_id: plugin_id.clone(),
restart_required: true,
});
}
let _ = self
.mcp
.reconcile(
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), &registry)
.await?,
)
.await;
let descriptor =
descriptor_for(self.packages.as_ref(), self.validator.as_ref(), saved).await?;
admin_from_descriptor(descriptor, self.packages.as_ref())
}
}
/// Uninstall input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UninstallPluginInput {
/// Plugin id.
pub plugin_id: String,
}
/// Uninstalls a plugin.
pub struct UninstallPlugin {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl UninstallPlugin {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
events,
mcp,
}
}
/// Executes the use case.
pub async fn execute(
&self,
input: UninstallPluginInput,
) -> Result<UninstallPluginResult, AppError> {
let plugin_id =
PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?;
self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?;
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
registry
.remove(&plugin_id)
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
self.registry
.save_registry(&registry)
.await
.map_err(map_registry)?;
let removal = self
.packages
.remove_package(&plugin_id)
.await
.map_err(map_store)?;
self.events.publish(DomainEvent::PluginUninstalled {
plugin_id: plugin_id.clone(),
restart_required: true,
});
Ok(UninstallPluginResult {
plugin_id: plugin_id.as_str().to_owned(),
removal_outcome: removal,
restart_required: true,
})
}
}
/// Lists runtime contributions.
pub struct ListPluginRuntimeContributions {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl ListPluginRuntimeContributions {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
packages,
registry,
validator,
}
}
/// Executes the use case.
pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let mut plugins = Vec::new();
for entry in registry.plugins {
if !entry.lifecycle_state.is_runtime_active() {
continue;
}
let descriptor =
descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?;
let bundle = plugin_asset_url(
&descriptor.manifest.id,
descriptor.manifest.version.as_str(),
&descriptor.registry.content_hash,
&descriptor.manifest.main,
);
let icon_url = match &descriptor.manifest.icon {
Some(icon) => Some(plugin_asset_url(
&descriptor.manifest.id,
descriptor.manifest.version.as_str(),
&descriptor.registry.content_hash,
icon,
)),
None => None,
};
plugins.push(PluginRuntimePlugin {
id: descriptor.manifest.id.as_str().to_owned(),
display_name: descriptor.manifest.display_name,
publisher: descriptor.manifest.publisher,
version: descriptor.manifest.version.as_str().to_owned(),
bundle_url: bundle,
icon_url,
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
contributes: descriptor.manifest.contributes,
});
}
Ok(PluginRuntimeCatalog { plugins })
}
}
/// Reconciles plugin MCP servers.
pub struct ReconcilePluginMcpServers {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl ReconcilePluginMcpServers {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
validator,
mcp,
}
}
/// Executes the use case.
pub async fn execute(&self) -> Result<domain::PluginMcpStatusSet, AppError> {
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let specs =
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), &registry).await?;
self.mcp.reconcile(specs).await.map_err(map_mcp)
}
}
async fn active_mcp_specs(
packages: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
registry: &domain::PluginRegistry,
) -> Result<Vec<PluginMcpServerSpec>, AppError> {
let installed_roots = packages
.list_installed()
.await
.map_err(map_store)?
.into_iter()
.filter_map(|p| p.plugin_id.clone().map(|id| (id, p.root)))
.collect::<std::collections::HashMap<_, _>>();
let app_data_dir = packages.app_data_dir_label();
let mut specs = Vec::new();
for entry in &registry.plugins {
if !entry.lifecycle_state.is_runtime_active() {
continue;
}
let descriptor = descriptor_for(packages, validator, entry.clone()).await?;
let plugin_root = installed_roots
.get(&descriptor.manifest.id)
.cloned()
.unwrap_or_else(|| plugin_package_ref(&descriptor.manifest.id).root);
for server in descriptor.manifest.contributes.mcp_servers {
if !server.auto_start {
continue;
}
let command = substitute_vars(&server.command, &plugin_root, app_data_dir.as_deref());
let command = if server.allow_absolute_command || looks_absolute(&command) {
command
} else {
format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command)
};
specs.push(PluginMcpServerSpec {
identity: format!(
"plugin:{}:{}",
descriptor.manifest.id.as_str(),
server.id.as_str()
),
plugin_id: descriptor.manifest.id.clone(),
server_id: server.id,
display_name: server.display_name,
command,
args: server
.args
.into_iter()
.map(|a| substitute_vars(&a, &plugin_root, app_data_dir.as_deref()))
.collect(),
env: server
.env
.into_iter()
.map(|(k, v)| {
(
k,
substitute_vars(&v, &plugin_root, app_data_dir.as_deref()),
)
})
.collect(),
cwd: substitute_vars(
server.cwd.as_deref().unwrap_or("${pluginRoot}"),
&plugin_root,
app_data_dir.as_deref(),
),
transport: server.transport,
});
}
}
Ok(specs)
}
fn substitute_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
let value = raw.replace("${pluginRoot}", plugin_root);
match app_data_dir {
Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir),
None => value,
}
}
/// JSON manifest validator.
#[derive(Debug, Clone)]
pub struct JsonPluginManifestValidator {
idea_version: String,
}
impl JsonPluginManifestValidator {
/// Builds a validator for the current app version.
#[must_use]
pub fn new(idea_version: impl Into<String>) -> Self {
Self {
idea_version: idea_version.into(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawManifest {
idea_plugin_manifest_version: u32,
id: String,
display_name: String,
#[serde(default)]
publisher: Option<String>,
version: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
engines: RawEngines,
main: String,
#[serde(default)]
icon: Option<String>,
trust_level: String,
#[serde(default)]
capabilities: Vec<String>,
contributes: RawContributes,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawEngines {
#[serde(default)]
idea: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawContributes {
#[serde(default)]
menus: Vec<RawMenu>,
#[serde(default)]
menu_items: Vec<RawMenuItem>,
#[serde(default)]
layouts: Vec<RawLayout>,
#[serde(default)]
mcp_servers: Vec<RawMcpServer>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMenu {
id: String,
label: String,
top_level: bool,
#[serde(default)]
order: Option<i32>,
#[serde(default)]
icon: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMenuItem {
id: String,
target_menu_id: String,
label: String,
command: String,
#[serde(default)]
order: Option<i32>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
when: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawLayout {
#[serde(rename = "type")]
layout_type: String,
label: String,
component: String,
#[serde(default)]
order: Option<i32>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
when: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMcpServer {
id: String,
display_name: String,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::BTreeMap<String, String>,
#[serde(default)]
cwd: Option<String>,
transport: String,
#[serde(default)]
auto_start: bool,
#[serde(default)]
allow_absolute_command: bool,
}
impl PluginManifestValidator for JsonPluginManifestValidator {
fn validate(
&self,
bytes: &[u8],
_package_root: &domain::PluginPackageRef,
) -> Result<PluginManifest, PluginManifestError> {
let raw: RawManifest =
serde_json::from_slice(bytes).map_err(|e| PluginManifestError::Json(e.to_string()))?;
if raw.idea_plugin_manifest_version != 1 {
return Err(PluginManifestError::Invalid(
"ideaPluginManifestVersion must be 1".to_owned(),
));
}
if raw.display_name.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"displayName is required".to_owned(),
));
}
if raw.trust_level != "full" {
return Err(PluginManifestError::Invalid(
"trustLevel must be full in v1".to_owned(),
));
}
if let Some(range) = &raw.engines.idea {
if !engine_allows(range, &self.idea_version) {
return Err(PluginManifestError::IncompatibleEngine(range.clone()));
}
}
let id = PluginId::new(raw.id).map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
let version = domain::PluginVersion::new(raw.version)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
let main = domain::RelativePath::new(raw.main)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
if !(main.as_str().ends_with(".js") || main.as_str().ends_with(".mjs")) {
return Err(PluginManifestError::Invalid(
"main must point to a .js or .mjs file".to_owned(),
));
}
let icon = raw
.icon
.map(domain::RelativePath::new)
.transpose()
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
let capabilities = raw
.capabilities
.into_iter()
.map(|c| match c.as_str() {
"ui" => Ok(domain::PluginCapability::Ui),
"mcp" => Ok(domain::PluginCapability::Mcp),
_ => Err(PluginManifestError::Invalid(format!(
"unknown capability: {c}"
))),
})
.collect::<Result<Vec<_>, _>>()?;
let contributes = validate_contributes(raw.contributes)?;
Ok(PluginManifest {
idea_plugin_manifest_version: 1,
id,
display_name: raw.display_name,
publisher: raw.publisher,
version,
description: raw.description,
engine_idea: raw.engines.idea,
main,
icon,
trust_level: PluginTrustLevel::Full,
capabilities,
contributes,
})
}
}
fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, PluginManifestError> {
let mut seen = HashSet::new();
let mut insert = |id: &str| {
if !seen.insert(id.to_owned()) {
Err(PluginManifestError::Invalid(format!(
"duplicate contribution id: {id}"
)))
} else {
Ok(())
}
};
let menus = raw
.menus
.into_iter()
.map(|m| {
insert(&m.id)?;
if !m.top_level {
return Err(PluginManifestError::Invalid(
"menus[].topLevel must be true".to_owned(),
));
}
Ok(domain::PluginTopLevelMenuContribution {
id: m.id,
label: m.label,
top_level: true,
order: m.order,
icon: m
.icon
.map(domain::RelativePath::new)
.transpose()
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
})
})
.collect::<Result<Vec<_>, _>>()?;
let menu_items = raw
.menu_items
.into_iter()
.map(|m| {
insert(&m.id)?;
Ok(domain::PluginMenuItemContribution {
id: m.id,
target_menu_id: m.target_menu_id,
label: m.label,
command: domain::PluginCommandId::new(m.command)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
order: m.order,
icon: m
.icon
.map(domain::RelativePath::new)
.transpose()
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
when: m.when,
})
})
.collect::<Result<Vec<_>, _>>()?;
let layouts = raw
.layouts
.into_iter()
.map(|l| {
insert(&l.layout_type)?;
if l.component.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"layouts[].component is required".to_owned(),
));
}
Ok(domain::PluginLayoutContribution {
layout_type: domain::PluginLayoutType::new(l.layout_type)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
label: l.label,
component: l.component,
order: l.order,
icon: l
.icon
.map(domain::RelativePath::new)
.transpose()
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
when: l.when,
})
})
.collect::<Result<Vec<_>, _>>()?;
let mcp_servers = raw
.mcp_servers
.into_iter()
.map(|s| {
insert(&s.id)?;
if s.transport != "stdio" {
return Err(PluginManifestError::Invalid(
"mcpServers[].transport must be stdio".to_owned(),
));
}
if !s.allow_absolute_command && looks_absolute(&s.command) {
return Err(PluginManifestError::Invalid(
"absolute MCP command requires allowAbsoluteCommand=true".to_owned(),
));
}
if !looks_absolute(&s.command) {
domain::RelativePath::new(s.command.clone())
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
}
if let Some(cwd) = &s.cwd {
if cwd != "${pluginRoot}"
&& !cwd.contains("${appDataDir}")
&& !cwd.contains("${pluginRoot}")
{
domain::RelativePath::new(cwd.clone())
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
}
}
Ok(domain::PluginMcpServerContribution {
id: domain::PluginMcpServerId::new(s.id)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
display_name: s.display_name,
command: s.command,
args: s.args,
env: s.env.into_iter().collect(),
cwd: s.cwd,
transport: s.transport,
auto_start: s.auto_start,
allow_absolute_command: s.allow_absolute_command,
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(PluginContributionSet {
menus,
menu_items,
layouts,
mcp_servers,
})
}
fn looks_absolute(path: &str) -> bool {
path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':')
}
fn engine_allows(range: &str, current: &str) -> bool {
let cur = parse_version_tuple(current).unwrap_or((0, 0, 0));
range.split_whitespace().all(|part| {
if let Some(v) = part.strip_prefix(">=") {
parse_version_tuple(v).is_some_and(|min| cur >= min)
} else if let Some(v) = part.strip_prefix('>') {
parse_version_tuple(v).is_some_and(|min| cur > min)
} else if let Some(v) = part.strip_prefix("<=") {
parse_version_tuple(v).is_some_and(|max| cur <= max)
} else if let Some(v) = part.strip_prefix('<') {
parse_version_tuple(v).is_some_and(|max| cur < max)
} else if let Some(v) = part.strip_prefix('=') {
parse_version_tuple(v).is_some_and(|eq| cur == eq)
} else {
true
}
})
}
fn parse_version_tuple(raw: &str) -> Option<(u64, u64, u64)> {
let core = raw.split_once('-').map_or(raw, |(a, _)| a);
let mut parts = core.split('.');
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::{EventStream, PluginPackageStore, PluginRegistryStore, PluginStoreError};
use std::collections::HashMap;
use std::sync::Mutex;
fn validator() -> JsonPluginManifestValidator {
JsonPluginManifestValidator::new("0.3.0")
}
fn valid_manifest() -> Vec<u8> {
br#"{
"ideaPluginManifestVersion": 1,
"id": "dev.acme.gitgraph",
"displayName": "Git Graph",
"publisher": "Acme",
"version": "1.2.3",
"engines": {"idea": ">=0.1.0 <1.0.0"},
"main": "dist/index.js",
"trustLevel": "full",
"capabilities": ["ui", "mcp"],
"contributes": {
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
}
}"#.to_vec()
}
fn plugin_id() -> PluginId {
PluginId::new("dev.acme.gitgraph").unwrap()
}
fn content_hash(raw: &str) -> ContentHash {
ContentHash::new(raw).unwrap()
}
struct FakePackages {
manifests: Mutex<HashMap<String, Vec<u8>>>,
staged: Mutex<Option<StagedPluginPackage>>,
removed: Mutex<Vec<String>>,
}
impl FakePackages {
fn with_manifest(bytes: Vec<u8>) -> Self {
let mut manifests = HashMap::new();
manifests.insert("dev.acme.gitgraph".to_owned(), bytes);
Self {
manifests: Mutex::new(manifests),
staged: Mutex::new(Some(StagedPluginPackage {
root: "/stage/plugin".to_owned(),
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: content_hash("abc123"),
})),
removed: Mutex::new(Vec::new()),
}
}
}
#[async_trait::async_trait]
impl PluginPackageStore for FakePackages {
async fn list_installed(&self) -> Result<Vec<domain::PluginPackageRef>, PluginStoreError> {
Ok(self
.manifests
.lock()
.unwrap()
.keys()
.map(|id| domain::PluginPackageRef {
plugin_id: Some(PluginId::new(id.clone()).unwrap()),
root: format!("/installed/{id}"),
})
.collect())
}
async fn read_manifest(
&self,
package: &domain::PluginPackageRef,
) -> Result<PluginManifestBytes, PluginStoreError> {
let key = package
.plugin_id
.as_ref()
.map_or("dev.acme.gitgraph", PluginId::as_str);
self.manifests
.lock()
.unwrap()
.get(key)
.cloned()
.map(|bytes| PluginManifestBytes { bytes })
.ok_or(PluginStoreError::NotFound)
}
async fn install_from_archive(
&self,
_archive: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
self.staged
.lock()
.unwrap()
.take()
.ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
}
async fn install_from_directory(
&self,
_dir: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
self.staged
.lock()
.unwrap()
.take()
.ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
}
async fn commit_install(
&self,
staged: StagedPluginPackage,
plugin_id: &PluginId,
) -> Result<domain::PluginPackageRef, PluginStoreError> {
self.manifests
.lock()
.unwrap()
.insert(plugin_id.as_str().to_owned(), valid_manifest());
Ok(domain::PluginPackageRef {
plugin_id: Some(plugin_id.clone()),
root: staged.root,
})
}
async fn remove_package(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStoreError> {
self.removed
.lock()
.unwrap()
.push(plugin_id.as_str().to_owned());
Ok(RemovalOutcome::Removed)
}
fn bundle_url(
&self,
plugin_id: &PluginId,
entry: &domain::RelativePath,
hash: &ContentHash,
) -> Result<domain::PluginBundleUrl, PluginStoreError> {
Ok(domain::PluginBundleUrl::new(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
entry.as_str()
)))
}
fn app_data_dir_label(&self) -> Option<String> {
Some("/app-data".to_owned())
}
}
#[derive(Default)]
struct FakeRegistry {
registry: Mutex<domain::PluginRegistry>,
}
#[async_trait::async_trait]
impl PluginRegistryStore for FakeRegistry {
async fn load_registry(&self) -> Result<domain::PluginRegistry, PluginRegistryError> {
Ok(self.registry.lock().unwrap().clone())
}
async fn save_registry(
&self,
registry: &domain::PluginRegistry,
) -> Result<(), PluginRegistryError> {
*self.registry.lock().unwrap() = registry.clone();
Ok(())
}
}
#[derive(Default)]
struct FakeEvents {
events: Mutex<Vec<DomainEvent>>,
}
impl EventBus for FakeEvents {
fn publish(&self, event: DomainEvent) {
self.events.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
#[derive(Default)]
struct FakeMcp {
reconciles: Mutex<Vec<Vec<PluginMcpServerSpec>>>,
stops: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl PluginMcpSupervisor for FakeMcp {
async fn reconcile(
&self,
active_servers: Vec<PluginMcpServerSpec>,
) -> Result<domain::PluginMcpStatusSet, PluginMcpError> {
self.reconciles.lock().unwrap().push(active_servers.clone());
Ok(domain::PluginMcpStatusSet {
servers: active_servers
.into_iter()
.map(|spec| domain::PluginMcpStatus {
identity: spec.identity,
running: true,
error: None,
})
.collect(),
})
}
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
self.stops
.lock()
.unwrap()
.push(plugin_id.as_str().to_owned());
Ok(())
}
}
fn registry_with(state: PluginLifecycleState) -> domain::PluginRegistry {
domain::PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: plugin_id(),
lifecycle_state: state,
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: content_hash("abc123"),
restart_required: false,
error: None,
}],
}
}
#[test]
fn validates_manifest_v1() {
let m = validator()
.validate(
&valid_manifest(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into(),
},
)
.unwrap();
assert_eq!(m.id.as_str(), "dev.acme.gitgraph");
assert_eq!(m.contributes.layouts.len(), 1);
assert_eq!(m.contributes.mcp_servers.len(), 1);
}
#[test]
fn rejects_unsafe_main_path_and_non_full_trust() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["main"] = serde_json::json!("../dist/index.js");
assert!(validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into()
}
)
.is_err());
value["main"] = serde_json::json!("dist/index.js");
value["trustLevel"] = serde_json::json!("sandbox");
assert!(validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into()
}
)
.is_err());
}
#[test]
fn rejects_duplicate_contribution_ids() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["contributes"]["layouts"][0]["type"] = serde_json::json!("dev.acme.open");
assert!(validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into()
}
)
.is_err());
}
#[tokio::test]
async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() {
for state in [
PluginLifecycleState::Disabled,
PluginLifecycleState::PendingUninstall,
PluginLifecycleState::Invalid,
] {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(state)),
});
let usecase =
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));
let catalog = usecase.execute().await.unwrap();
assert!(catalog.plugins.is_empty(), "{state:?} must not be active");
}
}
#[tokio::test]
async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let mcp = Arc::new(FakeMcp::default());
let usecase =
ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone());
let statuses = usecase.execute().await.unwrap();
assert_eq!(statuses.servers.len(), 1);
assert_eq!(
statuses.servers[0].identity,
"plugin:dev.acme.gitgraph:dev.acme.mcp"
);
let reconciles = mcp.reconciles.lock().unwrap();
assert_eq!(reconciles.len(), 1);
assert_eq!(
reconciles[0][0].command,
"/installed/dev.acme.gitgraph/servers/tool"
);
assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph");
}
#[tokio::test]
async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::PendingUninstall)),
});
let mcp = Arc::new(FakeMcp::default());
let usecase =
ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone());
let statuses = usecase.execute().await.unwrap();
assert!(statuses.servers.is_empty());
let reconciles = mcp.reconciles.lock().unwrap();
assert_eq!(reconciles.len(), 1);
assert!(reconciles[0].is_empty());
}
#[tokio::test]
async fn reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs() {
let mut manifest: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
manifest["contributes"]["mcpServers"][0]["command"] =
serde_json::json!("${appDataDir}/plugin-tools/gitgraph");
manifest["contributes"]["mcpServers"][0]["args"] =
serde_json::json!(["--cache", "${appDataDir}/cache", "--root", "${pluginRoot}"]);
manifest["contributes"]["mcpServers"][0]["env"] = serde_json::json!({
"PLUGIN_CACHE": "${appDataDir}/cache/dev.acme.gitgraph",
"PLUGIN_ROOT": "${pluginRoot}"
});
manifest["contributes"]["mcpServers"][0]["cwd"] =
serde_json::json!("${appDataDir}/work/dev.acme.gitgraph");
let packages = Arc::new(FakePackages::with_manifest(
serde_json::to_vec(&manifest).unwrap(),
));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let mcp = Arc::new(FakeMcp::default());
let usecase =
ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone());
usecase.execute().await.unwrap();
let reconciles = mcp.reconciles.lock().unwrap();
let spec = &reconciles[0][0];
assert_eq!(spec.command, "/app-data/plugin-tools/gitgraph");
assert_eq!(
spec.args,
vec![
"--cache".to_owned(),
"/app-data/cache".to_owned(),
"--root".to_owned(),
"/installed/dev.acme.gitgraph".to_owned()
]
);
assert!(spec.env.contains(&(
"PLUGIN_CACHE".to_owned(),
"/app-data/cache/dev.acme.gitgraph".to_owned()
)));
assert!(spec.env.contains(&(
"PLUGIN_ROOT".to_owned(),
"/installed/dev.acme.gitgraph".to_owned()
)));
assert_eq!(spec.cwd, "/app-data/work/dev.acme.gitgraph");
}
#[tokio::test]
async fn disable_stops_plugin_and_removes_it_from_runtime_catalog() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let disable = SetPluginEnabled::new(
packages.clone(),
registry.clone(),
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let admin = disable
.execute(SetPluginEnabledInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
enabled: false,
})
.await
.unwrap();
assert!(!admin.enabled);
assert_eq!(admin.lifecycle_state, PluginLifecycleState::Disabled);
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
event,
DomainEvent::PluginDisabled {
restart_required: true,
..
}
)));
let runtime =
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()))
.execute()
.await
.unwrap();
assert!(runtime.plugins.is_empty());
}
#[tokio::test]
async fn uninstall_removes_registry_package_and_stops_mcp() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let uninstall = UninstallPlugin::new(
packages.clone(),
registry.clone(),
events.clone(),
mcp.clone(),
);
let result = uninstall
.execute(UninstallPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
})
.await
.unwrap();
assert_eq!(result.removal_outcome, RemovalOutcome::Removed);
assert!(result.restart_required);
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
event,
DomainEvent::PluginUninstalled {
restart_required: true,
..
}
)));
}
}