use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{PluginError, PluginResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Platform {
Macos,
Windows,
Linux,
}
impl Platform {
pub fn current() -> Option<Platform> {
Self::parse(std::env::consts::OS)
}
pub fn as_str(self) -> &'static str {
match self {
Platform::Macos => "macos",
Platform::Windows => "windows",
Platform::Linux => "linux",
}
}
pub fn parse(value: &str) -> Option<Platform> {
match value {
"macos" => Some(Platform::Macos),
"windows" => Some(Platform::Windows),
"linux" => Some(Platform::Linux),
_ => None,
}
}
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerManifestEntry {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub transport: McpTransportManifest,
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default)]
pub denied_tools: Vec<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum McpTransportManifest {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(default)]
env: HashMap<String, String>,
},
Sse {
url: String,
#[serde(default)]
headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
},
#[serde(rename = "streamable_http")]
StreamableHttp {
url: String,
#[serde(default)]
headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
},
}
impl McpServerManifestEntry {
pub fn resolve(
&self,
plugin_dir: &Path,
plugin_id: &str,
platform: Platform,
) -> PluginResult<bamboo_domain::mcp_config::McpServerConfig> {
use bamboo_domain::mcp_config::{
default_connect_timeout, default_healthcheck_interval, default_request_timeout,
default_startup_timeout, McpServerConfig, ReconnectConfig, SseConfig, StdioConfig,
StreamableHttpConfig, TransportConfig,
};
let transport = match &self.transport {
McpTransportManifest::Stdio {
command,
args,
cwd,
env,
} => {
if command.trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"mcp server '{}' has an empty stdio command",
self.id
)));
}
TransportConfig::Stdio(StdioConfig {
command: substitute_tokens(command, plugin_dir, plugin_id, platform),
args: args
.iter()
.map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
.collect(),
cwd: cwd
.as_deref()
.map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform)),
env: env
.iter()
.map(|(key, value)| {
(
key.clone(),
substitute_tokens(value, plugin_dir, plugin_id, platform),
)
})
.collect(),
env_encrypted: HashMap::new(),
startup_timeout_ms: default_startup_timeout(),
})
}
McpTransportManifest::Sse { url, headers } => TransportConfig::Sse(SseConfig {
url: url.clone(),
headers: headers.clone(),
connect_timeout_ms: default_connect_timeout(),
}),
McpTransportManifest::StreamableHttp { url, headers } => {
TransportConfig::StreamableHttp(StreamableHttpConfig {
url: url.clone(),
headers: headers.clone(),
connect_timeout_ms: default_connect_timeout(),
})
}
};
Ok(McpServerConfig {
id: self.id.clone(),
name: self.name.clone(),
enabled: self.enabled,
transport,
request_timeout_ms: default_request_timeout(),
healthcheck_interval_ms: default_healthcheck_interval(),
reconnect: ReconnectConfig::default(),
allowed_tools: self.allowed_tools.clone(),
denied_tools: self.denied_tools.clone(),
})
}
}
pub fn substitute_tokens(
template: &str,
plugin_dir: &Path,
plugin_id: &str,
platform: Platform,
) -> String {
let plugin_dir_str = plugin_dir.to_string_lossy();
let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
.to_string_lossy()
.into_owned();
template
.replace("${plugin_dir}", plugin_dir_str.as_ref())
.replace("${platform_bin}", &platform_bin_str)
}
pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
let filename = if matches!(platform, Platform::Windows) {
format!("{plugin_id}.exe")
} else {
plugin_id.to_string()
};
plugin_dir
.join("bin")
.join(platform.as_str())
.join(filename)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginPromptPreset {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginArtifact {
pub url: String,
pub sha256: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginProvides {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<McpServerManifestEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<PluginPromptPreset>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workflows: Vec<String>,
}
impl PluginProvides {
pub fn is_empty(&self) -> bool {
self.mcp_servers.is_empty()
&& self.skills.is_empty()
&& self.prompts.is_empty()
&& self.workflows.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bamboo_min_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<Platform>>,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub artifacts: HashMap<String, PluginArtifact>,
}
const MAX_PLUGIN_ID_LEN: usize = 64;
const MAX_PRESET_ID_LEN: usize = 80;
const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
pub fn is_valid_plugin_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_PLUGIN_ID_LEN
&& id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
}
pub fn is_valid_preset_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_PRESET_ID_LEN
&& !RESERVED_PRESET_IDS.contains(&id)
&& id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}
pub fn is_plausible_semver(value: &str) -> bool {
let core = value.split(['-', '+']).next().unwrap_or_default();
let parts: Vec<&str> = core.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
}
fn is_safe_relative_name(name: &str) -> bool {
!name.is_empty()
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains("..")
&& !name.chars().any(|ch| ch.is_control())
}
impl PluginManifest {
pub fn parse_str(content: &str) -> PluginResult<Self> {
serde_json::from_str(content).map_err(PluginError::from)
}
pub fn validate(&self) -> PluginResult<()> {
if !is_valid_plugin_id(&self.id) {
return Err(PluginError::InvalidManifest(format!(
"invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
self.id, MAX_PLUGIN_ID_LEN
)));
}
if self.name.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"plugin name must not be empty".to_string(),
));
}
if !is_plausible_semver(&self.version) {
return Err(PluginError::InvalidManifest(format!(
"invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
self.version
)));
}
if let Some(min_version) = &self.bamboo_min_version {
if !is_plausible_semver(min_version) {
return Err(PluginError::InvalidManifest(format!(
"invalid bamboo_min_version '{min_version}'"
)));
}
}
if let Some(platforms) = &self.platforms {
if platforms.is_empty() {
return Err(PluginError::InvalidManifest(
"platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
.to_string(),
));
}
}
let mut seen_mcp_ids = std::collections::HashSet::new();
for entry in &self.provides.mcp_servers {
if entry.id.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"mcp server entries must have a non-empty id".to_string(),
));
}
if !seen_mcp_ids.insert(entry.id.clone()) {
return Err(PluginError::InvalidManifest(format!(
"duplicate mcp server id '{}' in provides.mcp_servers",
entry.id
)));
}
if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
if command.trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"mcp server '{}' has an empty stdio command",
entry.id
)));
}
}
}
for skill_dir in &self.provides.skills {
if !is_safe_relative_name(skill_dir) {
return Err(PluginError::InvalidManifest(format!(
"invalid skill directory name '{skill_dir}' in provides.skills"
)));
}
}
let mut seen_preset_ids = std::collections::HashSet::new();
for preset in &self.provides.prompts {
if !is_valid_preset_id(&preset.id) {
return Err(PluginError::InvalidManifest(format!(
"invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
preset.id, MAX_PRESET_ID_LEN
)));
}
if !seen_preset_ids.insert(preset.id.clone()) {
return Err(PluginError::InvalidManifest(format!(
"duplicate prompt preset id '{}' in provides.prompts",
preset.id
)));
}
if preset.name.trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"prompt preset '{}' has an empty name",
preset.id
)));
}
if preset.content.trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"prompt preset '{}' has empty content",
preset.id
)));
}
}
for workflow_file in &self.provides.workflows {
if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
return Err(PluginError::InvalidManifest(format!(
"invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
)));
}
}
for (platform_key, artifact) in &self.artifacts {
let Some(artifact_platform) = Platform::parse(platform_key) else {
return Err(PluginError::InvalidManifest(format!(
"unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
)));
};
if let Some(gate) = &self.platforms {
if !gate.contains(&artifact_platform) {
return Err(PluginError::InvalidManifest(format!(
"artifacts contains platform '{platform_key}' which is not in the \
`platforms` gate {:?}",
gate.iter()
.map(|platform| platform.as_str())
.collect::<Vec<_>>()
)));
}
}
if artifact.url.trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"artifact for platform '{platform_key}' has an empty url"
)));
}
let sha_is_hex64 = artifact.sha256.len() == 64
&& artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
if !sha_is_hex64 {
return Err(PluginError::InvalidManifest(format!(
"artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
)));
}
}
if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
for platform in self.effective_platforms() {
if !self.artifacts.contains_key(platform.as_str()) {
return Err(PluginError::InvalidManifest(format!(
"plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
artifact for supported platform '{}' (every supported platform needs a \
downloadable binary bundle)",
platform.as_str()
)));
}
}
}
Ok(())
}
pub fn supports_platform(&self, platform: Platform) -> bool {
match &self.platforms {
None => true,
Some(platforms) => platforms.contains(&platform),
}
}
pub fn effective_platforms(&self) -> Vec<Platform> {
self.platforms
.clone()
.unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
}
pub fn uses_platform_bin_token(&self) -> bool {
const TOKEN: &str = "${platform_bin}";
self.provides.mcp_servers.iter().any(|entry| {
let McpTransportManifest::Stdio {
command,
args,
cwd,
env,
} = &entry.transport
else {
return false;
};
command.contains(TOKEN)
|| args.iter().any(|value| value.contains(TOKEN))
|| cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
|| env.values().any(|value| value.contains(TOKEN))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_manifest_json() -> &'static str {
r#"{
"id": "hello-plugin",
"name": "Hello Plugin",
"version": "0.1.0",
"provides": {
"skills": ["hello-world"],
"prompts": [
{"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
]
}
}"#
}
#[test]
fn parses_minimal_manifest() {
let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
assert_eq!(manifest.id, "hello-plugin");
assert_eq!(manifest.version, "0.1.0");
assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
assert_eq!(manifest.provides.prompts.len(), 1);
assert!(manifest.provides.mcp_servers.is_empty());
assert!(manifest.artifacts.is_empty());
manifest.validate().expect("minimal manifest is valid");
}
#[test]
fn parses_full_manifest_with_mcp_and_artifacts() {
let json = r#"{
"id": "nova_plugin",
"name": "Nova",
"version": "1.2.3-beta+build.7",
"description": "Desktop control MCP server",
"bamboo_min_version": "2026.7.0",
"platforms": ["macos", "windows", "linux"],
"provides": {
"mcp_servers": [
{
"id": "nova",
"enabled": true,
"transport": {
"type": "stdio",
"command": "${platform_bin}",
"args": ["--serve"],
"cwd": "${plugin_dir}",
"env": {"NOVA_HOME": "${plugin_dir}/data"}
}
}
],
"workflows": ["daily-report.md"]
},
"artifacts": {
"macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
"linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
}
}"#;
let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
manifest.validate().expect("full manifest is valid");
assert!(manifest.supports_platform(Platform::Macos));
assert!(manifest.supports_platform(Platform::Windows));
assert!(manifest.supports_platform(Platform::Linux));
let entry = &manifest.provides.mcp_servers[0];
let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
let resolved = entry
.resolve(plugin_dir, &manifest.id, Platform::Macos)
.expect("resolve mcp entry");
match resolved.transport {
bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
assert_eq!(
stdio.command,
"/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
);
assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
assert_eq!(
stdio.env.get("NOVA_HOME").map(String::as_str),
Some("/home/user/.bamboo/plugins/nova_plugin/data")
);
}
_ => panic!("expected stdio transport"),
}
}
#[test]
fn platform_bin_path_appends_exe_on_windows_only() {
let dir = Path::new("/plugins/demo");
assert_eq!(
platform_bin_path(dir, "demo", Platform::Macos),
PathBuf::from("/plugins/demo/bin/macos/demo")
);
assert_eq!(
platform_bin_path(dir, "demo", Platform::Windows),
PathBuf::from("/plugins/demo/bin/windows/demo.exe")
);
assert_eq!(
platform_bin_path(dir, "demo", Platform::Linux),
PathBuf::from("/plugins/demo/bin/linux/demo")
);
}
#[test]
fn rejects_invalid_id() {
let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest.id = "Bad Id!".to_string();
let error = manifest.validate().expect_err("bad id should fail");
assert!(error.to_string().contains("invalid plugin id"));
}
#[test]
fn rejects_bad_semver() {
let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest.version = "latest".to_string();
let error = manifest.validate().expect_err("bad version should fail");
assert!(error.to_string().contains("invalid plugin version"));
}
#[test]
fn rejects_empty_platforms_list() {
let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest.platforms = Some(vec![]);
let error = manifest
.validate()
.expect_err("empty platforms should fail");
assert!(error.to_string().contains("platforms"));
}
#[test]
fn rejects_duplicate_mcp_server_ids() {
let json = r#"{
"id": "dup",
"name": "Dup",
"version": "1.0.0",
"provides": {
"mcp_servers": [
{"id": "a", "transport": {"type": "stdio", "command": "x"}},
{"id": "a", "transport": {"type": "stdio", "command": "y"}}
]
}
}"#;
let manifest = PluginManifest::parse_str(json).unwrap();
let error = manifest
.validate()
.expect_err("duplicate mcp id should fail");
assert!(error.to_string().contains("duplicate mcp server id"));
}
#[test]
fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest.provides.skills = vec!["../escape".to_string()];
assert!(manifest.validate().is_err());
let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest2.provides.skills = vec![];
manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
assert!(manifest2.validate().is_err());
}
#[test]
fn rejects_invalid_artifact_sha256() {
let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest.artifacts.insert(
"macos".to_string(),
PluginArtifact {
url: "https://example.com/x.tar.gz".to_string(),
sha256: "not-hex".to_string(),
},
);
let error = manifest.validate().expect_err("bad sha256 should fail");
assert!(error.to_string().contains("sha256"));
}
#[test]
fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
let json = r#"{
"id": "binbacked",
"name": "Bin Backed",
"version": "1.0.0",
"provides": {
"mcp_servers": [
{"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
]
},
"artifacts": {
"macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
}
}"#;
let manifest = PluginManifest::parse_str(json).unwrap();
let error = manifest
.validate()
.expect_err("missing linux artifact should fail");
assert!(error.to_string().contains("linux"));
}
#[test]
fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
let json = r#"{
"id": "binbacked",
"name": "Bin Backed",
"version": "1.0.0",
"platforms": ["macos", "windows"],
"provides": {
"mcp_servers": [
{"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
]
},
"artifacts": {
"macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
}
}"#;
let manifest = PluginManifest::parse_str(json).unwrap();
manifest
.validate()
.expect("gate-narrowed binary plugin is valid");
assert!(manifest.uses_platform_bin_token());
}
#[test]
fn rejects_artifact_for_platform_outside_the_gate() {
let json = r#"{
"id": "gated",
"name": "Gated",
"version": "1.0.0",
"platforms": ["macos"],
"artifacts": {
"linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
}
}"#;
let manifest = PluginManifest::parse_str(json).unwrap();
let error = manifest
.validate()
.expect_err("artifact outside gate should fail");
assert!(error.to_string().contains("not in the `platforms` gate"));
}
#[test]
fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
let json = r#"{
"id": "localbin",
"name": "Local Bin",
"version": "1.0.0",
"provides": {
"mcp_servers": [
{"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
]
}
}"#;
let manifest = PluginManifest::parse_str(json).unwrap();
manifest
.validate()
.expect("local binary plugin without artifacts is valid");
}
#[test]
fn rejects_reserved_preset_id() {
let json = r#"{
"id": "reserver",
"name": "Reserver",
"version": "1.0.0",
"provides": {
"prompts": [
{"id": "general_assistant", "name": "Nope", "content": "x"}
]
}
}"#;
let manifest = PluginManifest::parse_str(json).unwrap();
let error = manifest
.validate()
.expect_err("reserved preset id should fail");
assert!(error.to_string().contains("prompt preset id"));
assert!(!is_valid_preset_id("general_assistant"));
}
#[test]
fn rejects_unknown_artifact_platform_key() {
let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
manifest.artifacts.insert(
"solaris".to_string(),
PluginArtifact {
url: "https://example.com/x.tar.gz".to_string(),
sha256: "a".repeat(64),
},
);
let error = manifest
.validate()
.expect_err("unknown platform key should fail");
assert!(error.to_string().contains("unknown platform key"));
}
#[test]
fn semver_shape_check() {
assert!(is_plausible_semver("1.2.3"));
assert!(is_plausible_semver("1.2.3-beta.1"));
assert!(is_plausible_semver("1.2.3+build.7"));
assert!(is_plausible_semver("1.2.3-beta+build"));
assert!(!is_plausible_semver("1.2"));
assert!(!is_plausible_semver("latest"));
assert!(!is_plausible_semver(""));
assert!(!is_plausible_semver("v1.2.3"));
}
#[test]
fn plugin_id_rules() {
assert!(is_valid_plugin_id("hello-plugin"));
assert!(is_valid_plugin_id("nova_plugin_2"));
assert!(!is_valid_plugin_id(""));
assert!(!is_valid_plugin_id("Hello"));
assert!(!is_valid_plugin_id("hello plugin"));
assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
}
}