use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::security::Capability;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: String,
pub version: String,
pub api_version: String,
pub description: Option<String>,
pub author: Option<String>,
pub entry_point: String,
pub execution_model: ExecutionModel,
pub capabilities: Vec<Capability>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
pub contributions: Contributions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExecutionModel {
ExternalProcess,
Wasm,
}
impl PluginManifest {
pub fn validate(&self) -> Result<()> {
if self.id.is_empty() {
anyhow::bail!("plugin manifest: id must not be empty");
}
if self.name.is_empty() {
anyhow::bail!("plugin manifest: name must not be empty");
}
if self.version.is_empty() {
anyhow::bail!("plugin manifest: version must not be empty");
}
if self.entry_point.is_empty() {
anyhow::bail!("plugin manifest: entry_point must not be empty");
}
Ok(())
}
pub fn high_risk_capabilities(&self) -> Vec<Capability> {
self.capabilities
.iter()
.filter(|capability| capability.is_high_risk())
.cloned()
.collect()
}
pub fn from_json(json: &str) -> Result<Self> {
let manifest: Self = serde_json::from_str(json)?;
manifest.validate()?;
Ok(manifest)
}
pub fn from_toml(toml: &str) -> Result<Self> {
let manifest: Self = toml::from_str(toml)?;
manifest.validate()?;
Ok(manifest)
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => Self::from_json(&contents),
Some("toml") => Self::from_toml(&contents),
Some(other) => anyhow::bail!("unsupported plugin manifest format: {}", other),
None => anyhow::bail!("plugin manifest file must have an extension"),
}
}
pub fn builder(
id: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
api_version: impl Into<String>,
entry_point: impl Into<String>,
execution_model: ExecutionModel,
) -> PluginManifestBuilder {
PluginManifestBuilder::new(id, name, version, api_version, entry_point, execution_model)
}
}
pub struct PluginManifestBuilder {
manifest: PluginManifest,
}
impl PluginManifestBuilder {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
api_version: impl Into<String>,
entry_point: impl Into<String>,
execution_model: ExecutionModel,
) -> Self {
Self {
manifest: PluginManifest {
id: id.into(),
name: name.into(),
version: version.into(),
api_version: api_version.into(),
description: None,
author: None,
entry_point: entry_point.into(),
execution_model,
capabilities: Vec::new(),
args: Vec::new(),
contributions: Contributions::default(),
},
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.manifest.description = Some(description.into());
self
}
pub fn author(mut self, author: impl Into<String>) -> Self {
self.manifest.author = Some(author.into());
self
}
pub fn capability(mut self, capability: Capability) -> Self {
self.manifest.capabilities.push(capability);
self
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.manifest.args.push(arg.into());
self
}
pub fn command(mut self, command: ContributedCommand) -> Self {
self.manifest.contributions.commands.push(command);
self
}
pub fn menu_item(mut self, menu_item: ContributedMenuItem) -> Self {
self.manifest.contributions.menu_items.push(menu_item);
self
}
pub fn panel(mut self, panel: ContributedPanel) -> Self {
self.manifest.contributions.panels.push(panel);
self
}
pub fn settings_schema(mut self, schema: serde_json::Value) -> Self {
self.manifest.contributions.settings_schema = Some(schema);
self
}
pub fn contributions(mut self, contributions: Contributions) -> Self {
self.manifest.contributions = contributions;
self
}
pub fn build(self) -> Result<PluginManifest> {
self.manifest.validate()?;
Ok(self.manifest)
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Contributions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<ContributedCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub menu_items: Vec<ContributedMenuItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub panels: Vec<ContributedPanel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContributedCommand {
pub id: String,
pub title: String,
pub keybinding: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContributedMenuItem {
pub target_menu: String,
pub label: String,
pub command_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContributedPanel {
pub id: String,
pub title: String,
pub default_position: PanelPosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PanelPosition {
Left,
Right,
Bottom,
Floating,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtensionInfo {
pub manifest: PluginManifest,
pub is_active: bool,
pub process_id: Option<crate::process_model::ProcessId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load_path: Option<PathBuf>,
#[serde(default)]
pub dev_mode: bool,
}
pub struct ExtensionHost {
extensions: HashMap<String, ExtensionInfo>,
}
impl ExtensionHost {
pub fn new() -> Self {
Self {
extensions: HashMap::new(),
}
}
pub fn load_manifest(&mut self, manifest: PluginManifest) -> Result<()> {
self.load_manifest_with_options(manifest, None, false)
}
pub fn load_manifest_with_options(
&mut self,
manifest: PluginManifest,
load_path: Option<PathBuf>,
dev_mode: bool,
) -> Result<()> {
manifest.validate()?;
if self.extensions.contains_key(&manifest.id) {
anyhow::bail!("extension already loaded: {}", manifest.id);
}
let info = ExtensionInfo {
manifest,
is_active: false,
process_id: None,
load_path,
dev_mode,
};
self.extensions.insert(info.manifest.id.clone(), info);
Ok(())
}
pub fn load_manifest_path(&mut self, path: impl AsRef<Path>) -> Result<()> {
self.load_manifest(PluginManifest::load(path)?)
}
pub fn activate(&mut self, id: &str) -> Result<()> {
let info = self
.extensions
.get_mut(id)
.ok_or_else(|| anyhow::anyhow!("extension not found: {}", id))?;
info.is_active = true;
Ok(())
}
pub fn attach_process(
&mut self,
id: &str,
process_id: crate::process_model::ProcessId,
) -> Result<()> {
let info = self
.extensions
.get_mut(id)
.ok_or_else(|| anyhow::anyhow!("extension not found: {}", id))?;
info.is_active = true;
info.process_id = Some(process_id);
Ok(())
}
pub fn deactivate(&mut self, id: &str) -> Result<()> {
let info = self
.extensions
.get_mut(id)
.ok_or_else(|| anyhow::anyhow!("extension not found: {}", id))?;
info.is_active = false;
info.process_id = None;
Ok(())
}
pub fn unload(&mut self, id: &str) -> Result<()> {
if let Some(info) = self.extensions.get(id) {
if info.is_active {
anyhow::bail!("cannot unload active extension: {}", id);
}
}
self.extensions.remove(id);
Ok(())
}
pub fn get(&self, id: &str) -> Option<&ExtensionInfo> {
self.extensions.get(id)
}
pub fn all(&self) -> Vec<&ExtensionInfo> {
self.extensions.values().collect()
}
pub fn active(&self) -> Vec<&ExtensionInfo> {
self.extensions.values().filter(|e| e.is_active).collect()
}
pub fn active_commands(&self) -> Vec<&ContributedCommand> {
self.active()
.into_iter()
.flat_map(|extension| extension.manifest.contributions.commands.iter())
.collect()
}
pub fn active_menu_items(&self) -> Vec<&ContributedMenuItem> {
self.active()
.into_iter()
.flat_map(|extension| extension.manifest.contributions.menu_items.iter())
.collect()
}
pub fn active_panels(&self) -> Vec<&ContributedPanel> {
self.active()
.into_iter()
.flat_map(|extension| extension.manifest.contributions.panels.iter())
.collect()
}
}
impl Default for ExtensionHost {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtensionManifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub author: Option<String>,
pub license: Option<String>,
pub contribution_points: Vec<ContributionPoint>,
pub permissions: Vec<String>,
pub activation_events: Vec<String>,
}
impl ExtensionManifest {
pub fn validate(&self) -> Result<()> {
if self.id.is_empty() {
anyhow::bail!("extension manifest: id must not be empty");
}
if self.name.is_empty() {
anyhow::bail!("extension manifest: name must not be empty");
}
if self.version.is_empty() {
anyhow::bail!("extension manifest: version must not be empty");
}
if self.description.is_empty() {
anyhow::bail!("extension manifest: description must not be empty");
}
Ok(())
}
pub fn commands(&self) -> Vec<(&str, &str)> {
self.contribution_points
.iter()
.filter_map(|cp| match cp {
ContributionPoint::Command { id, title, .. } => Some((id.as_str(), title.as_str())),
_ => None,
})
.collect()
}
pub fn panels(&self) -> Vec<(&str, &str)> {
self.contribution_points
.iter()
.filter_map(|cp| match cp {
ContributionPoint::Panel { id, title, .. } => Some((id.as_str(), title.as_str())),
_ => None,
})
.collect()
}
pub fn themes(&self) -> Vec<(&str, &str)> {
self.contribution_points
.iter()
.filter_map(|cp| match cp {
ContributionPoint::Theme { id, label } => Some((id.as_str(), label.as_str())),
_ => None,
})
.collect()
}
pub fn handles_file_extension(&self, ext: &str) -> bool {
self.contribution_points.iter().any(|cp| match cp {
ContributionPoint::FileType { extensions, .. } => extensions.iter().any(|e| e == ext),
_ => false,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ContributionPoint {
Command {
id: String,
title: String,
keybinding: Option<String>,
},
Menu {
location: String,
items: Vec<PluginMenuItem>,
},
Panel {
id: String,
title: String,
icon: Option<String>,
},
Setting {
key: String,
default_value: serde_json::Value,
description: String,
},
FileType {
extensions: Vec<String>,
language_id: String,
},
Theme {
id: String,
label: String,
},
Keybinding {
command: String,
key: String,
when: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginMenuItem {
pub command: String,
pub title: String,
pub group: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ExtensionState {
Inactive,
Activating,
Active,
Deactivating,
Error(String),
Crashed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtensionDiagnostics {
pub id: String,
pub state: ExtensionState,
pub activation_time_ms: Option<u64>,
pub memory_usage_bytes: Option<u64>,
pub error_count: u32,
pub last_error: Option<String>,
}
impl ExtensionDiagnostics {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
state: ExtensionState::Inactive,
activation_time_ms: None,
memory_usage_bytes: None,
error_count: 0,
last_error: None,
}
}
pub fn record_error(&mut self, message: impl Into<String>) {
let msg = message.into();
self.error_count += 1;
self.last_error = Some(msg);
}
}
pub struct ExtensionRegistry {
extensions: HashMap<String, ExtensionManifest>,
diagnostics: HashMap<String, ExtensionDiagnostics>,
}
impl ExtensionRegistry {
pub fn new() -> Self {
Self {
extensions: HashMap::new(),
diagnostics: HashMap::new(),
}
}
pub fn register(&mut self, manifest: ExtensionManifest) -> Result<()> {
manifest.validate()?;
if self.extensions.contains_key(&manifest.id) {
anyhow::bail!("extension already registered: {}", manifest.id);
}
let diag = ExtensionDiagnostics::new(&manifest.id);
self.diagnostics.insert(manifest.id.clone(), diag);
self.extensions.insert(manifest.id.clone(), manifest);
Ok(())
}
pub fn unregister(&mut self, id: &str) -> Result<ExtensionManifest> {
self.diagnostics.remove(id);
self.extensions
.remove(id)
.ok_or_else(|| anyhow::anyhow!("extension not found: {}", id))
}
pub fn get(&self, id: &str) -> Option<&ExtensionManifest> {
self.extensions.get(id)
}
pub fn list(&self) -> Vec<&ExtensionManifest> {
self.extensions.values().collect()
}
pub fn update_diagnostics(&mut self, id: &str, state: ExtensionState) -> Result<()> {
let diag = self
.diagnostics
.get_mut(id)
.ok_or_else(|| anyhow::anyhow!("extension not found: {}", id))?;
if let ExtensionState::Error(ref msg) = state {
diag.record_error(msg.clone());
}
diag.state = state;
Ok(())
}
pub fn get_diagnostics(&self, id: &str) -> Option<&ExtensionDiagnostics> {
self.diagnostics.get(id)
}
pub fn commands(&self) -> Vec<(&str, &str)> {
self.extensions
.values()
.flat_map(|m| m.commands())
.collect()
}
pub fn panels(&self) -> Vec<(&str, &str)> {
self.extensions.values().flat_map(|m| m.panels()).collect()
}
pub fn themes(&self) -> Vec<(&str, &str)> {
self.extensions.values().flat_map(|m| m.themes()).collect()
}
pub fn file_type_handlers(&self, extension: &str) -> Vec<&ExtensionManifest> {
self.extensions
.values()
.filter(|m| m.handles_file_extension(extension))
.collect()
}
}
impl Default for ExtensionRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrashPolicy {
pub max_restarts: u32,
pub restart_delay_ms: u64,
pub backoff_factor: f64,
}
impl Default for CrashPolicy {
fn default() -> Self {
Self {
max_restarts: 3,
restart_delay_ms: 1000,
backoff_factor: 2.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrashRecord {
pub extension_id: String,
pub crash_count: u32,
pub last_crash: Option<u64>,
pub disabled: bool,
}
impl CrashRecord {
pub fn new(extension_id: impl Into<String>) -> Self {
Self {
extension_id: extension_id.into(),
crash_count: 0,
last_crash: None,
disabled: false,
}
}
pub fn record_crash(&mut self, policy: &CrashPolicy) {
self.crash_count += 1;
self.last_crash = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
);
if self.crash_count > policy.max_restarts {
self.disabled = true;
}
}
pub fn should_restart(&self, policy: &CrashPolicy) -> bool {
!self.disabled && self.crash_count <= policy.max_restarts
}
pub fn next_restart_delay(&self, policy: &CrashPolicy) -> u64 {
if self.crash_count == 0 {
return policy.restart_delay_ms;
}
let exponent = (self.crash_count - 1) as f64;
let delay = policy.restart_delay_ms as f64 * policy.backoff_factor.powf(exponent);
delay as u64
}
}
pub const HOST_API_VERSION: &str = "1.0.0";
pub fn is_api_compatible(plugin_api_version: &str) -> bool {
plugin_api_version.starts_with("1.")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_validation() {
let valid = PluginManifest {
id: "com.example.plugin".to_string(),
name: "Example".to_string(),
version: "1.0.0".to_string(),
api_version: "1.0.0".to_string(),
description: None,
author: None,
entry_point: "plugin.wasm".to_string(),
execution_model: ExecutionModel::Wasm,
capabilities: vec![],
args: vec![],
contributions: Contributions::default(),
};
assert!(valid.validate().is_ok());
let invalid = PluginManifest {
id: "".to_string(),
..valid.clone()
};
assert!(invalid.validate().is_err());
assert!(valid.high_risk_capabilities().is_empty());
}
#[test]
fn test_manifest_serialization() {
let manifest = PluginManifest {
id: "com.test.plugin".to_string(),
name: "Test Plugin".to_string(),
version: "0.1.0".to_string(),
api_version: "1.0.0".to_string(),
description: Some("A test plugin".to_string()),
author: Some("Test Author".to_string()),
entry_point: "main.wasm".to_string(),
execution_model: ExecutionModel::Wasm,
capabilities: vec![Capability::ClipboardRead],
args: vec![],
contributions: Contributions {
commands: vec![ContributedCommand {
id: "test.cmd".to_string(),
title: "Test Command".to_string(),
keybinding: Some("cmd+t".to_string()),
}],
menu_items: vec![],
panels: vec![],
settings_schema: None,
},
};
let json = serde_json::to_string(&manifest).unwrap();
let decoded: PluginManifest = serde_json::from_str(&json).unwrap();
assert_eq!(manifest, decoded);
}
#[test]
fn test_extension_host_lifecycle() {
let mut host = ExtensionHost::new();
let manifest = PluginManifest {
id: "ext-1".to_string(),
name: "Extension 1".to_string(),
version: "1.0.0".to_string(),
api_version: "1.0.0".to_string(),
description: None,
author: None,
entry_point: "ext.wasm".to_string(),
execution_model: ExecutionModel::Wasm,
capabilities: vec![],
args: Vec::new(),
contributions: Contributions::default(),
};
host.load_manifest(manifest.clone()).unwrap();
assert!(host.get("ext-1").is_some());
assert!(!host.get("ext-1").unwrap().is_active);
host.activate("ext-1").unwrap();
assert!(host.get("ext-1").unwrap().is_active);
host.deactivate("ext-1").unwrap();
assert!(!host.get("ext-1").unwrap().is_active);
host.unload("ext-1").unwrap();
assert!(host.get("ext-1").is_none());
}
#[test]
fn test_api_version_compatibility() {
assert!(is_api_compatible("1.0.0"));
assert!(is_api_compatible("1.2.3"));
assert!(!is_api_compatible("2.0.0"));
assert!(!is_api_compatible("0.9.0"));
}
#[test]
fn test_manifest_builder() {
let manifest = PluginManifest::builder(
"com.example.builder",
"Builder",
"1.0.0",
"1.0.0",
"plugin.wasm",
ExecutionModel::Wasm,
)
.description("builder manifest")
.capability(Capability::ShellExecute)
.build()
.unwrap();
assert_eq!(manifest.description.as_deref(), Some("builder manifest"));
assert_eq!(
manifest.high_risk_capabilities(),
vec![Capability::ShellExecute]
);
}
#[test]
fn test_extension_host_active_contributions() {
let mut host = ExtensionHost::new();
let manifest = PluginManifest::builder(
"ext-2",
"Extension 2",
"1.0.0",
"1.0.0",
"ext.wasm",
ExecutionModel::Wasm,
)
.contributions(Contributions {
commands: vec![ContributedCommand {
id: "ext.command".to_string(),
title: "Extension Command".to_string(),
keybinding: None,
}],
menu_items: vec![ContributedMenuItem {
target_menu: "file".to_string(),
label: "Do Thing".to_string(),
command_id: "ext.command".to_string(),
}],
panels: vec![ContributedPanel {
id: "ext.panel".to_string(),
title: "Panel".to_string(),
default_position: PanelPosition::Right,
}],
settings_schema: None,
})
.build()
.unwrap();
host.load_manifest(manifest).unwrap();
host.activate("ext-2").unwrap();
assert_eq!(host.active_commands().len(), 1);
assert_eq!(host.active_menu_items().len(), 1);
assert_eq!(host.active_panels().len(), 1);
}
fn sample_extension_manifest() -> ExtensionManifest {
ExtensionManifest {
id: "com.test.ext".to_string(),
name: "Test Extension".to_string(),
version: "1.0.0".to_string(),
description: "A test extension".to_string(),
author: Some("Tester".to_string()),
license: Some("MIT".to_string()),
contribution_points: vec![],
permissions: vec!["fs.read".to_string()],
activation_events: vec!["onStartup".to_string()],
}
}
#[test]
fn test_extension_manifest_validate_ok() {
assert!(sample_extension_manifest().validate().is_ok());
}
#[test]
fn test_extension_manifest_validate_empty_id() {
let mut m = sample_extension_manifest();
m.id = String::new();
assert!(m.validate().is_err());
}
#[test]
fn test_extension_manifest_validate_empty_name() {
let mut m = sample_extension_manifest();
m.name = String::new();
assert!(m.validate().is_err());
}
#[test]
fn test_extension_manifest_validate_empty_version() {
let mut m = sample_extension_manifest();
m.version = String::new();
assert!(m.validate().is_err());
}
#[test]
fn test_extension_manifest_validate_empty_description() {
let mut m = sample_extension_manifest();
m.description = String::new();
assert!(m.validate().is_err());
}
#[test]
fn test_extension_manifest_commands() {
let m = ExtensionManifest {
contribution_points: vec![
ContributionPoint::Command {
id: "cmd.one".to_string(),
title: "One".to_string(),
keybinding: None,
},
ContributionPoint::Panel {
id: "panel.x".to_string(),
title: "X".to_string(),
icon: None,
},
ContributionPoint::Command {
id: "cmd.two".to_string(),
title: "Two".to_string(),
keybinding: Some("ctrl+t".to_string()),
},
],
..sample_extension_manifest()
};
let cmds = m.commands();
assert_eq!(cmds.len(), 2);
assert!(cmds.contains(&("cmd.one", "One")));
assert!(cmds.contains(&("cmd.two", "Two")));
}
#[test]
fn test_extension_manifest_panels() {
let m = ExtensionManifest {
contribution_points: vec![ContributionPoint::Panel {
id: "panel.a".to_string(),
title: "Panel A".to_string(),
icon: Some("icon.svg".to_string()),
}],
..sample_extension_manifest()
};
let panels = m.panels();
assert_eq!(panels, vec![("panel.a", "Panel A")]);
}
#[test]
fn test_extension_manifest_themes() {
let m = ExtensionManifest {
contribution_points: vec![
ContributionPoint::Theme {
id: "dark".to_string(),
label: "Dark Theme".to_string(),
},
ContributionPoint::Theme {
id: "light".to_string(),
label: "Light Theme".to_string(),
},
],
..sample_extension_manifest()
};
let themes = m.themes();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_extension_manifest_handles_file_extension() {
let m = ExtensionManifest {
contribution_points: vec![ContributionPoint::FileType {
extensions: vec!["rs".to_string(), "toml".to_string()],
language_id: "rust".to_string(),
}],
..sample_extension_manifest()
};
assert!(m.handles_file_extension("rs"));
assert!(m.handles_file_extension("toml"));
assert!(!m.handles_file_extension("py"));
}
#[test]
fn test_extension_manifest_serialization() {
let m = ExtensionManifest {
contribution_points: vec![ContributionPoint::Setting {
key: "fontSize".to_string(),
default_value: serde_json::json!(14),
description: "Font size".to_string(),
}],
..sample_extension_manifest()
};
let json = serde_json::to_string(&m).unwrap();
let decoded: ExtensionManifest = serde_json::from_str(&json).unwrap();
assert_eq!(m, decoded);
}
#[test]
fn test_contribution_point_command_variant() {
let cp = ContributionPoint::Command {
id: "cmd.test".to_string(),
title: "Test".to_string(),
keybinding: Some("ctrl+shift+t".to_string()),
};
let json = serde_json::to_string(&cp).unwrap();
let decoded: ContributionPoint = serde_json::from_str(&json).unwrap();
assert_eq!(cp, decoded);
}
#[test]
fn test_contribution_point_menu_variant() {
let cp = ContributionPoint::Menu {
location: "file".to_string(),
items: vec![
PluginMenuItem {
command: "save".to_string(),
title: "Save".to_string(),
group: Some("1_file".to_string()),
},
PluginMenuItem {
command: "open".to_string(),
title: "Open".to_string(),
group: None,
},
],
};
let json = serde_json::to_string(&cp).unwrap();
let decoded: ContributionPoint = serde_json::from_str(&json).unwrap();
assert_eq!(cp, decoded);
}
#[test]
fn test_contribution_point_keybinding_variant() {
let cp = ContributionPoint::Keybinding {
command: "editor.format".to_string(),
key: "ctrl+shift+f".to_string(),
when: Some("editorFocus".to_string()),
};
let json = serde_json::to_string(&cp).unwrap();
let decoded: ContributionPoint = serde_json::from_str(&json).unwrap();
assert_eq!(cp, decoded);
}
#[test]
fn test_extension_state_variants() {
let states = vec![
ExtensionState::Inactive,
ExtensionState::Activating,
ExtensionState::Active,
ExtensionState::Deactivating,
ExtensionState::Error("fail".to_string()),
ExtensionState::Crashed,
];
for state in &states {
let json = serde_json::to_string(state).unwrap();
let decoded: ExtensionState = serde_json::from_str(&json).unwrap();
assert_eq!(*state, decoded);
}
}
#[test]
fn test_diagnostics_new() {
let diag = ExtensionDiagnostics::new("ext-1");
assert_eq!(diag.id, "ext-1");
assert_eq!(diag.state, ExtensionState::Inactive);
assert_eq!(diag.error_count, 0);
assert!(diag.last_error.is_none());
assert!(diag.activation_time_ms.is_none());
assert!(diag.memory_usage_bytes.is_none());
}
#[test]
fn test_diagnostics_record_error() {
let mut diag = ExtensionDiagnostics::new("ext-1");
diag.record_error("first error");
assert_eq!(diag.error_count, 1);
assert_eq!(diag.last_error.as_deref(), Some("first error"));
diag.record_error("second error");
assert_eq!(diag.error_count, 2);
assert_eq!(diag.last_error.as_deref(), Some("second error"));
}
#[test]
fn test_registry_register_and_get() {
let mut reg = ExtensionRegistry::new();
reg.register(sample_extension_manifest()).unwrap();
assert!(reg.get("com.test.ext").is_some());
assert_eq!(reg.get("com.test.ext").unwrap().name, "Test Extension");
}
#[test]
fn test_registry_duplicate_registration() {
let mut reg = ExtensionRegistry::new();
reg.register(sample_extension_manifest()).unwrap();
assert!(reg.register(sample_extension_manifest()).is_err());
}
#[test]
fn test_registry_invalid_manifest() {
let mut reg = ExtensionRegistry::new();
let mut m = sample_extension_manifest();
m.id = String::new();
assert!(reg.register(m).is_err());
}
#[test]
fn test_registry_unregister() {
let mut reg = ExtensionRegistry::new();
reg.register(sample_extension_manifest()).unwrap();
let removed = reg.unregister("com.test.ext").unwrap();
assert_eq!(removed.id, "com.test.ext");
assert!(reg.get("com.test.ext").is_none());
}
#[test]
fn test_registry_unregister_missing() {
let mut reg = ExtensionRegistry::new();
assert!(reg.unregister("nonexistent").is_err());
}
#[test]
fn test_registry_list() {
let mut reg = ExtensionRegistry::new();
let m1 = sample_extension_manifest();
let mut m2 = sample_extension_manifest();
m2.id = "com.test.ext2".to_string();
m2.name = "Ext Two".to_string();
reg.register(m1).unwrap();
reg.register(m2).unwrap();
assert_eq!(reg.list().len(), 2);
}
#[test]
fn test_registry_update_diagnostics() {
let mut reg = ExtensionRegistry::new();
reg.register(sample_extension_manifest()).unwrap();
reg.update_diagnostics("com.test.ext", ExtensionState::Active)
.unwrap();
let diag = reg.get_diagnostics("com.test.ext").unwrap();
assert_eq!(diag.state, ExtensionState::Active);
assert_eq!(diag.error_count, 0);
}
#[test]
fn test_registry_update_diagnostics_error_state() {
let mut reg = ExtensionRegistry::new();
reg.register(sample_extension_manifest()).unwrap();
reg.update_diagnostics(
"com.test.ext",
ExtensionState::Error("something broke".to_string()),
)
.unwrap();
let diag = reg.get_diagnostics("com.test.ext").unwrap();
assert_eq!(diag.error_count, 1);
assert_eq!(diag.last_error.as_deref(), Some("something broke"));
}
#[test]
fn test_registry_update_diagnostics_missing() {
let mut reg = ExtensionRegistry::new();
assert!(
reg.update_diagnostics("missing", ExtensionState::Active)
.is_err()
);
}
#[test]
fn test_registry_commands() {
let mut reg = ExtensionRegistry::new();
let m = ExtensionManifest {
contribution_points: vec![
ContributionPoint::Command {
id: "cmd.a".to_string(),
title: "A".to_string(),
keybinding: None,
},
ContributionPoint::Command {
id: "cmd.b".to_string(),
title: "B".to_string(),
keybinding: None,
},
],
..sample_extension_manifest()
};
reg.register(m).unwrap();
let cmds = reg.commands();
assert_eq!(cmds.len(), 2);
}
#[test]
fn test_registry_panels() {
let mut reg = ExtensionRegistry::new();
let m = ExtensionManifest {
contribution_points: vec![ContributionPoint::Panel {
id: "p.1".to_string(),
title: "Panel 1".to_string(),
icon: None,
}],
..sample_extension_manifest()
};
reg.register(m).unwrap();
assert_eq!(reg.panels().len(), 1);
}
#[test]
fn test_registry_themes() {
let mut reg = ExtensionRegistry::new();
let m = ExtensionManifest {
contribution_points: vec![ContributionPoint::Theme {
id: "monokai".to_string(),
label: "Monokai".to_string(),
}],
..sample_extension_manifest()
};
reg.register(m).unwrap();
let themes = reg.themes();
assert_eq!(themes.len(), 1);
assert_eq!(themes[0], ("monokai", "Monokai"));
}
#[test]
fn test_registry_file_type_handlers() {
let mut reg = ExtensionRegistry::new();
let m = ExtensionManifest {
contribution_points: vec![ContributionPoint::FileType {
extensions: vec!["rs".to_string()],
language_id: "rust".to_string(),
}],
..sample_extension_manifest()
};
reg.register(m).unwrap();
assert_eq!(reg.file_type_handlers("rs").len(), 1);
assert_eq!(reg.file_type_handlers("py").len(), 0);
}
#[test]
fn test_registry_default() {
let reg = ExtensionRegistry::default();
assert!(reg.list().is_empty());
}
#[test]
fn test_crash_policy_default() {
let policy = CrashPolicy::default();
assert_eq!(policy.max_restarts, 3);
assert_eq!(policy.restart_delay_ms, 1000);
assert!((policy.backoff_factor - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_crash_record_new() {
let record = CrashRecord::new("ext-1");
assert_eq!(record.extension_id, "ext-1");
assert_eq!(record.crash_count, 0);
assert!(record.last_crash.is_none());
assert!(!record.disabled);
}
#[test]
fn test_crash_record_single_crash() {
let policy = CrashPolicy::default();
let mut record = CrashRecord::new("ext-1");
record.record_crash(&policy);
assert_eq!(record.crash_count, 1);
assert!(record.last_crash.is_some());
assert!(!record.disabled);
assert!(record.should_restart(&policy));
}
#[test]
fn test_crash_record_max_restarts() {
let policy = CrashPolicy {
max_restarts: 2,
restart_delay_ms: 100,
backoff_factor: 1.5,
};
let mut record = CrashRecord::new("ext-1");
record.record_crash(&policy);
assert!(record.should_restart(&policy));
record.record_crash(&policy);
assert!(record.should_restart(&policy));
record.record_crash(&policy);
assert!(record.disabled);
assert!(!record.should_restart(&policy));
}
#[test]
fn test_crash_record_next_restart_delay_zero_crashes() {
let policy = CrashPolicy::default();
let record = CrashRecord::new("ext-1");
assert_eq!(record.next_restart_delay(&policy), 1000);
}
#[test]
fn test_crash_record_next_restart_delay_backoff() {
let policy = CrashPolicy {
max_restarts: 5,
restart_delay_ms: 1000,
backoff_factor: 2.0,
};
let mut record = CrashRecord::new("ext-1");
record.crash_count = 1;
assert_eq!(record.next_restart_delay(&policy), 1000);
record.crash_count = 2;
assert_eq!(record.next_restart_delay(&policy), 2000);
record.crash_count = 3;
assert_eq!(record.next_restart_delay(&policy), 4000);
}
#[test]
fn test_crash_record_serialization() {
let policy = CrashPolicy::default();
let mut record = CrashRecord::new("ext-1");
record.record_crash(&policy);
let json = serde_json::to_string(&record).unwrap();
let decoded: CrashRecord = serde_json::from_str(&json).unwrap();
assert_eq!(record, decoded);
}
#[test]
fn test_crash_policy_serialization() {
let policy = CrashPolicy {
max_restarts: 5,
restart_delay_ms: 500,
backoff_factor: 1.5,
};
let json = serde_json::to_string(&policy).unwrap();
let decoded: CrashPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(policy, decoded);
}
}