1use crate::error::{KernelError, KernelResult};
53use crate::wasm_runtime::WasmPluginCapabilities;
54use std::collections::HashMap;
55use std::path::Path;
56
57#[derive(Debug, Clone)]
63pub struct PluginManifest {
64 pub plugin: PluginMetadata,
66 pub capabilities: ManifestCapabilities,
68 pub resources: ResourceLimits,
70 pub exports: ExportedFunctions,
72 pub hooks: TableHooks,
74 pub config_schema: Option<ConfigSchema>,
76}
77
78#[derive(Debug, Clone)]
80pub struct PluginMetadata {
81 pub name: String,
83 pub version: String,
85 pub description: String,
87 pub author: String,
89 pub license: Option<String>,
91 pub homepage: Option<String>,
93 pub repository: Option<String>,
95 pub min_kernel_version: Option<String>,
97}
98
99#[derive(Debug, Clone, Default)]
101pub struct ManifestCapabilities {
102 pub can_read_table: Vec<String>,
104 pub can_write_table: Vec<String>,
106 pub can_vector_search: bool,
108 pub can_index_search: bool,
110 pub can_call_plugin: Vec<String>,
112}
113
114#[derive(Debug, Clone)]
116pub struct ResourceLimits {
117 pub memory_limit_mb: u64,
119 pub fuel_limit: u64,
121 pub timeout_ms: u64,
123 pub max_instances: u32,
125}
126
127impl Default for ResourceLimits {
128 fn default() -> Self {
129 Self {
130 memory_limit_mb: 16,
131 fuel_limit: 1_000_000,
132 timeout_ms: 100,
133 max_instances: 4,
134 }
135 }
136}
137
138#[derive(Debug, Clone, Default)]
140pub struct ExportedFunctions {
141 pub functions: Vec<String>,
143 pub signatures: HashMap<String, FunctionSignature>,
145}
146
147#[derive(Debug, Clone)]
149pub struct FunctionSignature {
150 pub params: Vec<WasmType>,
152 pub returns: Vec<WasmType>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum WasmType {
159 I32,
160 I64,
161 F32,
162 F64,
163 ExternRef,
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct TableHooks {
169 pub before_insert: Vec<String>,
171 pub after_insert: Vec<String>,
173 pub before_update: Vec<String>,
175 pub after_update: Vec<String>,
177 pub before_delete: Vec<String>,
179 pub after_delete: Vec<String>,
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct ConfigSchema {
186 pub fields: Vec<ConfigField>,
188}
189
190#[derive(Debug, Clone)]
192pub struct ConfigField {
193 pub name: String,
195 pub field_type: ConfigFieldType,
197 pub required: bool,
199 pub default: Option<String>,
201 pub description: Option<String>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum ConfigFieldType {
208 String,
209 Integer,
210 Float,
211 Boolean,
212 StringArray,
213}
214
215impl PluginManifest {
220 pub fn from_toml(content: &str) -> KernelResult<Self> {
222 let mut manifest = Self::default();
224
225 let mut current_section = "";
226 let mut _current_subsection = "";
227
228 for line in content.lines() {
229 let line = line.trim();
230
231 if line.is_empty() || line.starts_with('#') {
233 continue;
234 }
235
236 if line.starts_with('[') && line.ends_with(']') {
238 let section = &line[1..line.len() - 1];
239 if section.contains('.') {
240 let parts: Vec<&str> = section.split('.').collect();
241 current_section = parts[0];
242 _current_subsection = parts[1];
243 } else {
244 current_section = section;
245 _current_subsection = "";
246 }
247 continue;
248 }
249
250 if let Some((key, value)) = line.split_once('=') {
252 let key = key.trim();
253 let value = value.trim();
254 let value = value.trim_matches('"');
255
256 match current_section {
257 "plugin" => Self::parse_plugin_field(&mut manifest.plugin, key, value),
258 "capabilities" => {
259 Self::parse_capabilities_field(&mut manifest.capabilities, key, value)
260 }
261 "resources" => Self::parse_resources_field(&mut manifest.resources, key, value),
262 "exports" => Self::parse_exports_field(&mut manifest.exports, key, value),
263 "hooks" => Self::parse_hooks_field(&mut manifest.hooks, key, value),
264 _ => {}
265 }
266 }
267 }
268
269 manifest.validate()?;
271
272 Ok(manifest)
273 }
274
275 pub fn from_file(path: &Path) -> KernelResult<Self> {
277 let content = std::fs::read_to_string(path).map_err(|e| KernelError::Plugin {
278 message: format!("failed to read manifest: {}", e),
279 })?;
280 Self::from_toml(&content)
281 }
282
283 pub fn to_capabilities(&self) -> WasmPluginCapabilities {
285 WasmPluginCapabilities {
286 can_read_table: self.capabilities.can_read_table.clone(),
287 can_write_table: self.capabilities.can_write_table.clone(),
288 can_vector_search: self.capabilities.can_vector_search,
289 can_index_search: self.capabilities.can_index_search,
290 can_call_plugin: self.capabilities.can_call_plugin.clone(),
291 memory_limit_bytes: self.resources.memory_limit_mb * 1024 * 1024,
292 fuel_limit: self.resources.fuel_limit,
293 timeout_ms: self.resources.timeout_ms,
294 }
295 }
296
297 pub fn validate(&self) -> KernelResult<()> {
299 if self.plugin.name.is_empty() {
301 return Err(KernelError::Plugin {
302 message: "plugin name is required".to_string(),
303 });
304 }
305
306 if !self
308 .plugin
309 .name
310 .chars()
311 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
312 {
313 return Err(KernelError::Plugin {
314 message: format!("invalid plugin name: {}", self.plugin.name),
315 });
316 }
317
318 if self.plugin.version.is_empty() {
320 return Err(KernelError::Plugin {
321 message: "plugin version is required".to_string(),
322 });
323 }
324
325 if self.resources.memory_limit_mb > 1024 {
327 return Err(KernelError::Plugin {
328 message: "memory limit exceeds 1GB maximum".to_string(),
329 });
330 }
331
332 if self.resources.timeout_ms > 60_000 {
333 return Err(KernelError::Plugin {
334 message: "timeout exceeds 60s maximum".to_string(),
335 });
336 }
337
338 let exported: std::collections::HashSet<_> = self.exports.functions.iter().collect();
340 for hook in self.all_hooks() {
341 if !exported.contains(&hook) {
342 return Err(KernelError::Plugin {
343 message: format!("hook function '{}' not in exports", hook),
344 });
345 }
346 }
347
348 Ok(())
349 }
350
351 fn all_hooks(&self) -> Vec<String> {
353 let mut hooks = Vec::new();
354 hooks.extend(self.hooks.before_insert.clone());
355 hooks.extend(self.hooks.after_insert.clone());
356 hooks.extend(self.hooks.before_update.clone());
357 hooks.extend(self.hooks.after_update.clone());
358 hooks.extend(self.hooks.before_delete.clone());
359 hooks.extend(self.hooks.after_delete.clone());
360 hooks
361 }
362
363 fn parse_plugin_field(plugin: &mut PluginMetadata, key: &str, value: &str) {
365 match key {
366 "name" => plugin.name = value.to_string(),
367 "version" => plugin.version = value.to_string(),
368 "description" => plugin.description = value.to_string(),
369 "author" => plugin.author = value.to_string(),
370 "license" => plugin.license = Some(value.to_string()),
371 "homepage" => plugin.homepage = Some(value.to_string()),
372 "repository" => plugin.repository = Some(value.to_string()),
373 "min_kernel_version" => plugin.min_kernel_version = Some(value.to_string()),
374 _ => {}
375 }
376 }
377
378 fn parse_capabilities_field(caps: &mut ManifestCapabilities, key: &str, value: &str) {
379 match key {
380 "can_read_table" => caps.can_read_table = Self::parse_string_array(value),
381 "can_write_table" => caps.can_write_table = Self::parse_string_array(value),
382 "can_vector_search" => caps.can_vector_search = value == "true",
383 "can_index_search" => caps.can_index_search = value == "true",
384 "can_call_plugin" => caps.can_call_plugin = Self::parse_string_array(value),
385 _ => {}
386 }
387 }
388
389 fn parse_resources_field(res: &mut ResourceLimits, key: &str, value: &str) {
390 match key {
391 "memory_limit_mb" => res.memory_limit_mb = value.parse().unwrap_or(16),
392 "fuel_limit" => res.fuel_limit = value.parse().unwrap_or(1_000_000),
393 "timeout_ms" => res.timeout_ms = value.parse().unwrap_or(100),
394 "max_instances" => res.max_instances = value.parse().unwrap_or(4),
395 _ => {}
396 }
397 }
398
399 fn parse_exports_field(exports: &mut ExportedFunctions, key: &str, value: &str) {
400 if key == "functions" {
401 exports.functions = Self::parse_string_array(value);
402 }
403 }
404
405 fn parse_hooks_field(hooks: &mut TableHooks, key: &str, value: &str) {
406 let funcs = Self::parse_string_array(value);
407 match key {
408 "before_insert" => hooks.before_insert = funcs,
409 "after_insert" => hooks.after_insert = funcs,
410 "before_update" => hooks.before_update = funcs,
411 "after_update" => hooks.after_update = funcs,
412 "before_delete" => hooks.before_delete = funcs,
413 "after_delete" => hooks.after_delete = funcs,
414 _ => {}
415 }
416 }
417
418 fn parse_string_array(value: &str) -> Vec<String> {
419 let value = value.trim();
421 if value.starts_with('[') && value.ends_with(']') {
422 let inner = &value[1..value.len() - 1];
423 inner
424 .split(',')
425 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
426 .filter(|s| !s.is_empty())
427 .collect()
428 } else {
429 vec![value.to_string()]
430 }
431 }
432}
433
434impl Default for PluginManifest {
435 fn default() -> Self {
436 Self {
437 plugin: PluginMetadata {
438 name: String::new(),
439 version: String::new(),
440 description: String::new(),
441 author: String::new(),
442 license: None,
443 homepage: None,
444 repository: None,
445 min_kernel_version: None,
446 },
447 capabilities: ManifestCapabilities::default(),
448 resources: ResourceLimits::default(),
449 exports: ExportedFunctions::default(),
450 hooks: TableHooks::default(),
451 config_schema: None,
452 }
453 }
454}
455
456pub struct ManifestBuilder {
462 manifest: PluginManifest,
463}
464
465impl ManifestBuilder {
466 pub fn new(name: &str, version: &str) -> Self {
468 let mut manifest = PluginManifest::default();
469 manifest.plugin.name = name.to_string();
470 manifest.plugin.version = version.to_string();
471 Self { manifest }
472 }
473
474 pub fn description(mut self, desc: &str) -> Self {
476 self.manifest.plugin.description = desc.to_string();
477 self
478 }
479
480 pub fn author(mut self, author: &str) -> Self {
482 self.manifest.plugin.author = author.to_string();
483 self
484 }
485
486 pub fn license(mut self, license: &str) -> Self {
488 self.manifest.plugin.license = Some(license.to_string());
489 self
490 }
491
492 pub fn can_read(mut self, pattern: &str) -> Self {
494 self.manifest
495 .capabilities
496 .can_read_table
497 .push(pattern.to_string());
498 self
499 }
500
501 pub fn can_write(mut self, pattern: &str) -> Self {
503 self.manifest
504 .capabilities
505 .can_write_table
506 .push(pattern.to_string());
507 self
508 }
509
510 pub fn with_vector_search(mut self) -> Self {
512 self.manifest.capabilities.can_vector_search = true;
513 self
514 }
515
516 pub fn with_index_search(mut self) -> Self {
518 self.manifest.capabilities.can_index_search = true;
519 self
520 }
521
522 pub fn memory_limit_mb(mut self, mb: u64) -> Self {
524 self.manifest.resources.memory_limit_mb = mb;
525 self
526 }
527
528 pub fn fuel_limit(mut self, fuel: u64) -> Self {
530 self.manifest.resources.fuel_limit = fuel;
531 self
532 }
533
534 pub fn timeout_ms(mut self, ms: u64) -> Self {
536 self.manifest.resources.timeout_ms = ms;
537 self
538 }
539
540 pub fn export(mut self, func: &str) -> Self {
542 self.manifest.exports.functions.push(func.to_string());
543 self
544 }
545
546 pub fn before_insert(mut self, func: &str) -> Self {
548 self.manifest.hooks.before_insert.push(func.to_string());
549 self
550 }
551
552 pub fn after_insert(mut self, func: &str) -> Self {
554 self.manifest.hooks.after_insert.push(func.to_string());
555 self
556 }
557
558 pub fn build(self) -> KernelResult<PluginManifest> {
560 self.manifest.validate()?;
561 Ok(self.manifest)
562 }
563}
564
565impl PluginManifest {
570 pub fn to_toml(&self) -> String {
572 let mut out = String::new();
573
574 out.push_str("[plugin]\n");
576 out.push_str(&format!("name = \"{}\"\n", self.plugin.name));
577 out.push_str(&format!("version = \"{}\"\n", self.plugin.version));
578 if !self.plugin.description.is_empty() {
579 out.push_str(&format!("description = \"{}\"\n", self.plugin.description));
580 }
581 if !self.plugin.author.is_empty() {
582 out.push_str(&format!("author = \"{}\"\n", self.plugin.author));
583 }
584 if let Some(license) = &self.plugin.license {
585 out.push_str(&format!("license = \"{}\"\n", license));
586 }
587 out.push('\n');
588
589 out.push_str("[capabilities]\n");
591 if !self.capabilities.can_read_table.is_empty() {
592 out.push_str(&format!(
593 "can_read_table = {:?}\n",
594 self.capabilities.can_read_table
595 ));
596 }
597 if !self.capabilities.can_write_table.is_empty() {
598 out.push_str(&format!(
599 "can_write_table = {:?}\n",
600 self.capabilities.can_write_table
601 ));
602 }
603 out.push_str(&format!(
604 "can_vector_search = {}\n",
605 self.capabilities.can_vector_search
606 ));
607 out.push_str(&format!(
608 "can_index_search = {}\n",
609 self.capabilities.can_index_search
610 ));
611 out.push('\n');
612
613 out.push_str("[resources]\n");
615 out.push_str(&format!(
616 "memory_limit_mb = {}\n",
617 self.resources.memory_limit_mb
618 ));
619 out.push_str(&format!("fuel_limit = {}\n", self.resources.fuel_limit));
620 out.push_str(&format!("timeout_ms = {}\n", self.resources.timeout_ms));
621 out.push('\n');
622
623 if !self.exports.functions.is_empty() {
625 out.push_str("[exports]\n");
626 out.push_str(&format!("functions = {:?}\n", self.exports.functions));
627 out.push('\n');
628 }
629
630 if !self.hooks.before_insert.is_empty() || !self.hooks.after_insert.is_empty() {
632 out.push_str("[hooks]\n");
633 if !self.hooks.before_insert.is_empty() {
634 out.push_str(&format!("before_insert = {:?}\n", self.hooks.before_insert));
635 }
636 if !self.hooks.after_insert.is_empty() {
637 out.push_str(&format!("after_insert = {:?}\n", self.hooks.after_insert));
638 }
639 if !self.hooks.before_update.is_empty() {
640 out.push_str(&format!("before_update = {:?}\n", self.hooks.before_update));
641 }
642 if !self.hooks.after_update.is_empty() {
643 out.push_str(&format!("after_update = {:?}\n", self.hooks.after_update));
644 }
645 }
646
647 out
648 }
649}
650
651#[cfg(test)]
656mod tests {
657 use super::*;
658
659 const SAMPLE_MANIFEST: &str = r#"
660[plugin]
661name = "my-analytics-plugin"
662version = "1.0.0"
663description = "Analytics plugin for aggregation"
664author = "SochDB Team"
665license = "MIT"
666
667[capabilities]
668can_read_table = ["analytics_*", "metrics"]
669can_write_table = ["analytics_results"]
670can_vector_search = false
671can_index_search = true
672
673[resources]
674memory_limit_mb = 64
675fuel_limit = 10000000
676timeout_ms = 1000
677
678[exports]
679functions = ["on_insert", "aggregate"]
680
681[hooks]
682before_insert = []
683after_insert = ["on_insert"]
684"#;
685
686 #[test]
687 fn test_parse_manifest() {
688 let manifest = PluginManifest::from_toml(SAMPLE_MANIFEST).unwrap();
689
690 assert_eq!(manifest.plugin.name, "my-analytics-plugin");
691 assert_eq!(manifest.plugin.version, "1.0.0");
692 assert_eq!(manifest.plugin.author, "SochDB Team");
693 assert_eq!(manifest.plugin.license, Some("MIT".to_string()));
694
695 assert_eq!(
696 manifest.capabilities.can_read_table,
697 vec!["analytics_*", "metrics"]
698 );
699 assert_eq!(
700 manifest.capabilities.can_write_table,
701 vec!["analytics_results"]
702 );
703 assert!(!manifest.capabilities.can_vector_search);
704 assert!(manifest.capabilities.can_index_search);
705
706 assert_eq!(manifest.resources.memory_limit_mb, 64);
707 assert_eq!(manifest.resources.fuel_limit, 10_000_000);
708 assert_eq!(manifest.resources.timeout_ms, 1000);
709
710 assert!(
711 manifest
712 .exports
713 .functions
714 .contains(&"on_insert".to_string())
715 );
716 assert!(
717 manifest
718 .exports
719 .functions
720 .contains(&"aggregate".to_string())
721 );
722
723 assert!(
724 manifest
725 .hooks
726 .after_insert
727 .contains(&"on_insert".to_string())
728 );
729 }
730
731 #[test]
732 fn test_manifest_validation() {
733 let manifest = PluginManifest::default();
735 assert!(manifest.validate().is_err());
736
737 let mut manifest = PluginManifest::default();
739 manifest.plugin.name = "invalid name!".to_string();
740 manifest.plugin.version = "1.0.0".to_string();
741 assert!(manifest.validate().is_err());
742
743 let mut manifest = PluginManifest::default();
745 manifest.plugin.name = "valid-plugin".to_string();
746 manifest.plugin.version = "1.0.0".to_string();
747 assert!(manifest.validate().is_ok());
748 }
749
750 #[test]
751 fn test_manifest_builder() {
752 let manifest = ManifestBuilder::new("test-plugin", "1.0.0")
753 .description("A test plugin")
754 .author("Test Author")
755 .license("MIT")
756 .can_read("users")
757 .can_read("logs_*")
758 .can_write("results")
759 .with_vector_search()
760 .memory_limit_mb(32)
761 .fuel_limit(500_000)
762 .export("handler")
763 .build()
764 .unwrap();
765
766 assert_eq!(manifest.plugin.name, "test-plugin");
767 assert!(
768 manifest
769 .capabilities
770 .can_read_table
771 .contains(&"users".to_string())
772 );
773 assert!(
774 manifest
775 .capabilities
776 .can_read_table
777 .contains(&"logs_*".to_string())
778 );
779 assert!(manifest.capabilities.can_vector_search);
780 assert_eq!(manifest.resources.memory_limit_mb, 32);
781 }
782
783 #[test]
784 fn test_to_capabilities() {
785 let manifest = ManifestBuilder::new("test", "1.0.0")
786 .can_read("table1")
787 .memory_limit_mb(32)
788 .fuel_limit(500_000)
789 .timeout_ms(200)
790 .build()
791 .unwrap();
792
793 let caps = manifest.to_capabilities();
794
795 assert!(caps.can_read("table1"));
796 assert!(!caps.can_read("other"));
797 assert_eq!(caps.memory_limit_bytes, 32 * 1024 * 1024);
798 assert_eq!(caps.fuel_limit, 500_000);
799 assert_eq!(caps.timeout_ms, 200);
800 }
801
802 #[test]
803 fn test_to_toml() {
804 let manifest = ManifestBuilder::new("roundtrip-test", "2.0.0")
805 .description("Test roundtrip")
806 .author("Test")
807 .can_read("data")
808 .memory_limit_mb(16)
809 .export("init")
810 .build()
811 .unwrap();
812
813 let toml = manifest.to_toml();
814
815 let parsed = PluginManifest::from_toml(&toml).unwrap();
817
818 assert_eq!(parsed.plugin.name, "roundtrip-test");
819 assert_eq!(parsed.plugin.version, "2.0.0");
820 assert!(
821 parsed
822 .capabilities
823 .can_read_table
824 .contains(&"data".to_string())
825 );
826 }
827
828 #[test]
829 fn test_resource_limits_validation() {
830 let mut manifest = PluginManifest::default();
832 manifest.plugin.name = "test".to_string();
833 manifest.plugin.version = "1.0.0".to_string();
834 manifest.resources.memory_limit_mb = 2048;
835 assert!(manifest.validate().is_err());
836
837 let mut manifest = PluginManifest::default();
839 manifest.plugin.name = "test".to_string();
840 manifest.plugin.version = "1.0.0".to_string();
841 manifest.resources.timeout_ms = 120_000;
842 assert!(manifest.validate().is_err());
843 }
844
845 #[test]
846 fn test_hook_validation() {
847 let mut manifest = PluginManifest::default();
849 manifest.plugin.name = "test".to_string();
850 manifest.plugin.version = "1.0.0".to_string();
851 manifest
852 .hooks
853 .before_insert
854 .push("missing_function".to_string());
855 assert!(manifest.validate().is_err());
857
858 manifest
860 .exports
861 .functions
862 .push("missing_function".to_string());
863 assert!(manifest.validate().is_ok());
864 }
865}