use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use super::bootstrap::AddonInstall;
use super::capabilities::AddonCapabilities;
use crate::core::mcp_catalog::{GatewayServer, TransportKind};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AddonMeta {
pub name: String,
pub display_name: String,
pub version: String,
pub description: String,
pub author: String,
pub homepage: String,
pub license: String,
pub categories: Vec<String>,
pub integration: String,
pub keywords: Vec<String>,
pub min_lean_ctx: String,
pub verified: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AddonMcp {
pub transport: TransportKind,
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub sha256: String,
pub url: String,
pub headers: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AddonManifest {
pub addon: AddonMeta,
#[serde(default)]
pub mcp: AddonMcp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<AddonCapabilities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<super::commerce::AddonPricing>,
#[serde(default, skip_serializing_if = "AddonInstall::is_absent")]
pub install: AddonInstall,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub artifacts: BTreeMap<String, super::artifact_install::ArtifactAsset>,
}
impl AddonManifest {
pub fn from_toml(text: &str) -> Result<Self, String> {
toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
}
pub fn from_path(path: &Path) -> Result<Self, String> {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let manifest = Self::from_toml(&raw)?;
manifest.validate()?;
Ok(manifest)
}
pub fn integration_kind(&self) -> String {
use crate::core::mcp_catalog::adapters::IntegrationKind;
let explicit = self.addon.integration.trim();
let kind = if explicit.is_empty() {
IntegrationKind::from_categories(&self.addon.categories)
} else {
IntegrationKind::parse(explicit)
};
kind.as_str().to_string()
}
pub fn display_name(&self) -> &str {
if self.addon.display_name.trim().is_empty() {
&self.addon.name
} else {
&self.addon.display_name
}
}
pub fn validate(&self) -> Result<(), String> {
let name = self.addon.name.trim();
if name.is_empty() {
return Err("addon manifest is missing `addon.name`".into());
}
if !is_slug(name) {
return Err(format!(
"addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
no leading/trailing dash)"
));
}
if let Some(caps) = &self.capabilities {
caps.validate()?;
}
self.install.validate()?;
for (triple, asset) in &self.artifacts {
if asset.filename.trim().is_empty() {
return Err(format!(
"addon `{name}` artifact for `{triple}` is missing `filename`"
));
}
if asset.url.trim().is_empty() {
return Err(format!(
"addon `{name}` artifact for `{triple}` is missing `url`"
));
}
if asset.sha256.trim().is_empty() {
return Err(format!(
"addon `{name}` artifact for `{triple}` is missing `sha256` — a managed \
binary must be pinned"
));
}
}
Ok(())
}
pub fn artifact_for_current_platform(&self) -> Option<&super::artifact_install::ArtifactAsset> {
self.artifacts
.get(super::artifact_install::current_target_triple())
}
pub fn to_gateway_server(&self) -> GatewayServer {
GatewayServer {
name: self.addon.name.clone(),
transport: self.mcp.transport,
enabled: true,
command: self.mcp.command.clone(),
args: self.mcp.args.clone(),
env: self.mcp.env.clone(),
binary_sha256: self.mcp.sha256.clone(),
url: self.mcp.url.clone(),
headers: self.mcp.headers.clone(),
capabilities: self.capabilities.clone(),
integration: self.integration_kind(),
}
}
pub fn is_installable(&self) -> bool {
self.to_gateway_server().resolve().is_ok()
}
}
fn is_slug(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('-')
&& !s.ends_with('-')
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
fn stdio_manifest() -> AddonManifest {
AddonManifest::from_toml(
r#"
[addon]
name = "demo"
display_name = "Demo Addon"
version = "1.2.3"
description = "A demo"
author = "tester"
categories = ["search"]
keywords = ["alpha", "beta"]
[mcp]
transport = "stdio"
command = "demo-mcp"
args = ["serve"]
"#,
)
.expect("parse")
}
#[test]
fn parses_full_stdio_manifest() {
let m = stdio_manifest();
assert_eq!(m.addon.name, "demo");
assert_eq!(m.display_name(), "Demo Addon");
assert_eq!(m.mcp.transport, TransportKind::Stdio);
assert_eq!(m.mcp.command, "demo-mcp");
assert!(m.is_installable());
let srv = m.to_gateway_server();
assert_eq!(srv.name, "demo");
assert_eq!(srv.args, vec!["serve".to_string()]);
assert!(srv.enabled);
}
#[test]
fn listed_only_entry_is_not_installable() {
let m = AddonManifest::from_toml(
r#"
[addon]
name = "listed"
description = "no mcp block"
homepage = "https://example.com"
"#,
)
.expect("parse");
assert!(m.validate().is_ok());
assert!(!m.is_installable(), "no [mcp] block → listed only");
}
#[test]
fn http_manifest_is_installable() {
let m = AddonManifest::from_toml(
r#"
[addon]
name = "remote"
[mcp]
transport = "http"
url = "https://example.com/mcp"
"#,
)
.expect("parse");
assert!(m.is_installable());
assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
}
#[test]
fn display_name_falls_back_to_slug() {
let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
assert_eq!(m.display_name(), "slug-only");
}
#[test]
fn capabilities_block_parses_and_threads_to_gateway() {
let m = AddonManifest::from_toml(
r#"
[addon]
name = "caps"
[mcp]
transport = "stdio"
command = "caps-mcp"
[capabilities]
network = "full"
filesystem = "read_write"
env = ["GITHUB_TOKEN"]
"#,
)
.expect("parse");
let caps = m.capabilities.as_ref().expect("capabilities present");
assert!(caps.network_allowed());
assert!(caps.filesystem_writable());
assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
}
#[test]
fn absent_capabilities_is_none() {
let m = stdio_manifest();
assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
assert!(m.to_gateway_server().capabilities.is_none());
}
#[test]
fn invalid_capability_env_name_fails_validation() {
let m = AddonManifest::from_toml(
"[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
)
.expect("parse");
assert!(m.validate().is_err());
}
#[test]
fn rejects_missing_and_bad_names() {
assert!(AddonManifest::default().validate().is_err());
let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
assert!(bad.validate().is_err());
let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
assert!(bad2.validate().is_err());
}
#[test]
fn install_block_parses_validates_and_records_receipt() {
let m = AddonManifest::from_toml(
r#"
[addon]
name = "boot"
[mcp]
transport = "stdio"
command = "boot"
args = ["serve"]
[install]
manager = "uv"
package = "boot-ai[mcp]"
version = "1.4.2"
bin = "boot"
"#,
)
.expect("parse");
assert!(m.install.is_declared());
assert!(m.validate().is_ok());
assert!(m.is_installable(), "an installed-binary command resolves");
let receipt = m.install.to_receipt();
assert_eq!(receipt.manager, "uv");
assert_eq!(receipt.bin, "boot");
assert_eq!(
m.install.install_argv(),
["tool", "install", "boot-ai[mcp]==1.4.2"]
);
}
#[test]
fn install_block_with_bad_pin_fails_manifest_validation() {
let m = AddonManifest::from_toml(
"[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
[install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
)
.expect("parse");
assert!(m.validate().is_err(), "floating version is rejected");
}
#[test]
fn absent_install_block_is_default() {
let m = stdio_manifest();
assert!(!m.install.is_declared(), "no [install] → no bootstrap");
}
#[test]
fn slug_validation() {
assert!(is_slug("lmd"));
assert!(is_slug("my-addon-2"));
assert!(!is_slug("Bad"));
assert!(!is_slug("-x"));
assert!(!is_slug("x-"));
assert!(!is_slug("under_score"));
assert!(!is_slug(""));
}
fn artifacts_manifest() -> AddonManifest {
AddonManifest::from_toml(
r#"
[addon]
name = "lean-md"
version = "0.2.0"
[mcp]
transport = "stdio"
command = "lean-md"
args = ["mcp"]
[artifacts.aarch64-apple-darwin]
filename = "lean-md-aarch64-apple-darwin"
url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
[artifacts.x86_64-unknown-linux-gnu]
filename = "lean-md-x86_64-unknown-linux-gnu"
url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
"#,
)
.expect("parse")
}
#[test]
fn artifacts_block_parses_and_validates() {
let m = artifacts_manifest();
assert!(m.validate().is_ok());
assert_eq!(m.artifacts.len(), 2);
let asset = &m.artifacts["aarch64-apple-darwin"];
assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
assert_eq!(asset.sha256, "a".repeat(64));
}
#[test]
fn unpinned_artifact_fails_validation() {
let mut m = artifacts_manifest();
m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
let err = m.validate().unwrap_err();
assert!(err.contains("sha256"), "got: {err}");
}
#[test]
fn artifact_missing_url_fails_validation() {
let mut m = artifacts_manifest();
m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
let err = m.validate().unwrap_err();
assert!(err.contains("url"), "got: {err}");
}
#[test]
fn artifact_for_current_platform_resolves_by_triple() {
let m = artifacts_manifest();
let triple = super::super::artifact_install::current_target_triple();
assert_eq!(
m.artifact_for_current_platform().is_some(),
m.artifacts.contains_key(triple)
);
}
#[test]
fn absent_artifacts_is_empty_and_not_serialized() {
let m = stdio_manifest();
assert!(m.artifacts.is_empty());
let toml = toml::to_string(&m).expect("serialize");
assert!(!toml.contains("[artifacts"), "got: {toml}");
}
}