use anyhow::{Context, Result, bail};
use bmux_config::BmuxConfig;
use bmux_plugin::{PluginDeclaration, PluginRegistry};
use bmux_plugin_sdk::{PluginCommand, PluginCommandArgument, PluginCommandArgumentKind};
use clap::{Arg, ArgAction, ArgMatches, Command};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone)]
pub struct ResolvedPluginCommand {
pub plugin_id: String,
pub command_name: String,
pub arguments: Vec<String>,
pub schema: PluginCommand,
}
#[derive(Debug, Clone)]
pub struct RegisteredPluginCommand {
pub plugin_id: String,
pub command_name: String,
pub canonical_path: Vec<String>,
pub aliases: Vec<Vec<String>>,
pub schema: PluginCommand,
}
#[derive(Debug, Default, Clone)]
pub struct PluginCommandRegistry {
commands: Vec<RegisteredPluginCommand>,
resolved_by_path: BTreeMap<Vec<String>, ResolvedPathEntry>,
owned_exact_paths: BTreeMap<Vec<String>, String>,
owned_namespaces: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
struct ResolvedPathEntry {
plugin_id: String,
command_name: String,
schema: PluginCommand,
}
impl PluginCommandRegistry {
pub fn build(config: &BmuxConfig, plugins: &PluginRegistry) -> Result<Self> {
let mut registry = Self::default();
let enabled = config.plugins.enabled.iter().collect::<BTreeSet<_>>();
let mut claimed: BTreeMap<Vec<String>, (String, String)> = BTreeMap::new();
for plugin in plugins.iter() {
let plugin_id = plugin.declaration.id.as_str().to_string();
if !enabled.contains(&plugin_id) {
continue;
}
register_plugin_ownership(
&mut registry.owned_exact_paths,
&mut registry.owned_namespaces,
&plugin.declaration,
)?;
}
for plugin in plugins.iter() {
let plugin_id = plugin.declaration.id.as_str().to_string();
if !enabled.contains(&plugin_id) {
continue;
}
for command in plugin
.declaration
.commands
.iter()
.filter(|command| command.expose_in_cli)
{
let canonical = command.canonical_path();
validate_plugin_owns_path(&plugin.declaration, &canonical, &command.name)?;
validate_path_collision(
&canonical,
&claimed,
plugin.declaration.id.as_str(),
&command.name,
)?;
validate_prefix_collision(
&canonical,
&claimed,
plugin.declaration.id.as_str(),
&command.name,
)?;
claimed.insert(
canonical.clone(),
(
plugin.declaration.id.as_str().to_string(),
command.name.clone(),
),
);
let mut aliases = Vec::new();
for alias in &command.aliases {
validate_plugin_owns_path(&plugin.declaration, alias, &command.name)?;
validate_path_collision(
alias,
&claimed,
plugin.declaration.id.as_str(),
&command.name,
)?;
validate_prefix_collision(
alias,
&claimed,
plugin.declaration.id.as_str(),
&command.name,
)?;
claimed.insert(
alias.clone(),
(
plugin.declaration.id.as_str().to_string(),
command.name.clone(),
),
);
aliases.push(alias.clone());
}
registry.commands.push(RegisteredPluginCommand {
plugin_id: plugin.declaration.id.as_str().to_string(),
command_name: command.name.clone(),
canonical_path: canonical,
aliases,
schema: command.clone(),
});
let registered = registry
.commands
.last()
.expect("just-pushed plugin command should exist");
register_resolved_path_entry(
&mut registry.resolved_by_path,
®istered.canonical_path,
registered,
);
for alias in ®istered.aliases {
register_resolved_path_entry(&mut registry.resolved_by_path, alias, registered);
}
}
}
Ok(registry)
}
#[must_use]
pub fn owner_for_path(&self, path: &[String]) -> Option<String> {
if let Some(owner) = self.owned_exact_paths.get(path) {
return Some(owner.clone());
}
path.first()
.and_then(|namespace| self.owner_for_namespace(namespace))
}
#[must_use]
pub fn owner_for_namespace(&self, namespace: &str) -> Option<String> {
self.owned_namespaces.get(namespace).cloned()
}
pub fn resolve(&self, raw: &[String]) -> Option<ResolvedPluginCommand> {
for prefix_len in (1..=raw.len()).rev() {
let candidate = raw[..prefix_len].to_vec();
let Some(entry) = self.resolved_by_path.get(&candidate) else {
continue;
};
return Some(ResolvedPluginCommand {
plugin_id: entry.plugin_id.clone(),
command_name: entry.command_name.clone(),
arguments: raw[prefix_len..].to_vec(),
schema: entry.schema.clone(),
});
}
None
}
pub fn resolve_exact_path(&self, path: &[String]) -> Option<ResolvedPluginCommand> {
self.resolved_by_path
.get(path)
.map(|entry| ResolvedPluginCommand {
plugin_id: entry.plugin_id.clone(),
command_name: entry.command_name.clone(),
arguments: Vec::new(),
schema: entry.schema.clone(),
})
}
pub fn validate_arguments(
command: &PluginCommand,
arguments: &[String],
) -> Result<Vec<String>> {
let mut clap_command =
Command::new(leak_string(&command.name)).disable_help_subcommand(true);
for argument in &command.arguments {
clap_command = clap_command.arg(build_clap_arg(argument));
}
let mut argv = Vec::with_capacity(arguments.len() + 1);
argv.push(command.name.clone());
argv.extend(arguments.iter().cloned());
let matches = clap_command.try_get_matches_from(argv).with_context(|| {
format!(
"invalid arguments for plugin command '{}': {}",
command.name,
arguments.join(" ")
)
})?;
let mut normalized = Vec::new();
for argument in &command.arguments {
if let Some(long) = &argument.long {
if matches.value_source(&argument.name).is_none() {
continue;
}
if matches!(argument.kind, PluginCommandArgumentKind::Boolean) {
if matches.get_flag(&argument.name) {
normalized.push(format!("--{long}"));
}
continue;
}
if argument.multiple {
if let Some(values) = matches.get_many::<String>(&argument.name) {
for value in values {
normalized.push(format!("--{long}"));
normalized.push(value.clone());
}
}
} else if let Some(value) = matches.get_one::<String>(&argument.name) {
normalized.push(format!("--{long}"));
normalized.push(value.clone());
}
continue;
}
if argument.multiple {
if let Some(values) = matches.get_many::<String>(&argument.name) {
normalized.extend(values.cloned());
}
} else if let Some(value) = matches.get_one::<String>(&argument.name) {
normalized.push(value.clone());
}
}
Ok(normalized)
}
pub fn normalize_arguments_from_matches(
command: &PluginCommand,
matches: &ArgMatches,
) -> Vec<String> {
let mut normalized = Vec::new();
for argument in &command.arguments {
if let Some(long) = &argument.long {
if matches.value_source(&argument.name).is_none() {
continue;
}
if matches!(argument.kind, PluginCommandArgumentKind::Boolean) {
if matches.get_flag(&argument.name) {
normalized.push(format!("--{long}"));
}
continue;
}
if argument.multiple {
if let Some(values) = matches.get_many::<String>(&argument.name) {
for value in values {
normalized.push(format!("--{long}"));
normalized.push(value.clone());
}
}
} else if let Some(value) = matches.get_one::<String>(&argument.name) {
normalized.push(format!("--{long}"));
normalized.push(value.clone());
}
continue;
}
if argument.multiple {
if let Some(values) = matches.get_many::<String>(&argument.name) {
normalized.extend(values.cloned());
}
} else if let Some(value) = matches.get_one::<String>(&argument.name) {
normalized.push(value.clone());
}
}
normalized
}
pub fn augment_clap_command(&self, root: Command) -> Result<Command> {
let mut root = root;
for command in &self.commands {
for path in std::iter::once(&command.canonical_path).chain(command.aliases.iter()) {
if clap_path_exists(&root, path) {
continue;
}
insert_plugin_path(&mut root, path, &command.schema)?;
}
}
Ok(root)
}
}
fn register_resolved_path_entry(
index: &mut BTreeMap<Vec<String>, ResolvedPathEntry>,
path: &[String],
command: &RegisteredPluginCommand,
) {
let previous = index.insert(
path.to_vec(),
ResolvedPathEntry {
plugin_id: command.plugin_id.clone(),
command_name: command.command_name.clone(),
schema: command.schema.clone(),
},
);
debug_assert!(
previous.is_none(),
"path collisions should be rejected before indexing"
);
}
pub fn selected_subcommand_path(matches: &ArgMatches) -> (Vec<String>, &ArgMatches) {
let mut path = Vec::new();
let mut current = matches;
while let Some((name, next)) = current.subcommand() {
path.push(name.to_string());
current = next;
}
(path, current)
}
fn insert_plugin_path(root: &mut Command, path: &[String], schema: &PluginCommand) -> Result<()> {
if path.is_empty() {
bail!("plugin command path cannot be empty");
}
if path.len() == 1 {
let updated = std::mem::replace(root, Command::new("bmux-temp-root")).subcommand(
build_plugin_leaf_command(path.last().expect("leaf exists"), schema),
);
*root = updated;
return Ok(());
}
let head = &path[0];
let tail = &path[1..];
if root.find_subcommand(head).is_none() {
let updated = std::mem::replace(root, Command::new("bmux-temp-root"))
.subcommand(build_plugin_namespace_command(head));
*root = updated;
}
let child = root.find_subcommand_mut(head).with_context(|| {
format!(
"missing clap namespace for plugin path '{}' after namespace creation",
path.join(" ")
)
})?;
if tail.len() == 1 {
let updated = std::mem::replace(child, Command::new("bmux-temp-child")).subcommand(
build_plugin_leaf_command(tail.last().expect("leaf exists"), schema),
);
*child = updated;
return Ok(());
}
insert_plugin_path(child, tail, schema)
}
fn build_plugin_namespace_command(name: &str) -> Command {
Command::new(leak_string(name))
.disable_help_subcommand(true)
.arg_required_else_help(true)
}
fn build_plugin_leaf_command(name: &str, schema: &PluginCommand) -> Command {
let mut command = Command::new(leak_string(name))
.about(leak_string(&schema.summary))
.disable_help_subcommand(true);
if let Some(description) = &schema.description {
command = command.long_about(leak_string(description));
}
for argument in &schema.arguments {
command = command.arg(build_clap_arg(argument));
}
command
}
fn build_clap_arg(argument: &PluginCommandArgument) -> Arg {
let mut arg = Arg::new(leak_string(&argument.name)).required(argument.required);
if let Some(position) = argument.position {
arg = arg.index(position + 1);
}
if let Some(long) = &argument.long {
arg = arg.long(leak_string(long));
}
if let Some(short) = argument.short {
arg = arg.short(short);
}
if let Some(summary) = &argument.summary {
arg = arg.help(summary);
}
if let Some(value_name) = &argument.value_name {
arg = arg.value_name(leak_string(value_name));
}
if argument.multiple {
arg = arg.action(ArgAction::Append);
} else if matches!(argument.kind, PluginCommandArgumentKind::Boolean) {
arg = arg.action(ArgAction::SetTrue);
} else {
arg = arg.action(ArgAction::Set);
}
if argument.trailing_var_arg {
arg = arg.trailing_var_arg(true);
}
if argument.allow_hyphen_values {
arg = arg.allow_hyphen_values(true);
}
match &argument.kind {
PluginCommandArgumentKind::Integer => {
arg = arg.value_parser(clap::value_parser!(i64));
}
PluginCommandArgumentKind::Choice => {
let leaked = argument
.choice_values
.iter()
.map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str)
.collect::<Vec<_>>();
arg = arg.value_parser(leaked);
}
PluginCommandArgumentKind::String
| PluginCommandArgumentKind::Boolean
| PluginCommandArgumentKind::Path => {}
}
arg
}
fn leak_string(value: &str) -> &'static str {
Box::leak(value.to_string().into_boxed_str())
}
fn validate_path_collision(
path: &[String],
claimed: &BTreeMap<Vec<String>, (String, String)>,
plugin_id: &str,
command_name: &str,
) -> Result<()> {
if let Some((owner_plugin, owner_command)) = claimed.get(path) {
bail!(
"plugin '{plugin_id}' command '{command_name}' collides with plugin '{owner_plugin}' command '{owner_command}' on CLI path '{}'",
path.join(" ")
);
}
Ok(())
}
fn validate_prefix_collision(
path: &[String],
claimed: &BTreeMap<Vec<String>, (String, String)>,
plugin_id: &str,
command_name: &str,
) -> Result<()> {
for (claimed_path, (owner_plugin, owner_command)) in claimed {
if is_prefix_collision(path, claimed_path) {
bail!(
"plugin '{plugin_id}' command '{command_name}' creates ambiguous CLI nesting with plugin '{owner_plugin}' command '{owner_command}' on path '{}'",
claimed_path.join(" ")
);
}
}
Ok(())
}
fn clap_path_exists(root: &Command, path: &[String]) -> bool {
if path.is_empty() {
return false;
}
let mut current = root;
for segment in path {
let Some(next) = current.find_subcommand(segment) else {
return false;
};
current = next;
}
true
}
#[allow(clippy::suspicious_operation_groupings)] fn is_prefix_collision(left: &[String], right: &[String]) -> bool {
left != right && (left.starts_with(right) || right.starts_with(left))
}
fn register_plugin_ownership(
owned_exact_paths: &mut BTreeMap<Vec<String>, String>,
owned_namespaces: &mut BTreeMap<String, String>,
declaration: &PluginDeclaration,
) -> Result<()> {
let plugin_id = declaration.id.as_str().to_string();
for namespace in &declaration.owns_namespaces {
if let Some(existing) = owned_namespaces.get(namespace)
&& existing != &plugin_id
{
bail!(
"plugin '{plugin_id}' ownership conflict: namespace '{namespace}' already owned by plugin '{existing}'"
);
}
owned_namespaces.insert(namespace.clone(), plugin_id.clone());
}
for path in &declaration.owns_paths {
if let Some(existing) = owned_exact_paths.get(&path.0)
&& existing != &plugin_id
{
bail!(
"plugin '{plugin_id}' ownership conflict: path '{}' already owned by plugin '{existing}'",
path.0.join(" ")
);
}
owned_exact_paths.insert(path.0.clone(), plugin_id.clone());
}
Ok(())
}
fn validate_plugin_owns_path(
declaration: &PluginDeclaration,
path: &[String],
command_name: &str,
) -> Result<()> {
let exact = declaration
.owns_paths
.iter()
.any(|candidate| candidate.matches(path));
let namespace_owned = path
.first()
.is_some_and(|segment| declaration.owns_namespaces.contains(segment));
if exact || namespace_owned {
return Ok(());
}
bail!(
"plugin '{}' command '{}' path '{}' is not within declared ownership (owns_paths/owns_namespaces)",
declaration.id.as_str(),
command_name,
path.join(" ")
)
}
#[cfg(test)]
mod tests {
use super::PluginCommandRegistry;
use bmux_cli_schema::Cli;
use bmux_config::BmuxConfig;
use bmux_plugin::{PluginManifest, PluginRegistry};
use clap::{Command, CommandFactory};
use std::path::Path;
fn config_with_enabled(plugin_id: &str) -> BmuxConfig {
let mut config = BmuxConfig::default();
config.plugins.enabled.push(plugin_id.to_string());
config
}
#[test]
fn resolves_nested_aliases_by_longest_prefix() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "example.plugin"
name = "Example"
version = "0.1.0"
entry = "plugin.dylib"
owns_namespaces = ["acl"]
[[commands]]
name = "roles"
path = ["acl", "list"]
aliases = [["acl", "roles"]]
summary = "list"
expose_in_cli = true
[plugin_api]
minimum = "1.0"
[native_abi]
minimum = "1.0"
"#,
)
.expect("manifest should parse");
let mut registry = PluginRegistry::new();
registry
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
let commands =
PluginCommandRegistry::build(&config_with_enabled("example.plugin"), ®istry)
.expect("command registry should build");
let resolved = commands
.resolve(&["acl".into(), "roles".into(), "dev".into()])
.expect("command should resolve");
assert_eq!(resolved.command_name, "roles");
assert_eq!(resolved.arguments, vec!["dev"]);
}
#[test]
fn plugin_aliases_build_without_collision_for_session_namespace() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "policy.plugin"
name = "Policy"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["roles", "assign", "session"]
[[commands]]
name = "roles"
path = ["roles"]
aliases = [["session", "roles"]]
summary = "list"
execution = "provider_exec"
expose_in_cli = true
[[commands]]
name = "assign"
path = ["assign"]
aliases = [["session", "assign"]]
summary = "assign"
execution = "provider_exec"
expose_in_cli = true
[plugin_api]
minimum = "1.0"
[native_abi]
minimum = "1.0"
"#,
)
.expect("manifest should parse");
let mut registry = PluginRegistry::new();
registry
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
PluginCommandRegistry::build(&config_with_enabled("policy.plugin"), ®istry)
.expect("policy command registry should build");
}
#[test]
fn plugin_aliases_build_without_collision_for_dynamic_namespace() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "workspace.plugin"
name = "Workspace"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["item", "item-open", "item-focus"]
[[commands]]
name = "item-open"
path = ["item-open"]
aliases = [["item", "open"]]
summary = "open"
execution = "provider_exec"
expose_in_cli = true
[[commands]]
name = "item-focus"
path = ["item-focus"]
aliases = [["item", "focus"]]
summary = "focus"
execution = "provider_exec"
expose_in_cli = true
[plugin_api]
minimum = "1.0"
[native_abi]
minimum = "1.0"
"#,
)
.expect("manifest should parse");
let mut registry = PluginRegistry::new();
registry
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
PluginCommandRegistry::build(&config_with_enabled("workspace.plugin"), ®istry)
.expect("workspace command registry should build");
}
#[test]
fn plugin_can_claim_current_static_core_command_path_when_owned() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "example.plugin"
name = "Example"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["new-session"]
[[commands]]
name = "new-session"
path = ["new-session"]
summary = "new"
execution = "provider_exec"
expose_in_cli = true
[plugin_api]
minimum = "1.0"
[native_abi]
minimum = "1.0"
"#,
)
.expect("manifest should parse");
let mut registry = PluginRegistry::new();
registry
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
let built = PluginCommandRegistry::build(&config_with_enabled("example.plugin"), ®istry)
.expect("plugin should be allowed to own built-in command path");
let resolved = built
.resolve_exact_path(&["new-session".to_string()])
.expect("plugin command should resolve on owned built-in path");
assert_eq!(resolved.plugin_id, "example.plugin");
}
#[test]
fn augment_clap_command_creates_missing_namespace_roots() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "workspace.plugin"
name = "Workspace"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["item", "item-open"]
[[commands]]
name = "item-open"
path = ["item-open"]
aliases = [["item", "open"]]
summary = "open"
execution = "provider_exec"
expose_in_cli = true
[plugin_api]
minimum = "1.0"
[native_abi]
minimum = "1.0"
"#,
)
.expect("manifest should parse");
let mut registry = PluginRegistry::new();
registry
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
let commands =
PluginCommandRegistry::build(&config_with_enabled("workspace.plugin"), ®istry)
.expect("workspace command registry should build");
let clap = commands
.augment_clap_command(Command::new("bmux"))
.expect("dynamic namespace should be created");
let matches = clap
.try_get_matches_from(["bmux", "item", "open"])
.expect("dynamic namespace path should parse");
let (path, _) = super::selected_subcommand_path(&matches);
assert_eq!(path, vec!["item".to_string(), "open".to_string()]);
}
#[test]
fn augment_clap_command_extends_existing_session_namespace() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "policy.plugin"
name = "Policy"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["roles", "session"]
[[commands]]
name = "roles"
path = ["roles"]
aliases = [["session", "roles"]]
summary = "list"
execution = "provider_exec"
expose_in_cli = true
[plugin_api]
minimum = "1.0"
[native_abi]
minimum = "1.0"
"#,
)
.expect("manifest should parse");
let mut registry = PluginRegistry::new();
registry
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
let commands =
PluginCommandRegistry::build(&config_with_enabled("policy.plugin"), ®istry)
.expect("policy command registry should build");
let clap = commands
.augment_clap_command(Cli::command())
.expect("existing session namespace should be extended");
let matches = clap
.try_get_matches_from(["bmux", "session", "roles"])
.expect("plugin session alias should parse under mixed namespace");
let (path, _) = super::selected_subcommand_path(&matches);
assert_eq!(path, vec!["session".to_string(), "roles".to_string()]);
}
#[test]
fn ownership_exact_path_takes_precedence_over_namespace() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "owner.plugin"
name = "Owner"
version = "0.1.0"
entry = "owner.dylib"
owns_namespaces = ["workspace"]
owns_paths = [["workspace", "doctor"]]
required_capabilities = ["bmux.commands"]
[[commands]]
name = "doctor"
path = ["workspace", "doctor"]
summary = "doctor"
execution = "provider_exec"
expose_in_cli = true
[[commands]]
name = "probe"
path = ["workspace", "probe"]
summary = "probe"
execution = "provider_exec"
expose_in_cli = true
"#,
)
.expect("manifest should parse");
let mut plugins = PluginRegistry::new();
plugins
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
let registry = PluginCommandRegistry::build(&config_with_enabled("owner.plugin"), &plugins)
.expect("registry should build");
assert_eq!(
registry.owner_for_path(&["workspace".to_string(), "doctor".to_string()]),
Some("owner.plugin".to_string())
);
assert_eq!(
registry.owner_for_path(&["workspace".to_string(), "probe".to_string()]),
Some("owner.plugin".to_string())
);
assert_eq!(
registry.owner_for_path(&["workspace".to_string(), "doctor".to_string()]),
Some("owner.plugin".to_string())
);
}
#[test]
fn build_rejects_command_outside_declared_ownership() {
let manifest = PluginManifest::from_toml_str(
r#"
id = "invalid.plugin"
name = "Invalid"
version = "0.1.0"
entry = "invalid.dylib"
owns_namespaces = ["window"]
required_capabilities = ["bmux.commands"]
[[commands]]
name = "bad"
path = ["logs", "tail"]
summary = "bad"
execution = "provider_exec"
expose_in_cli = true
"#,
)
.expect("manifest should parse");
let mut plugins = PluginRegistry::new();
plugins
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/plugin.toml"),
manifest,
)
.expect("manifest should register");
let error = PluginCommandRegistry::build(&config_with_enabled("invalid.plugin"), &plugins)
.expect_err("registry build should fail for unowned command path");
assert!(error.to_string().contains("not within declared ownership"));
}
#[test]
fn build_rejects_namespace_ownership_conflicts() {
let first = PluginManifest::from_toml_str(
r#"
id = "first.plugin"
name = "First"
version = "0.1.0"
entry = "first.dylib"
owns_namespaces = ["logs"]
required_capabilities = ["bmux.commands"]
[[commands]]
name = "list"
path = ["logs", "list"]
summary = "list"
execution = "provider_exec"
expose_in_cli = true
"#,
)
.expect("first manifest should parse");
let second = PluginManifest::from_toml_str(
r#"
id = "second.plugin"
name = "Second"
version = "0.1.0"
entry = "second.dylib"
owns_namespaces = ["logs"]
required_capabilities = ["bmux.commands"]
[[commands]]
name = "tail"
path = ["logs", "tail"]
summary = "tail"
execution = "provider_exec"
expose_in_cli = true
"#,
)
.expect("second manifest should parse");
let mut plugins = PluginRegistry::new();
plugins
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/first.toml"),
first,
)
.expect("first should register");
plugins
.register_manifest_from_root(
Path::new("/plugins"),
Path::new("/plugins/second.toml"),
second,
)
.expect("second should register");
let mut config = BmuxConfig::default();
config.plugins.enabled = vec!["first.plugin".to_string(), "second.plugin".to_string()];
let error = PluginCommandRegistry::build(&config, &plugins)
.expect_err("registry build should fail for namespace ownership conflict");
assert!(error.to_string().contains("ownership conflict"));
}
}