1use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::event::ALL_HOOK_EVENT_KINDS;
13
14#[derive(Debug, Error)]
15pub enum ManifestError {
16 #[error("manifest parse error: {0}")]
17 Parse(#[from] toml::de::Error),
18 #[error("manifest validation failed: {0}")]
19 Validation(String),
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PluginManifest {
24 pub plugin: PluginMetadata,
25 #[serde(default)]
26 pub skills: Vec<PluginSkill>,
27 #[serde(default)]
28 pub tools: Vec<PluginTool>,
29 #[serde(default)]
30 pub hooks: Vec<PluginHook>,
31 #[serde(default)]
33 pub language_provider: Option<LanguageProviderDeclaration>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct LanguageProviderDeclaration {
40 pub language: String,
42 pub artifact: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct BundleManifest {
50 pub schema_version: u32,
51 pub bundle: BundleMetadata,
52 #[serde(default)]
53 pub skills: Vec<PluginSkill>,
54 #[serde(default)]
55 pub tools: Vec<PluginTool>,
56 #[serde(default)]
57 pub hooks: Vec<PluginHook>,
58 #[serde(default)]
60 pub language_provider: Option<LanguageProviderDeclaration>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct BundleMetadata {
67 pub name: String,
68 pub version: String,
69 pub carrier: String,
70 pub artifact: String,
71 #[serde(default)]
72 pub digest: Option<String>,
73 #[serde(default)]
74 pub description: Option<String>,
75 #[serde(default)]
76 pub talos_protocol: Option<String>,
77}
78
79#[derive(Debug, Clone)]
81pub enum CompatibleManifest {
82 Legacy(PluginManifest),
83 Bundle(BundleManifest),
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct MigrationOptions {
89 pub schema_version: u32,
91 pub allow_write: bool,
93}
94
95pub fn migrate_legacy_manifest(
99 input: &str,
100 options: MigrationOptions,
101) -> Result<String, ManifestError> {
102 if !options.allow_write {
103 return Err(ManifestError::Validation(
104 "migration write requires explicit opt-in".into(),
105 ));
106 }
107 if options.schema_version != 1 {
108 return Err(ManifestError::Validation(
109 "unsupported migration schema version".into(),
110 ));
111 }
112 let value: toml::Value = toml::from_str(input)?;
113 let allowed = ["plugin", "skills", "tools", "hooks"];
114 if let Some(unknown) = value
115 .as_table()
116 .and_then(|table| table.keys().find(|key| !allowed.contains(&key.as_str())))
117 {
118 return Err(ManifestError::Validation(format!(
119 "unknown legacy manifest field '{unknown}'"
120 )));
121 }
122 if let Some(plugin) = value.get("plugin").and_then(toml::Value::as_table) {
123 let allowed_plugin = [
124 "name",
125 "version",
126 "carrier",
127 "artifact",
128 "description",
129 "talos_protocol",
130 ];
131 if let Some(unknown) = plugin
132 .keys()
133 .find(|key| !allowed_plugin.contains(&key.as_str()))
134 {
135 return Err(ManifestError::Validation(format!(
136 "unknown legacy plugin field '{unknown}'"
137 )));
138 }
139 }
140 let legacy = parse_manifest(input)?;
141 let bundle = BundleManifest {
142 schema_version: 1,
143 bundle: BundleMetadata {
144 name: legacy.plugin.name,
145 version: legacy.plugin.version,
146 carrier: legacy.plugin.carrier,
147 artifact: legacy.plugin.artifact,
148 digest: None,
149 description: legacy.plugin.description,
150 talos_protocol: legacy.plugin.talos_protocol,
151 },
152 skills: legacy.skills,
153 tools: legacy.tools,
154 hooks: legacy.hooks,
155 language_provider: legacy.language_provider,
156 };
157 bundle.validate()?;
158 toml::to_string_pretty(&bundle)
159 .map_err(|error| ManifestError::Validation(format!("bundle serialization failed: {error}")))
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PluginMetadata {
164 pub name: String,
165 pub version: String,
166 pub carrier: String,
167 pub artifact: String,
168 #[serde(default)]
169 pub description: Option<String>,
170 #[serde(default)]
171 pub talos_protocol: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct PluginSkill {
177 pub name: String,
178 pub path: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct PluginTool {
184 pub name: String,
185 pub handler: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct PluginHook {
191 pub name: String,
192 pub event: String,
193 pub handler: String,
194 #[serde(default)]
195 pub priority: Option<i32>,
196}
197
198pub fn parse_manifest(toml_str: &str) -> Result<PluginManifest, ManifestError> {
199 let manifest: PluginManifest = toml::from_str(toml_str)?;
200 manifest.validate()?;
201 Ok(manifest)
202}
203
204pub fn parse_compatible_manifest(toml_str: &str) -> Result<CompatibleManifest, ManifestError> {
206 let value: toml::Value = toml::from_str(toml_str)?;
207 let has_plugin = value.get("plugin").is_some();
208 let has_bundle = value.get("bundle").is_some();
209 if has_plugin == has_bundle {
210 return Err(ManifestError::Validation(
211 "manifest must contain exactly one of [plugin] or [bundle]".into(),
212 ));
213 }
214 if has_bundle {
215 let allowed = [
216 "schema_version",
217 "bundle",
218 "skills",
219 "tools",
220 "hooks",
221 "language_provider",
222 ];
223 if let Some(unknown) = value
224 .as_table()
225 .and_then(|table| table.keys().find(|key| !allowed.contains(&key.as_str())))
226 {
227 return Err(ManifestError::Validation(format!(
228 "unknown bundle manifest field '{unknown}'"
229 )));
230 }
231 let manifest: BundleManifest = value
232 .try_into()
233 .map_err(|error| ManifestError::Validation(format!("bundle manifest: {error}")))?;
234 manifest.validate()?;
235 Ok(CompatibleManifest::Bundle(manifest))
236 } else {
237 let allowed = ["plugin", "skills", "tools", "hooks"];
238 if let Some(unknown) = value
239 .as_table()
240 .and_then(|table| table.keys().find(|key| !allowed.contains(&key.as_str())))
241 {
242 return Err(ManifestError::Validation(format!(
243 "unknown legacy manifest field '{unknown}'"
244 )));
245 }
246 if let Some(plugin) = value.get("plugin").and_then(toml::Value::as_table) {
247 let allowed_plugin = [
248 "name",
249 "version",
250 "carrier",
251 "artifact",
252 "description",
253 "talos_protocol",
254 ];
255 if let Some(unknown) = plugin
256 .keys()
257 .find(|key| !allowed_plugin.contains(&key.as_str()))
258 {
259 return Err(ManifestError::Validation(format!(
260 "unknown legacy plugin field '{unknown}'"
261 )));
262 }
263 }
264 Ok(CompatibleManifest::Legacy(parse_manifest(toml_str)?))
265 }
266}
267
268impl PluginManifest {
269 pub fn validate(&self) -> Result<(), ManifestError> {
270 let p = &self.plugin;
271 if p.name.trim().is_empty() {
272 return Err(ManifestError::Validation("plugin.name is empty".into()));
273 }
274 if p.version.trim().is_empty() {
275 return Err(ManifestError::Validation("plugin.version is empty".into()));
276 }
277 if p.artifact.trim().is_empty() {
278 return Err(ManifestError::Validation("plugin.artifact is empty".into()));
279 }
280 if p.carrier != "wasm" {
281 return Err(ManifestError::Validation(format!(
282 "plugin.carrier must be 'wasm' (got '{}'); other carriers are not yet supported",
283 p.carrier
284 )));
285 }
286 if let Some(provider) = &self.language_provider {
287 if provider.language.trim().is_empty() {
288 return Err(ManifestError::Validation(
289 "language_provider.language is empty".into(),
290 ));
291 }
292 if provider.artifact.trim().is_empty() {
293 return Err(ManifestError::Validation(
294 "language_provider.artifact is empty".into(),
295 ));
296 }
297 }
298 let mut seen_tools: HashSet<&str> = HashSet::new();
299 for tool in &self.tools {
300 if tool.name.trim().is_empty() {
301 return Err(ManifestError::Validation(
302 "tool name is empty in [[tools]]".into(),
303 ));
304 }
305 if tool.handler.trim().is_empty() {
306 return Err(ManifestError::Validation(format!(
307 "tool '{}' has empty handler",
308 tool.name
309 )));
310 }
311 if !seen_tools.insert(&tool.name) {
312 return Err(ManifestError::Validation(format!(
313 "duplicate tool name '{}'",
314 tool.name
315 )));
316 }
317 }
318 for skill in &self.skills {
319 if skill.name.trim().is_empty() {
320 return Err(ManifestError::Validation(
321 "skill name is empty in [[skills]]".into(),
322 ));
323 }
324 if skill.path.trim().is_empty() {
325 return Err(ManifestError::Validation(format!(
326 "skill '{}' has empty path",
327 skill.name
328 )));
329 }
330 }
331 let mut seen_hooks: HashSet<&str> = HashSet::new();
332 for hook in &self.hooks {
333 if hook.name.trim().is_empty() {
334 return Err(ManifestError::Validation(
335 "hook name is empty in [[hooks]]".into(),
336 ));
337 }
338 if hook.handler.trim().is_empty() {
339 return Err(ManifestError::Validation(format!(
340 "hook '{}' has empty handler",
341 hook.name
342 )));
343 }
344 if !is_known_hook_event(&hook.event) {
345 return Err(ManifestError::Validation(format!(
346 "hook '{}' references unknown event '{}'",
347 hook.name, hook.event
348 )));
349 }
350 if !seen_hooks.insert(&hook.name) {
351 return Err(ManifestError::Validation(format!(
352 "duplicate hook name '{}'",
353 hook.name
354 )));
355 }
356 }
357 Ok(())
358 }
359}
360
361impl BundleManifest {
362 pub fn validate(&self) -> Result<(), ManifestError> {
364 if self.schema_version == 0 {
365 return Err(ManifestError::Validation(
366 "bundle.schema_version must be non-zero".into(),
367 ));
368 }
369 if self.schema_version != 1 {
370 return Err(ManifestError::Validation(format!(
371 "unsupported bundle schema version {}",
372 self.schema_version
373 )));
374 }
375 if self.bundle.name.trim().is_empty() || !valid_semver(&self.bundle.version) {
376 return Err(ManifestError::Validation(
377 "bundle name and version are required".into(),
378 ));
379 }
380 if self.bundle.artifact.trim().is_empty()
381 || self.bundle.artifact.starts_with('/')
382 || !safe_relative_path(&self.bundle.artifact)
383 {
384 return Err(ManifestError::Validation(
385 "bundle.artifact must be a safe relative path".into(),
386 ));
387 }
388 if self.bundle.carrier != "wasm" {
389 return Err(ManifestError::Validation(
390 "bundle.carrier must be 'wasm'".into(),
391 ));
392 }
393 if self
394 .bundle
395 .digest
396 .as_deref()
397 .is_some_and(|d| !valid_digest(d))
398 {
399 return Err(ManifestError::Validation(
400 "bundle.digest cannot be empty".into(),
401 ));
402 }
403 validate_components(&self.tools, &self.skills, &self.hooks)?;
404 if let Some(provider) = &self.language_provider
405 && (provider.language.trim().is_empty()
406 || provider.artifact.trim().is_empty()
407 || !safe_relative_path(&provider.artifact))
408 {
409 return Err(ManifestError::Validation(
410 "bundle language_provider must use a non-empty safe relative artifact path".into(),
411 ));
412 }
413 if self
414 .tools
415 .iter()
416 .any(|tool| !safe_relative_path(&tool.handler))
417 || self
418 .skills
419 .iter()
420 .any(|skill| !safe_relative_path(&skill.path))
421 || self
422 .hooks
423 .iter()
424 .any(|hook| !safe_relative_path(&hook.handler))
425 {
426 return Err(ManifestError::Validation(
427 "bundle component paths must be safe relative paths".into(),
428 ));
429 }
430 Ok(())
431 }
432}
433
434fn validate_components(
435 tools: &[PluginTool],
436 skills: &[PluginSkill],
437 hooks: &[PluginHook],
438) -> Result<(), ManifestError> {
439 let mut names = HashSet::new();
440 for tool in tools {
441 if tool.name.trim().is_empty()
442 || tool.handler.trim().is_empty()
443 || !names.insert(tool.name.as_str())
444 {
445 return Err(ManifestError::Validation(
446 "invalid or duplicate tool".into(),
447 ));
448 }
449 }
450 for skill in skills {
451 if skill.name.trim().is_empty() || !safe_relative_path(&skill.path) {
452 return Err(ManifestError::Validation("invalid skill".into()));
453 }
454 }
455 for hook in hooks {
456 if hook.name.trim().is_empty()
457 || hook.handler.trim().is_empty()
458 || !is_known_hook_event(&hook.event)
459 {
460 return Err(ManifestError::Validation("invalid hook".into()));
461 }
462 }
463 Ok(())
464}
465
466fn safe_relative_path(path: &str) -> bool {
467 let path = path.trim();
468 !path.is_empty()
469 && !path.starts_with('/')
470 && !path.starts_with('\\')
471 && !path.contains(':')
472 && !path
473 .split(['/', '\\'])
474 .any(|part| part.is_empty() || part == "..")
475}
476
477fn valid_digest(digest: &str) -> bool {
478 let Some(hex) = digest.strip_prefix("sha256:") else {
479 return false;
480 };
481 hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())
482}
483
484fn valid_semver(version: &str) -> bool {
485 let mut parts = version.trim().split('.');
486 let valid = (0..3).all(|_| {
487 parts
488 .next()
489 .is_some_and(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
490 });
491 valid && parts.next().is_none()
492}
493
494fn is_known_hook_event(event: &str) -> bool {
495 ALL_HOOK_EVENT_KINDS
496 .iter()
497 .any(|kind| kind.to_string() == event)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 const VALID_MANIFEST: &str = r#"
505[plugin]
506name = "my-plugin"
507version = "0.1.0"
508carrier = "wasm"
509artifact = "artifacts/my-plugin.wasm"
510description = "A test plugin"
511
512[[tools]]
513name = "greet"
514handler = "tools/greet.wasm"
515
516[[skills]]
517name = "my-skill"
518path = "skills/my-skill/SKILL.md"
519
520[[hooks]]
521name = "pre-call"
522event = "BeforeProviderCall"
523handler = "hooks/pre-call.wasm"
524priority = 10
525"#;
526
527 #[test]
528 fn parse_valid_manifest() {
529 let manifest = parse_manifest(VALID_MANIFEST).expect("valid manifest");
530 assert_eq!(manifest.plugin.name, "my-plugin");
531 assert_eq!(manifest.plugin.version, "0.1.0");
532 assert_eq!(manifest.plugin.carrier, "wasm");
533 assert_eq!(manifest.plugin.artifact, "artifacts/my-plugin.wasm");
534 assert_eq!(manifest.tools.len(), 1);
535 assert_eq!(manifest.tools[0].name, "greet");
536 assert_eq!(manifest.skills.len(), 1);
537 assert_eq!(manifest.skills[0].name, "my-skill");
538 assert_eq!(manifest.hooks.len(), 1);
539 assert_eq!(manifest.hooks[0].name, "pre-call");
540 assert_eq!(manifest.hooks[0].event, "BeforeProviderCall");
541 assert_eq!(manifest.hooks[0].handler, "hooks/pre-call.wasm");
542 assert_eq!(manifest.hooks[0].priority, Some(10));
543 }
544
545 #[test]
546 fn parse_versioned_bundle_manifest() {
547 let toml = r#"
548schema_version = 1
549[bundle]
550name = "my-bundle"
551version = "1.0.0"
552carrier = "wasm"
553artifact = "artifacts/main.wasm"
554digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
555
556[language_provider]
557language = "rust"
558artifact = "artifacts/rust.wasm"
559"#;
560 let parsed = parse_compatible_manifest(toml).expect("bundle manifest");
561 assert!(matches!(
562 &parsed,
563 CompatibleManifest::Bundle(BundleManifest {
564 schema_version: 1,
565 ..
566 })
567 ));
568 let CompatibleManifest::Bundle(bundle) = parsed else {
569 unreachable!()
570 };
571 assert_eq!(bundle.language_provider.expect("provider").language, "rust");
572 }
573
574 #[test]
575 fn reject_mixed_manifest_roots_and_unknown_bundle_fields() {
576 let mixed = format!("{}\n[bundle]\nname = \"b\"", VALID_MANIFEST);
577 assert!(parse_compatible_manifest(&mixed).is_err());
578 let unknown = r#"
579schema_version = 1
580future = true
581[bundle]
582name = "b"
583version = "1.0.0"
584carrier = "wasm"
585artifact = "b.wasm"
586"#;
587 let err = parse_compatible_manifest(unknown).expect_err("unknown field must fail closed");
588 assert!(err.to_string().contains("unknown bundle manifest field"));
589 let unknown_legacy = format!("unknown = true\n{}", VALID_MANIFEST);
590 assert!(parse_compatible_manifest(&unknown_legacy).is_err());
591 }
592
593 #[test]
594 fn reject_bundle_language_provider_path_escape() {
595 let toml = r#"
596schema_version = 1
597[bundle]
598name = "bundle"
599version = "1.0.0"
600carrier = "wasm"
601artifact = "main.wasm"
602[language_provider]
603language = "rust"
604artifact = "../provider.wasm"
605"#;
606 assert!(parse_compatible_manifest(toml).is_err());
607 }
608
609 #[test]
610 fn migration_requires_explicit_opt_in_and_preserves_legacy_input() {
611 let migrated = migrate_legacy_manifest(
612 VALID_MANIFEST,
613 MigrationOptions {
614 schema_version: 1,
615 allow_write: true,
616 },
617 )
618 .expect("migration");
619 assert!(migrated.contains("schema_version = 1"));
620 assert!(migrated.contains("[bundle]"));
621 assert!(
622 migrate_legacy_manifest(
623 VALID_MANIFEST,
624 MigrationOptions {
625 schema_version: 1,
626 allow_write: false
627 }
628 )
629 .is_err()
630 );
631 }
632
633 #[test]
634 fn parse_minimal_manifest_no_components() {
635 let toml = r#"
636[plugin]
637name = "bare"
638version = "0.1.0"
639carrier = "wasm"
640artifact = "bare.wasm"
641"#;
642 let manifest = parse_manifest(toml).expect("minimal manifest");
643 assert!(manifest.tools.is_empty());
644 assert!(manifest.skills.is_empty());
645 assert!(manifest.hooks.is_empty());
646 }
647
648 #[test]
649 fn parse_explicit_language_provider_declaration() {
650 let manifest = parse_manifest(
651 r#"
652[plugin]
653name = "rust-provider"
654version = "1.0.0"
655carrier = "wasm"
656artifact = "provider.wasm"
657
658[language_provider]
659language = "rust"
660artifact = "provider.wasm"
661"#,
662 )
663 .expect("manifest should parse");
664 let declaration = manifest
665 .language_provider
666 .expect("provider declaration should be retained");
667 assert_eq!(declaration.language, "rust");
668 assert_eq!(declaration.artifact, "provider.wasm");
669 }
670
671 #[test]
672 fn reject_empty_name() {
673 let toml = r#"
674[plugin]
675name = ""
676version = "0.1.0"
677carrier = "wasm"
678artifact = "x.wasm"
679"#;
680 let err = parse_manifest(toml).expect_err("operation should fail");
681 assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("name is empty")));
682 }
683
684 #[test]
685 fn reject_empty_version() {
686 let toml = r#"
687[plugin]
688name = "p"
689version = ""
690carrier = "wasm"
691artifact = "x.wasm"
692"#;
693 let err = parse_manifest(toml).expect_err("operation should fail");
694 assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("version is empty")));
695 }
696
697 #[test]
698 fn reject_empty_artifact() {
699 let toml = r#"
700[plugin]
701name = "p"
702version = "0.1.0"
703carrier = "wasm"
704artifact = ""
705"#;
706 let err = parse_manifest(toml).expect_err("operation should fail");
707 assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("artifact is empty")));
708 }
709
710 #[test]
711 fn reject_non_wasm_carrier() {
712 let toml = r#"
713[plugin]
714name = "p"
715version = "0.1.0"
716carrier = "lua"
717artifact = "x.lua"
718"#;
719 let err = parse_manifest(toml).expect_err("operation should fail");
720 assert!(
721 matches!(err, ManifestError::Validation(ref m) if m.contains("carrier must be 'wasm'"))
722 );
723 }
724
725 #[test]
726 fn reject_dylib_carrier() {
727 let toml = r#"
728[plugin]
729name = "p"
730version = "0.1.0"
731carrier = "dylib"
732artifact = "x.so"
733"#;
734 let err = parse_manifest(toml).expect_err("operation should fail");
735 assert!(
736 matches!(err, ManifestError::Validation(ref m) if m.contains("carrier must be 'wasm'"))
737 );
738 }
739
740 #[test]
741 fn reject_malformed_toml() {
742 let toml = "this is not valid toml {{{";
743 let err = parse_manifest(toml).expect_err("operation should fail");
744 assert!(matches!(err, ManifestError::Parse(_)));
745 }
746
747 #[test]
748 fn reject_missing_plugin_section() {
749 let toml = r#"
750[other]
751key = "value"
752"#;
753 let err = parse_manifest(toml).expect_err("operation should fail");
754 assert!(matches!(err, ManifestError::Parse(_)));
755 }
756
757 #[test]
758 fn reject_duplicate_tool_names() {
759 let toml = r#"
760[plugin]
761name = "p"
762version = "0.1.0"
763carrier = "wasm"
764artifact = "x.wasm"
765
766[[tools]]
767name = "dup"
768handler = "a.wasm"
769
770[[tools]]
771name = "dup"
772handler = "b.wasm"
773"#;
774 let err = parse_manifest(toml).expect_err("operation should fail");
775 assert!(
776 matches!(err, ManifestError::Validation(ref m) if m.contains("duplicate tool name"))
777 );
778 }
779
780 #[test]
781 fn reject_empty_tool_name() {
782 let toml = r#"
783[plugin]
784name = "p"
785version = "0.1.0"
786carrier = "wasm"
787artifact = "x.wasm"
788
789[[tools]]
790name = ""
791handler = "a.wasm"
792"#;
793 let err = parse_manifest(toml).expect_err("operation should fail");
794 assert!(
795 matches!(err, ManifestError::Validation(ref m) if m.contains("tool name is empty"))
796 );
797 }
798
799 #[test]
800 fn reject_empty_tool_handler() {
801 let toml = r#"
802[plugin]
803name = "p"
804version = "0.1.0"
805carrier = "wasm"
806artifact = "x.wasm"
807
808[[tools]]
809name = "t"
810handler = ""
811"#;
812 let err = parse_manifest(toml).expect_err("operation should fail");
813 assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("empty handler")));
814 }
815
816 #[test]
817 fn manifest_describes_permissions_without_granting() {
818 let toml = r#"
819[plugin]
820name = "p"
821version = "0.1.0"
822carrier = "wasm"
823artifact = "x.wasm"
824
825[plugin.permissions]
826fs = ["read"]
827network = false
828"#;
829 let manifest = parse_manifest(toml).expect("manifest with permissions section");
830 assert_eq!(manifest.plugin.name, "p");
831 }
832
833 #[test]
834 fn parse_hook_declaration() {
835 let toml = r#"
836[plugin]
837name = "p"
838version = "0.1.0"
839carrier = "wasm"
840artifact = "x.wasm"
841
842[[hooks]]
843name = "turn-start"
844event = "TurnStart"
845handler = "hooks/turn-start.wasm"
846"#;
847 let manifest = parse_manifest(toml).expect("valid manifest");
848 assert_eq!(manifest.hooks.len(), 1);
849 assert_eq!(manifest.hooks[0].name, "turn-start");
850 assert_eq!(manifest.hooks[0].event, "TurnStart");
851 }
852
853 #[test]
854 fn reject_unknown_hook_event() {
855 let toml = r#"
856[plugin]
857name = "p"
858version = "0.1.0"
859carrier = "wasm"
860artifact = "x.wasm"
861
862[[hooks]]
863name = "bad"
864event = "MadeUpEvent"
865handler = "hooks/bad.wasm"
866"#;
867 let err = parse_manifest(toml).expect_err("operation should fail");
868 assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("unknown event")));
869 }
870
871 #[test]
872 fn reject_duplicate_hook_names() {
873 let toml = r#"
874[plugin]
875name = "p"
876version = "0.1.0"
877carrier = "wasm"
878artifact = "x.wasm"
879
880[[hooks]]
881name = "dup"
882event = "TurnStart"
883handler = "hooks/a.wasm"
884
885[[hooks]]
886name = "dup"
887event = "TurnComplete"
888handler = "hooks/b.wasm"
889"#;
890 let err = parse_manifest(toml).expect_err("operation should fail");
891 assert!(
892 matches!(err, ManifestError::Validation(ref m) if m.contains("duplicate hook name"))
893 );
894 }
895
896 #[test]
897 fn bundle_semver_boundary_and_legacy_compatibility() {
898 let bundle = |version: &str| {
899 format!(
900 "schema_version = 1\n[bundle]\nname=\"b\"\nversion=\"{version}\"\ncarrier=\"wasm\"\nartifact=\"b.wasm\""
901 )
902 };
903 assert!(parse_compatible_manifest(&bundle("1.2.3")).is_ok());
904 assert!(parse_compatible_manifest(&bundle("garbage")).is_err());
905 let legacy = VALID_MANIFEST.replace("0.1.0", "0.1");
906 assert!(parse_compatible_manifest(&legacy).is_ok());
907 assert!(
908 migrate_legacy_manifest(
909 &legacy,
910 MigrationOptions {
911 schema_version: 1,
912 allow_write: true
913 }
914 )
915 .is_err()
916 );
917 }
918}