1use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::bootstrap::AddonInstall;
15use super::capabilities::AddonCapabilities;
16use crate::core::gateway::{GatewayServer, TransportKind};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct AddonMeta {
22 pub name: String,
24 pub display_name: String,
26 pub version: String,
28 pub description: String,
30 pub author: String,
32 pub homepage: String,
34 pub license: String,
36 pub categories: Vec<String>,
38 pub integration: String,
45 pub keywords: Vec<String>,
47 pub min_lean_ctx: String,
49 pub verified: bool,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(default)]
62pub struct AddonMcp {
63 pub transport: TransportKind,
65 pub command: String,
67 pub args: Vec<String>,
69 pub env: BTreeMap<String, String>,
71 pub sha256: String,
75 pub url: String,
77 pub headers: BTreeMap<String, String>,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct AddonManifest {
84 pub addon: AddonMeta,
85 #[serde(default)]
86 pub mcp: AddonMcp,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub capabilities: Option<AddonCapabilities>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub pricing: Option<super::commerce::AddonPricing>,
97 #[serde(default, skip_serializing_if = "AddonInstall::is_absent")]
102 pub install: AddonInstall,
103 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110 pub artifacts: BTreeMap<String, super::artifact_install::ArtifactAsset>,
111}
112
113impl AddonManifest {
114 pub fn from_toml(text: &str) -> Result<Self, String> {
116 toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
117 }
118
119 pub fn from_path(path: &Path) -> Result<Self, String> {
121 let raw = std::fs::read_to_string(path)
122 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
123 let manifest = Self::from_toml(&raw)?;
124 manifest.validate()?;
125 Ok(manifest)
126 }
127
128 pub fn integration_kind(&self) -> String {
132 use crate::core::gateway::adapters::IntegrationKind;
133 let explicit = self.addon.integration.trim();
134 let kind = if explicit.is_empty() {
135 IntegrationKind::from_categories(&self.addon.categories)
136 } else {
137 IntegrationKind::parse(explicit)
138 };
139 kind.as_str().to_string()
140 }
141
142 pub fn display_name(&self) -> &str {
144 if self.addon.display_name.trim().is_empty() {
145 &self.addon.name
146 } else {
147 &self.addon.display_name
148 }
149 }
150
151 pub fn validate(&self) -> Result<(), String> {
154 let name = self.addon.name.trim();
155 if name.is_empty() {
156 return Err("addon manifest is missing `addon.name`".into());
157 }
158 if !is_slug(name) {
159 return Err(format!(
160 "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
161 no leading/trailing dash)"
162 ));
163 }
164 if let Some(caps) = &self.capabilities {
165 caps.validate()?;
166 }
167 self.install.validate()?;
168 for (triple, asset) in &self.artifacts {
169 if asset.filename.trim().is_empty() {
170 return Err(format!(
171 "addon `{name}` artifact for `{triple}` is missing `filename`"
172 ));
173 }
174 if asset.url.trim().is_empty() {
175 return Err(format!(
176 "addon `{name}` artifact for `{triple}` is missing `url`"
177 ));
178 }
179 if asset.sha256.trim().is_empty() {
180 return Err(format!(
181 "addon `{name}` artifact for `{triple}` is missing `sha256` — a managed \
182 binary must be pinned"
183 ));
184 }
185 }
186 Ok(())
187 }
188
189 pub fn artifact_for_current_platform(&self) -> Option<&super::artifact_install::ArtifactAsset> {
191 self.artifacts
192 .get(super::artifact_install::current_target_triple())
193 }
194
195 pub fn to_gateway_server(&self) -> GatewayServer {
197 GatewayServer {
198 name: self.addon.name.clone(),
199 transport: self.mcp.transport,
200 enabled: true,
201 command: self.mcp.command.clone(),
202 args: self.mcp.args.clone(),
203 env: self.mcp.env.clone(),
204 binary_sha256: self.mcp.sha256.clone(),
205 url: self.mcp.url.clone(),
206 headers: self.mcp.headers.clone(),
207 capabilities: self.capabilities.clone(),
208 integration: self.integration_kind(),
211 }
212 }
213
214 pub fn is_installable(&self) -> bool {
218 self.to_gateway_server().resolve().is_ok()
219 }
220}
221
222fn is_slug(s: &str) -> bool {
223 !s.is_empty()
224 && !s.starts_with('-')
225 && !s.ends_with('-')
226 && s.chars()
227 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn stdio_manifest() -> AddonManifest {
235 AddonManifest::from_toml(
236 r#"
237[addon]
238name = "demo"
239display_name = "Demo Addon"
240version = "1.2.3"
241description = "A demo"
242author = "tester"
243categories = ["search"]
244keywords = ["alpha", "beta"]
245
246[mcp]
247transport = "stdio"
248command = "demo-mcp"
249args = ["serve"]
250"#,
251 )
252 .expect("parse")
253 }
254
255 #[test]
256 fn parses_full_stdio_manifest() {
257 let m = stdio_manifest();
258 assert_eq!(m.addon.name, "demo");
259 assert_eq!(m.display_name(), "Demo Addon");
260 assert_eq!(m.mcp.transport, TransportKind::Stdio);
261 assert_eq!(m.mcp.command, "demo-mcp");
262 assert!(m.is_installable());
263 let srv = m.to_gateway_server();
264 assert_eq!(srv.name, "demo");
265 assert_eq!(srv.args, vec!["serve".to_string()]);
266 assert!(srv.enabled);
267 }
268
269 #[test]
270 fn listed_only_entry_is_not_installable() {
271 let m = AddonManifest::from_toml(
272 r#"
273[addon]
274name = "listed"
275description = "no mcp block"
276homepage = "https://example.com"
277"#,
278 )
279 .expect("parse");
280 assert!(m.validate().is_ok());
281 assert!(!m.is_installable(), "no [mcp] block → listed only");
282 }
283
284 #[test]
285 fn http_manifest_is_installable() {
286 let m = AddonManifest::from_toml(
287 r#"
288[addon]
289name = "remote"
290
291[mcp]
292transport = "http"
293url = "https://example.com/mcp"
294"#,
295 )
296 .expect("parse");
297 assert!(m.is_installable());
298 assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
299 }
300
301 #[test]
302 fn display_name_falls_back_to_slug() {
303 let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
304 assert_eq!(m.display_name(), "slug-only");
305 }
306
307 #[test]
308 fn capabilities_block_parses_and_threads_to_gateway() {
309 let m = AddonManifest::from_toml(
310 r#"
311[addon]
312name = "caps"
313
314[mcp]
315transport = "stdio"
316command = "caps-mcp"
317
318[capabilities]
319network = "full"
320filesystem = "read_write"
321env = ["GITHUB_TOKEN"]
322"#,
323 )
324 .expect("parse");
325 let caps = m.capabilities.as_ref().expect("capabilities present");
326 assert!(caps.network_allowed());
327 assert!(caps.filesystem_writable());
328 assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
329 assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
331 }
332
333 #[test]
334 fn absent_capabilities_is_none() {
335 let m = stdio_manifest();
336 assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
337 assert!(m.to_gateway_server().capabilities.is_none());
338 }
339
340 #[test]
341 fn invalid_capability_env_name_fails_validation() {
342 let m = AddonManifest::from_toml(
343 "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
344 )
345 .expect("parse");
346 assert!(m.validate().is_err());
347 }
348
349 #[test]
350 fn rejects_missing_and_bad_names() {
351 assert!(AddonManifest::default().validate().is_err());
352 let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
353 assert!(bad.validate().is_err());
354 let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
355 assert!(bad2.validate().is_err());
356 }
357
358 #[test]
359 fn install_block_parses_validates_and_records_receipt() {
360 let m = AddonManifest::from_toml(
361 r#"
362[addon]
363name = "boot"
364
365[mcp]
366transport = "stdio"
367command = "boot"
368args = ["serve"]
369
370[install]
371manager = "uv"
372package = "boot-ai[mcp]"
373version = "1.4.2"
374bin = "boot"
375"#,
376 )
377 .expect("parse");
378 assert!(m.install.is_declared());
379 assert!(m.validate().is_ok());
380 assert!(m.is_installable(), "an installed-binary command resolves");
381 let receipt = m.install.to_receipt();
382 assert_eq!(receipt.manager, "uv");
383 assert_eq!(receipt.bin, "boot");
384 assert_eq!(
385 m.install.install_argv(),
386 ["tool", "install", "boot-ai[mcp]==1.4.2"]
387 );
388 }
389
390 #[test]
391 fn install_block_with_bad_pin_fails_manifest_validation() {
392 let m = AddonManifest::from_toml(
393 "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
394 [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
395 )
396 .expect("parse");
397 assert!(m.validate().is_err(), "floating version is rejected");
398 }
399
400 #[test]
401 fn absent_install_block_is_default() {
402 let m = stdio_manifest();
403 assert!(!m.install.is_declared(), "no [install] → no bootstrap");
404 }
405
406 #[test]
407 fn slug_validation() {
408 assert!(is_slug("lmd"));
409 assert!(is_slug("my-addon-2"));
410 assert!(!is_slug("Bad"));
411 assert!(!is_slug("-x"));
412 assert!(!is_slug("x-"));
413 assert!(!is_slug("under_score"));
414 assert!(!is_slug(""));
415 }
416
417 fn artifacts_manifest() -> AddonManifest {
420 AddonManifest::from_toml(
421 r#"
422[addon]
423name = "lean-md"
424version = "0.2.0"
425
426[mcp]
427transport = "stdio"
428command = "lean-md"
429args = ["mcp"]
430
431[artifacts.aarch64-apple-darwin]
432filename = "lean-md-aarch64-apple-darwin"
433url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
434sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
435
436[artifacts.x86_64-unknown-linux-gnu]
437filename = "lean-md-x86_64-unknown-linux-gnu"
438url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
439sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
440"#,
441 )
442 .expect("parse")
443 }
444
445 #[test]
446 fn artifacts_block_parses_and_validates() {
447 let m = artifacts_manifest();
448 assert!(m.validate().is_ok());
449 assert_eq!(m.artifacts.len(), 2);
450 let asset = &m.artifacts["aarch64-apple-darwin"];
451 assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
452 assert_eq!(asset.sha256, "a".repeat(64));
453 }
454
455 #[test]
456 fn unpinned_artifact_fails_validation() {
457 let mut m = artifacts_manifest();
458 m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
459 let err = m.validate().unwrap_err();
460 assert!(err.contains("sha256"), "got: {err}");
461 }
462
463 #[test]
464 fn artifact_missing_url_fails_validation() {
465 let mut m = artifacts_manifest();
466 m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
467 let err = m.validate().unwrap_err();
468 assert!(err.contains("url"), "got: {err}");
469 }
470
471 #[test]
472 fn artifact_for_current_platform_resolves_by_triple() {
473 let m = artifacts_manifest();
474 let triple = super::super::artifact_install::current_target_triple();
475 assert_eq!(
476 m.artifact_for_current_platform().is_some(),
477 m.artifacts.contains_key(triple)
478 );
479 }
480
481 #[test]
484 fn absent_artifacts_is_empty_and_not_serialized() {
485 let m = stdio_manifest();
486 assert!(m.artifacts.is_empty());
487 let toml = toml::to_string(&m).expect("serialize");
488 assert!(!toml.contains("[artifacts"), "got: {toml}");
489 }
490}