//! Plugin domain model and validated manifest image. use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; /// Plugin domain validation error. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PluginError { /// A required field is empty or malformed. #[error("invalid plugin field {field}: {reason}")] InvalidField { /// Field name. field: &'static str, /// Human-readable reason. reason: String, }, /// A path escaped the plugin root. #[error("invalid plugin path {field}: {path}")] InvalidPath { /// Field name. field: &'static str, /// Offending path. path: String, }, /// A contribution id is duplicated within the same plugin. #[error("duplicate contribution id: {0}")] DuplicateContribution(String), } /// Stable plugin identifier. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct PluginId(String); impl PluginId { /// Validates and creates a plugin id. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); let valid_len = (3..=128).contains(&raw.len()); let mut chars = raw.chars(); let first = chars.next().unwrap_or('\0'); let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit(); let valid_rest = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-'); if valid_len && valid_first && valid_rest { Ok(Self(raw)) } else { Err(PluginError::InvalidField { field: "id", reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(), }) } } /// Returns the raw id. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl std::fmt::Display for PluginId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } /// SemVer-like plugin version. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct PluginVersion(String); impl PluginVersion { /// Validates and creates a version. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a); let parts: Vec<&str> = core.split('.').collect(); let ok = parts.len() == 3 && parts.iter().all(|p| { !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && (p == &"0" || !p.starts_with('0')) }); if ok { Ok(Self(raw)) } else { Err(PluginError::InvalidField { field: "version", reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(), }) } } /// Returns the raw version. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// Relative path inside a plugin package. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct RelativePath(String); impl RelativePath { /// Validates a manifest path as relative, normalized, and root-confined. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); if is_safe_relative_path(&raw) { Ok(Self(raw.replace('\\', "/"))) } else { Err(PluginError::InvalidPath { field: "path", path: raw, }) } } /// Returns the relative path. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } fn is_safe_relative_path(raw: &str) -> bool { if raw.is_empty() || raw.starts_with('/') || raw.starts_with('\\') || raw.contains('\0') || raw.contains(':') { return false; } raw.replace('\\', "/") .split('/') .all(|p| !p.is_empty() && p != "." && p != "..") } /// Content hash of a plugin bundle/package. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ContentHash(String); impl ContentHash { /// Creates a content hash value. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) { Ok(Self(raw)) } else { Err(PluginError::InvalidField { field: "contentHash", reason: "expected non-empty hexadecimal hash".to_owned(), }) } } /// Returns the hash. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// URL exposed to the frontend for a plugin bundle or asset. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct PluginBundleUrl(String); impl PluginBundleUrl { /// Creates a bundle URL. #[must_use] pub fn new(raw: impl Into) -> Self { Self(raw.into()) } /// Returns the URL. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// Package reference managed by the store adapter. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginPackageRef { /// Plugin id if the package is already committed. pub plugin_id: Option, /// Opaque adapter-owned root path label. pub root: String, } /// Staged package produced by install/review. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StagedPluginPackage { /// Opaque staging root path. pub root: String, /// Original source label. pub source: PluginInstallSource, /// Hash of package contents at staging time. pub content_hash: ContentHash, } /// Plugin install source persisted for admin display. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum PluginInstallSource { /// Local archive source. Archive { /// Human-readable path label. path_label: String, }, /// Local directory source. Directory { /// Human-readable path label. path_label: String, }, } impl PluginInstallSource { /// Returns the stable DTO source kind. #[must_use] pub fn kind(&self) -> &'static str { match self { Self::Archive { .. } => "archive", Self::Directory { .. } => "directory", } } /// Returns the optional label. #[must_use] pub fn label(&self) -> &str { match self { Self::Archive { path_label } | Self::Directory { path_label } => path_label, } } } /// Plugin lifecycle state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PluginLifecycleState { /// Installed and enabled. Enabled, /// Installed and disabled. Disabled, /// Enable requested. PendingEnable, /// Disable requested. PendingDisable, /// Uninstall requested. PendingUninstall, /// Invalid manifest/incompatible engine. Invalid, } impl PluginLifecycleState { /// Whether the plugin can expose runtime contributions. #[must_use] pub fn is_runtime_active(self) -> bool { matches!(self, Self::Enabled | Self::PendingEnable) } } /// Trust level supported by v1. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginTrustLevel { /// Full-trust plugin. Full, } /// Declared plugin capability. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginCapability { /// UI bundle/contributions. Ui, /// External MCP server declarations. Mcp, /// Public tooling/build/debug services. Tooling, } /// Plugin command id. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct PluginCommandId(String); impl PluginCommandId { /// Validates and creates a command id. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); if raw.len() >= 3 && raw .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')) { Ok(Self(raw)) } else { Err(PluginError::InvalidField { field: "command", reason: "invalid command id".to_owned(), }) } } /// Returns the raw id. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// Plugin layout type. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct PluginLayoutType(String); impl PluginLayoutType { /// Validates and creates a layout type. pub fn new(raw: impl Into) -> Result { PluginCommandId::new(raw).map(|v| Self(v.0)) } /// Returns the raw type. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// Plugin MCP server id. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct PluginMcpServerId(String); impl PluginMcpServerId { /// Validates and creates a server id. pub fn new(raw: impl Into) -> Result { PluginCommandId::new(raw).map(|v| Self(v.0)) } /// Returns the raw id. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// Top-level menu contribution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginTopLevelMenuContribution { /// Menu id. pub id: String, /// Display label. pub label: String, /// Must be true for this contribution kind. pub top_level: bool, /// Sort order. #[serde(default)] pub order: Option, /// Optional icon path. #[serde(default)] pub icon: Option, } /// Menu item contribution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginMenuItemContribution { /// Item id. pub id: String, /// Target menu id. pub target_menu_id: String, /// Display label. pub label: String, /// Command id. pub command: PluginCommandId, /// Sort order. #[serde(default)] pub order: Option, /// Optional icon path. #[serde(default)] pub icon: Option, /// Optional declarative condition. #[serde(default)] pub when: Option, } /// Layout contribution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginLayoutContribution { /// Persisted layout type. #[serde(rename = "type")] pub layout_type: PluginLayoutType, /// Display label. pub label: String, /// Component export name. pub component: String, /// Sort order. #[serde(default)] pub order: Option, /// Optional icon path. #[serde(default)] pub icon: Option, /// Optional declarative condition. #[serde(default)] pub when: Option, } /// MCP server contribution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginMcpServerContribution { /// Server id. pub id: PluginMcpServerId, /// Display name. pub display_name: String, /// Executable command path or absolute command when allowed. pub command: String, /// Arguments. #[serde(default)] pub args: Vec, /// Environment variables. #[serde(default)] pub env: Vec<(String, String)>, /// Working directory. #[serde(default)] pub cwd: Option, /// Transport, v1 only `stdio`. pub transport: String, /// Auto start flag. #[serde(default)] pub auto_start: bool, /// Development-only escape hatch for absolute commands. #[serde(default)] pub allow_absolute_command: bool, } /// Validated contribution set. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginContributionSet { /// Top-level menus. #[serde(default)] pub menus: Vec, /// Menu items. #[serde(default)] pub menu_items: Vec, /// Layout contributions. #[serde(default)] pub layouts: Vec, /// MCP server contributions. #[serde(default)] pub mcp_servers: Vec, } /// Validated plugin manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginManifest { /// Manifest schema version. pub idea_plugin_manifest_version: u32, /// Plugin id. pub id: PluginId, /// Display name. pub display_name: String, /// Publisher. #[serde(default)] pub publisher: Option, /// Version. pub version: PluginVersion, /// Description. #[serde(default)] pub description: Option, /// Engine constraint for IdeA. #[serde(default)] pub engine_idea: Option, /// Main ESM bundle. pub main: RelativePath, /// Optional icon path. #[serde(default)] pub icon: Option, /// Trust level. pub trust_level: PluginTrustLevel, /// Capabilities. #[serde(default)] pub capabilities: Vec, /// Contributions. pub contributes: PluginContributionSet, } /// Persisted registry entry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginRegistryEntry { /// Plugin id. pub id: PluginId, /// Lifecycle state. pub lifecycle_state: PluginLifecycleState, /// Source. pub source: PluginInstallSource, /// Content hash. pub content_hash: ContentHash, /// Restart required marker. #[serde(default)] pub restart_required: bool, /// Optional error. #[serde(default)] pub error: Option, } /// Persisted global registry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginRegistry { /// Schema version. pub version: u32, /// Registry entries. pub plugins: Vec, } impl Default for PluginRegistry { fn default() -> Self { Self { version: 1, plugins: Vec::new(), } } } impl PluginRegistry { /// Finds a plugin entry. #[must_use] pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> { self.plugins.iter().find(|p| &p.id == id) } /// Upserts a registry entry, preserving id uniqueness. pub fn upsert(&mut self, entry: PluginRegistryEntry) { if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) { *slot = entry; } else { self.plugins.push(entry); } } /// Removes an entry. pub fn remove(&mut self, id: &PluginId) -> Option { let index = self.plugins.iter().position(|p| &p.id == id)?; Some(self.plugins.remove(index)) } } /// Admin descriptor assembled from manifest + registry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginDescriptor { /// Manifest. pub manifest: PluginManifest, /// Registry entry. pub registry: PluginRegistryEntry, } impl PluginDescriptor { /// Returns true if runtime contributions are active. #[must_use] pub fn exposes_runtime_contributions(&self) -> bool { self.registry.lifecycle_state.is_runtime_active() } } /// Result of removing package files. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum RemovalOutcome { /// Files were removed. Removed, /// Files were moved to trash for later cleanup. Tombstoned, /// No package files existed. NotFound, } /// Resolved MCP server spec passed to the supervisor. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginMcpServerSpec { /// Plugin id. pub plugin_id: PluginId, /// Manifest server id. pub server_id: PluginMcpServerId, /// Stable external identity `plugin::`. pub identity: String, /// Display name. pub display_name: String, /// Command. pub command: String, /// Args. pub args: Vec, /// Env. pub env: Vec<(String, String)>, /// Cwd. pub cwd: String, /// Transport. pub transport: String, } /// MCP server status. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginMcpStatus { /// Identity. pub identity: String, /// Running flag. pub running: bool, /// Optional error. #[serde(default)] pub error: Option, } /// MCP status set. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginMcpStatusSet { /// Statuses. pub servers: Vec, } /// Custom plugin layout persisted in a layout leaf's opaque content. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CustomPluginLayout { /// Provider plugin id. pub plugin_id: PluginId, /// Provider display name if known. #[serde(default)] pub provider_plugin_display_name: Option, /// Layout type. pub layout_type: PluginLayoutType, /// Opaque plugin state. #[serde(default)] pub state: Value, } #[cfg(test)] mod tests { use super::*; #[test] fn plugin_id_pattern_is_enforced() { assert!(PluginId::new("dev.acme.gitgraph").is_ok()); assert!(PluginId::new("De.acme").is_err()); assert!(PluginId::new("a").is_err()); } #[test] fn relative_paths_cannot_escape_root() { assert_eq!( RelativePath::new("dist/index.js").unwrap().as_str(), "dist/index.js" ); assert!(RelativePath::new("../dist/index.js").is_err()); assert!(RelativePath::new("/tmp/index.js").is_err()); assert!(RelativePath::new("dist/../index.js").is_err()); } #[test] fn disabled_and_pending_uninstall_are_not_runtime_active() { assert!(!PluginLifecycleState::Disabled.is_runtime_active()); assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active()); assert!(PluginLifecycleState::Enabled.is_runtime_active()); } #[test] fn plugin_capabilities_serialize_public_manifest_names() { assert_eq!( serde_json::to_value([ PluginCapability::Ui, PluginCapability::Mcp, PluginCapability::Tooling ]) .unwrap(), serde_json::json!(["ui", "mcp", "tooling"]) ); } }