use crate::{
PluginDeclaration, PluginDependency, PluginEntrypoint, PluginExecutionClass, PluginId,
PluginOwnedPath,
};
use bmux_plugin_sdk::{
ApiVersion, HostScope, PluginCommand, PluginError, PluginEventPublication,
PluginEventSubscription, PluginFeature, PluginService, Result, VersionRange,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
pub use bmux_plugin_runtime::PluginConcurrencyConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum PluginRuntime {
#[default]
Native,
Process,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginManifestCompatibility {
pub minimum: String,
#[serde(default)]
pub maximum: Option<String>,
}
impl Default for PluginManifestCompatibility {
fn default() -> Self {
Self {
minimum: "1.0".to_string(),
maximum: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginManifestDependency {
pub plugin_id: String,
#[serde(default = "default_dependency_version_req")]
pub version_req: String,
#[serde(default = "default_true")]
pub required: bool,
}
impl PluginManifestCompatibility {
pub fn to_version_range(&self) -> std::result::Result<VersionRange, String> {
let minimum = self.minimum.parse::<ApiVersion>()?;
let maximum = self
.maximum
.as_deref()
.map(str::parse::<ApiVersion>)
.transpose()?;
Ok(VersionRange { minimum, maximum })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginManifestExtension {
pub extension_point: String,
#[serde(default)]
pub payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuxiliaryExecutableDeclaration {
pub id: String,
pub cargo_package: String,
pub cargo_binary: String,
pub relative_path: String,
pub protocol_version: u32,
#[serde(
default = "default_auxiliary_platforms",
skip_serializing_if = "is_default_auxiliary_platforms"
)]
pub supported_platforms: Vec<String>,
}
fn default_auxiliary_platforms() -> Vec<String> {
vec!["unix".into()]
}
fn is_default_auxiliary_platforms(platforms: &[String]) -> bool {
platforms == ["unix"]
}
impl AuxiliaryExecutableDeclaration {
pub(crate) fn supports_platform(&self, platform: &str) -> bool {
self.supported_platforms
.iter()
.any(|value| value == platform)
}
pub(crate) fn platform_filename(&self, platform: &str) -> Option<String> {
if !self.supports_platform(platform) {
return None;
}
match platform {
"unix" => Some(self.relative_path.clone()),
"windows" => Some(format!("{}.exe", self.relative_path)),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginManifest {
#[serde(default)]
pub auxiliary_executables: Vec<AuxiliaryExecutableDeclaration>,
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub provider_priority: i32,
#[serde(default = "default_true")]
pub enabled_by_default: bool,
#[serde(default)]
pub execution_class: PluginExecutionClass,
#[serde(default)]
pub concurrency: PluginConcurrencyConfig,
#[serde(default)]
pub owns_namespaces: BTreeSet<String>,
#[serde(default)]
pub owns_paths: BTreeSet<PluginOwnedPath>,
#[serde(default)]
pub runtime: PluginRuntime,
#[serde(default)]
pub entry: Option<PathBuf>,
#[serde(default)]
pub entry_args: Vec<String>,
#[serde(default)]
pub process_persistent_worker: bool,
#[serde(default = "default_entry_symbol")]
pub entry_symbol: String,
#[serde(default)]
pub plugin_api: PluginManifestCompatibility,
#[serde(default)]
pub native_abi: PluginManifestCompatibility,
#[serde(default)]
pub optional_capabilities: BTreeSet<HostScope>,
#[serde(default, alias = "required_host_scopes")]
pub required_capabilities: BTreeSet<HostScope>,
#[serde(default)]
pub provided_capabilities: BTreeSet<HostScope>,
#[serde(default)]
pub provided_features: BTreeSet<PluginFeature>,
#[serde(default)]
pub services: Vec<PluginService>,
#[serde(default)]
pub commands: Vec<PluginCommand>,
#[serde(default)]
pub event_subscriptions: Vec<PluginEventSubscription>,
#[serde(default)]
pub event_publications: Vec<PluginEventPublication>,
#[serde(default)]
pub extensions: Vec<PluginManifestExtension>,
#[serde(default)]
pub dependencies: Vec<PluginManifestDependency>,
#[serde(default)]
pub keybindings: PluginManifestKeybindings,
#[serde(default)]
pub ready_signals: Vec<bmux_plugin_sdk::ReadySignalDecl>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PluginManifestKeybindings {
#[serde(default)]
pub runtime: BTreeMap<String, String>,
#[serde(default)]
pub global: BTreeMap<String, String>,
#[serde(default)]
pub scroll: BTreeMap<String, String>,
}
impl PluginManifest {
pub fn from_toml_str(value: &str) -> Result<Self> {
let manifest: Self = toml::from_str(value)?;
manifest.validate_auxiliary_executables()?;
Ok(manifest)
}
pub fn from_path(path: &Path) -> Result<Self> {
let contents = fs::read_to_string(path)?;
Self::from_toml_str(&contents)
}
pub fn to_declaration(&self) -> Result<PluginDeclaration> {
self.validate_extensions()?;
let declaration = PluginDeclaration {
id: PluginId::new(self.id.clone())?,
display_name: self.name.clone(),
plugin_version: self.version.clone(),
plugin_api: self.plugin_api.to_version_range().map_err(|details| {
PluginError::InvalidVersionRange {
plugin_id: self.id.clone(),
field: "plugin_api",
details,
}
})?,
native_abi: self.native_abi.to_version_range().map_err(|details| {
PluginError::InvalidVersionRange {
plugin_id: self.id.clone(),
field: "native_abi",
details,
}
})?,
entrypoint: match self.runtime {
PluginRuntime::Native => PluginEntrypoint::Native {
symbol: self.entry_symbol.clone(),
},
PluginRuntime::Process => {
let command = self
.entry
.as_ref()
.map(|entry| entry.to_string_lossy().to_string())
.ok_or_else(|| PluginError::MissingEntryPath {
plugin_id: self.id.clone(),
})?;
PluginEntrypoint::Process {
command,
args: self.entry_args.clone(),
persistent_worker: self.process_persistent_worker,
}
}
},
description: self.description.clone(),
homepage: self.homepage.clone(),
provider_priority: self.provider_priority,
execution_class: self.execution_class,
concurrency: self.concurrency,
owns_namespaces: self.owns_namespaces.clone(),
owns_paths: self.owns_paths.clone(),
optional_capabilities: self.optional_capabilities.clone(),
required_capabilities: self.required_capabilities.clone(),
provided_capabilities: self.provided_capabilities.clone(),
provided_features: self.provided_features.clone(),
services: self.services.clone(),
commands: self.commands.clone(),
event_subscriptions: self.event_subscriptions.clone(),
event_publications: self.event_publications.clone(),
dependencies: self
.dependencies
.iter()
.map(|dependency| {
Ok(PluginDependency {
plugin_id: PluginId::new(dependency.plugin_id.clone())?,
version_req: dependency.version_req.clone(),
required: dependency.required,
})
})
.collect::<Result<Vec<_>>>()?,
lifecycle: crate::PluginLifecycle::default(),
ready_signals: self.ready_signals.clone(),
};
declaration.validate()?;
Ok(declaration)
}
#[must_use]
pub fn resolve_entry_path(&self, base_dir: &Path) -> Option<PathBuf> {
let entry = self.entry.as_ref()?;
if entry.is_absolute() {
Some(entry.clone())
} else {
Some(base_dir.join(entry))
}
}
pub(crate) fn validate_auxiliary_executables(&self) -> Result<()> {
let mut ids = BTreeSet::new();
for executable in &self.auxiliary_executables {
let platforms: BTreeSet<_> = executable.supported_platforms.iter().collect();
if !safe_auxiliary_name(&executable.id)
|| !safe_auxiliary_name(&executable.relative_path)
|| !safe_auxiliary_name(&executable.cargo_package)
|| !safe_auxiliary_name(&executable.cargo_binary)
|| executable.protocol_version == 0
|| platforms.is_empty()
|| platforms.len() != executable.supported_platforms.len()
|| platforms
.iter()
.any(|value| !matches!(value.as_str(), "unix" | "windows"))
|| (executable.supports_platform("windows")
&& (executable
.relative_path
.to_ascii_lowercase()
.ends_with(".exe")
|| executable
.cargo_binary
.to_ascii_lowercase()
.ends_with(".exe")))
|| !ids.insert(&executable.id)
{
return Err(PluginError::ServiceProtocol {
details: format!(
"plugin '{}' has invalid or duplicate auxiliary executable '{}'",
self.id, executable.id
),
});
}
}
if !self.auxiliary_executables.is_empty() {
let range = self
.native_abi
.to_version_range()
.map_err(|details| PluginError::ServiceProtocol { details })?;
if range.minimum.major != 1 || range.minimum < ApiVersion::new(1, 1) {
return Err(PluginError::ServiceProtocol {
details: "auxiliary executables require explicit native_abi minimum 1.1".into(),
});
}
}
Ok(())
}
fn validate_extensions(&self) -> Result<()> {
self.validate_auxiliary_executables()?;
for extension in &self.extensions {
if !extension_point_id_is_valid(&extension.extension_point) {
return Err(PluginError::ServiceProtocol {
details: format!(
"plugin '{}' declares invalid extension point '{}'; expected namespace.name/vN",
self.id, extension.extension_point
),
});
}
}
Ok(())
}
}
pub fn safe_auxiliary_name(value: &str) -> bool {
!value.is_empty()
&& value != "."
&& value != ".."
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
#[cfg(test)]
mod auxiliary_manifest_tests {
use super::*;
fn source(owner: &str, minimum: &str, id: &str, path: &str) -> String {
format!(
r#"id = "{owner}"
name = "Test"
version = "1.0.0"
[native_abi]
minimum = "{minimum}"
[[auxiliary_executables]]
id = "{id}"
cargo_package = "example"
cargo_binary = "worker"
relative_path = "{path}"
protocol_version = 1
"#
)
}
#[test]
fn helper_platforms_default_to_unix_and_windows_opt_in_is_explicit() {
let source = source("example.first", "1.1", "worker", "worker");
let legacy = PluginManifest::from_toml_str(&source).unwrap();
let declaration = &legacy.auxiliary_executables[0];
assert_eq!(declaration.supported_platforms, ["unix"]);
assert!(
serde_json::to_value(declaration)
.unwrap()
.get("supported_platforms")
.is_none()
);
assert_eq!(
declaration.platform_filename("unix").as_deref(),
Some("worker")
);
assert_eq!(declaration.platform_filename("windows"), None);
let manifest = PluginManifest::from_toml_str(&format!(
"{source}\nsupported_platforms = [\"unix\", \"windows\"]\n"
))
.unwrap();
assert_eq!(
manifest.auxiliary_executables[0]
.platform_filename("windows")
.as_deref(),
Some("worker.exe")
);
for platforms in ["[]", "[\"win32\"]", "[\"unix\", \"unix\"]"] {
assert!(
PluginManifest::from_toml_str(&format!(
"{source}\nsupported_platforms = {platforms}\n"
))
.is_err()
);
}
let suffixed = source.replace(
"relative_path = \"worker\"",
"relative_path = \"worker.exe\"",
);
assert!(
PluginManifest::from_toml_str(&format!(
"{suffixed}\nsupported_platforms = [\"windows\"]\n"
))
.is_err()
);
}
#[test]
fn auxiliary_declarations_are_versioned_and_owner_scoped() {
let first =
PluginManifest::from_toml_str(&source("example.first", "1.1", "worker", "worker"))
.unwrap();
let second =
PluginManifest::from_toml_str(&source("example.second", "1.1", "worker", "worker"))
.unwrap();
assert_eq!(first.auxiliary_executables, second.auxiliary_executables);
assert!(
PluginManifest::from_toml_str(&source("example.first", "1.0", "worker", "worker"))
.is_err()
);
for path in [
"../worker",
"/worker",
"nested/worker",
"..",
"worker\\\\escape",
] {
assert!(
PluginManifest::from_toml_str(&source("example.first", "1.1", "worker", path))
.is_err()
);
}
let duplicate = format!(
"{}\n[[auxiliary_executables]]\nid = \"worker\"\ncargo_package = \"example\"\ncargo_binary = \"other\"\nrelative_path = \"other\"\nprotocol_version = 1\n",
source("example.first", "1.1", "worker", "worker")
);
assert!(PluginManifest::from_toml_str(&duplicate).is_err());
assert!(
PluginManifest::from_toml_str(&source("example.first", "1.1", "../worker", "worker"))
.is_err()
);
}
}
fn extension_point_id_is_valid(value: &str) -> bool {
let Some((namespace, version)) = value.rsplit_once('/') else {
return false;
};
namespace.contains('.')
&& namespace
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
&& version.strip_prefix('v').is_some_and(|digits| {
!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())
})
}
fn default_entry_symbol() -> String {
bmux_plugin_sdk::DEFAULT_NATIVE_ENTRY_SYMBOL.to_string()
}
const fn default_true() -> bool {
true
}
fn default_dependency_version_req() -> String {
"*".to_string()
}
#[cfg(test)]
mod tests {
use super::{PluginManifest, PluginRuntime};
use crate::{PluginEntrypoint, PluginExecutionClass};
use bmux_plugin_sdk::{HostScope, PluginError};
#[test]
fn parses_generic_extension_payloads_without_app_specific_fields() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.extension"
name = "Extension"
version = "0.1.0"
[[extensions]]
extension_point = "vendor.surface/v1"
payload = [1, 2, 3]
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.extensions.len(), 1);
assert_eq!(manifest.extensions[0].extension_point, "vendor.surface/v1");
assert_eq!(manifest.extensions[0].payload, vec![1, 2, 3]);
manifest
.to_declaration()
.expect("valid extension point should validate");
}
#[test]
fn rejects_invalid_extension_point_shape() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.extension"
name = "Extension"
version = "0.1.0"
[[extensions]]
extension_point = "tui_surface"
payload = []
"#,
)
.expect("manifest should parse");
let error = manifest
.to_declaration()
.expect_err("invalid extension point should fail");
assert!(error.to_string().contains("expected namespace.name/vN"));
}
#[test]
fn parses_native_plugin_manifest() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "git.status"
name = "Git Status"
version = "0.1.0"
runtime = "native"
entry = "libgit_status.dylib"
required_capabilities = ["bmux.commands", "bmux.events.subscribe"]
[[commands]]
name = "hello"
summary = "hello"
execution = "provider_exec"
[[event_subscriptions]]
kinds = ["bmux.core/server-started"]
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.id, "git.status");
assert!(
manifest
.required_capabilities
.contains(&HostScope::new("bmux.commands").expect("scope should parse"))
);
assert_eq!(manifest.commands.len(), 1);
assert_eq!(manifest.event_subscriptions.len(), 1);
assert!(manifest.keybindings.runtime.is_empty());
assert!(manifest.keybindings.global.is_empty());
assert!(manifest.keybindings.scroll.is_empty());
}
#[test]
fn parses_manifest_keybinding_proposals() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "bmux.tabs"
name = "Windows"
version = "0.1.0"
runtime = "native"
entry = "libwindows.dylib"
[keybindings.runtime]
c = "plugin:bmux.tabs:new-tab"
"alt+w" = "plugin:bmux.tabs:switch-tab"
"#,
)
.expect("manifest should parse");
assert_eq!(
manifest.keybindings.runtime.get("c").map(String::as_str),
Some("plugin:bmux.tabs:new-tab")
);
}
#[test]
fn plugin_api_and_native_abi_default_to_1_0() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.minimal"
name = "Minimal"
version = "0.1.0"
"#,
)
.expect("manifest should parse without plugin_api/native_abi/entry");
assert_eq!(manifest.plugin_api.minimum, "1.0");
assert!(manifest.plugin_api.maximum.is_none());
assert_eq!(manifest.native_abi.minimum, "1.0");
assert!(manifest.native_abi.maximum.is_none());
assert!(manifest.entry.is_none());
let declaration = manifest.to_declaration().expect("declaration should build");
assert_eq!(declaration.id.as_str(), "test.minimal");
}
#[test]
fn entry_is_optional_and_resolves_when_present() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.with_entry"
name = "With Entry"
version = "0.1.0"
entry = "libfoo.dylib"
"#,
)
.expect("manifest should parse");
assert_eq!(
manifest.entry.as_deref(),
Some(std::path::Path::new("libfoo.dylib"))
);
assert_eq!(
manifest.resolve_entry_path(std::path::Path::new("/base")),
Some(std::path::PathBuf::from("/base/libfoo.dylib"))
);
let no_entry = PluginManifest::from_toml_str(
r#"
id = "test.no_entry"
name = "No Entry"
version = "0.1.0"
"#,
)
.expect("manifest should parse without entry");
assert!(no_entry.entry.is_none());
assert!(
no_entry
.resolve_entry_path(std::path::Path::new("/base"))
.is_none()
);
}
#[test]
fn explicit_plugin_api_and_native_abi_override_defaults() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.custom_compat"
name = "Custom Compat"
version = "0.1.0"
entry = "unused.dylib"
[plugin_api]
minimum = "2.0"
maximum = "3.0"
[native_abi]
minimum = "1.5"
"#,
)
.expect("manifest with explicit compat should parse");
assert_eq!(manifest.plugin_api.minimum, "2.0");
assert_eq!(manifest.plugin_api.maximum.as_deref(), Some("3.0"));
assert_eq!(manifest.native_abi.minimum, "1.5");
assert!(manifest.native_abi.maximum.is_none());
let declaration = manifest.to_declaration().expect("declaration should build");
assert_eq!(declaration.id.as_str(), "test.custom_compat");
}
#[test]
fn enabled_by_default_defaults_true_and_can_be_disabled() {
let default_manifest = PluginManifest::from_toml_str(
r#"
id = "test.default_enabled"
name = "Default Enabled"
version = "0.1.0"
"#,
)
.expect("manifest should parse");
assert!(default_manifest.enabled_by_default);
let opt_in_manifest = PluginManifest::from_toml_str(
r#"
id = "test.opt_in"
name = "Opt In"
version = "0.1.0"
enabled_by_default = false
"#,
)
.expect("manifest should parse");
assert!(!opt_in_manifest.enabled_by_default);
}
#[test]
fn execution_class_defaults_to_native_standard() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.execution.default"
name = "Execution Default"
version = "0.1.0"
"#,
)
.expect("manifest should parse");
assert_eq!(
manifest.execution_class,
PluginExecutionClass::NativeStandard
);
}
#[test]
fn execution_class_parses_interpreter() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.execution.interpreter"
name = "Execution Interpreter"
version = "0.1.0"
execution_class = "interpreter"
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.execution_class, PluginExecutionClass::Interpreter);
}
#[test]
fn parses_command_ownership_fields() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.ownership"
name = "Ownership"
version = "0.1.0"
owns_namespaces = ["logs", "recording"]
owns_paths = [["terminal", "doctor"], ["terminal", "install-terminfo"]]
"#,
)
.expect("manifest should parse");
assert!(manifest.owns_namespaces.contains("logs"));
assert!(
manifest
.owns_paths
.iter()
.any(|path| path.0 == vec!["terminal".to_string(), "doctor".to_string()])
);
}
#[test]
fn parses_process_runtime_manifest() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.process"
name = "Process Runtime"
version = "0.1.0"
runtime = "process"
entry = "python3"
entry_args = ["plugin.py", "--stdio"]
process_persistent_worker = true
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.runtime, PluginRuntime::Process);
let declaration = manifest
.to_declaration()
.expect("declaration should support process runtime");
assert!(matches!(
declaration.entrypoint,
PluginEntrypoint::Process {
ref command,
ref args,
persistent_worker: true,
} if command == "python3" && args == &vec!["plugin.py".to_string(), "--stdio".to_string()]
));
}
#[test]
fn process_runtime_requires_entry_command() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "test.process.missing"
name = "Missing Process Entry"
version = "0.1.0"
runtime = "process"
"#,
)
.expect("manifest should parse");
let error = manifest
.to_declaration()
.expect_err("process runtime requires entry command");
assert!(matches!(error, PluginError::MissingEntryPath { .. }));
}
}