Skip to main content

sochdb_kernel/
plugin_manifest.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Plugin Manifest Schema
19//!
20//! Defines the TOML-based manifest format for WASM plugins.
21//!
22//! ## Manifest Format
23//!
24//! ```toml
25//! [plugin]
26//! name = "my-analytics-plugin"
27//! version = "1.0.0"
28//! description = "Analytics plugin for aggregation"
29//! author = "SochDB Team"
30//! license = "MIT"
31//!
32//! [capabilities]
33//! can_read_table = ["analytics_*", "metrics"]
34//! can_write_table = ["analytics_results"]
35//! can_vector_search = false
36//! can_index_search = true
37//! can_call_plugin = ["logging-plugin"]
38//!
39//! [resources]
40//! memory_limit_mb = 64
41//! fuel_limit = 10000000
42//! timeout_ms = 1000
43//!
44//! [exports]
45//! functions = ["on_insert", "on_update", "aggregate"]
46//!
47//! [hooks]
48//! before_insert = ["validate_row"]
49//! after_insert = ["index_row", "emit_metric"]
50//! ```
51
52use crate::error::{KernelError, KernelResult};
53use crate::wasm_runtime::WasmPluginCapabilities;
54use std::collections::HashMap;
55use std::path::Path;
56
57// ============================================================================
58// Plugin Manifest
59// ============================================================================
60
61/// Plugin manifest defining metadata and capabilities
62#[derive(Debug, Clone)]
63pub struct PluginManifest {
64    /// Plugin metadata
65    pub plugin: PluginMetadata,
66    /// Granted capabilities
67    pub capabilities: ManifestCapabilities,
68    /// Resource limits
69    pub resources: ResourceLimits,
70    /// Exported functions
71    pub exports: ExportedFunctions,
72    /// Table hooks
73    pub hooks: TableHooks,
74    /// Optional configuration schema
75    pub config_schema: Option<ConfigSchema>,
76}
77
78/// Plugin metadata section
79#[derive(Debug, Clone)]
80pub struct PluginMetadata {
81    /// Plugin name (unique identifier)
82    pub name: String,
83    /// Semantic version
84    pub version: String,
85    /// Human-readable description
86    pub description: String,
87    /// Author name or organization
88    pub author: String,
89    /// License identifier (SPDX)
90    pub license: Option<String>,
91    /// Homepage URL
92    pub homepage: Option<String>,
93    /// Repository URL
94    pub repository: Option<String>,
95    /// Minimum SochDB kernel version required
96    pub min_kernel_version: Option<String>,
97}
98
99/// Capability declarations
100#[derive(Debug, Clone, Default)]
101pub struct ManifestCapabilities {
102    /// Tables the plugin can read (glob patterns)
103    pub can_read_table: Vec<String>,
104    /// Tables the plugin can write (glob patterns)
105    pub can_write_table: Vec<String>,
106    /// Can perform vector similarity search
107    pub can_vector_search: bool,
108    /// Can perform index lookups
109    pub can_index_search: bool,
110    /// Other plugins this plugin can call
111    pub can_call_plugin: Vec<String>,
112}
113
114/// Resource limits
115#[derive(Debug, Clone)]
116pub struct ResourceLimits {
117    /// Memory limit in megabytes
118    pub memory_limit_mb: u64,
119    /// Fuel limit (instruction count)
120    pub fuel_limit: u64,
121    /// Timeout in milliseconds
122    pub timeout_ms: u64,
123    /// Maximum concurrent instances
124    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/// Exported functions from the plugin
139#[derive(Debug, Clone, Default)]
140pub struct ExportedFunctions {
141    /// List of exported function names
142    pub functions: Vec<String>,
143    /// Function signatures (optional, for validation)
144    pub signatures: HashMap<String, FunctionSignature>,
145}
146
147/// Function signature
148#[derive(Debug, Clone)]
149pub struct FunctionSignature {
150    /// Parameter types
151    pub params: Vec<WasmType>,
152    /// Return types
153    pub returns: Vec<WasmType>,
154}
155
156/// WASM type for signature validation
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum WasmType {
159    I32,
160    I64,
161    F32,
162    F64,
163    ExternRef,
164}
165
166/// Table hook bindings
167#[derive(Debug, Clone, Default)]
168pub struct TableHooks {
169    /// Functions to call before INSERT
170    pub before_insert: Vec<String>,
171    /// Functions to call after INSERT
172    pub after_insert: Vec<String>,
173    /// Functions to call before UPDATE
174    pub before_update: Vec<String>,
175    /// Functions to call after UPDATE
176    pub after_update: Vec<String>,
177    /// Functions to call before DELETE
178    pub before_delete: Vec<String>,
179    /// Functions to call after DELETE
180    pub after_delete: Vec<String>,
181}
182
183/// Configuration schema for plugin settings
184#[derive(Debug, Clone, Default)]
185pub struct ConfigSchema {
186    /// Configuration fields
187    pub fields: Vec<ConfigField>,
188}
189
190/// A configuration field
191#[derive(Debug, Clone)]
192pub struct ConfigField {
193    /// Field name
194    pub name: String,
195    /// Field type
196    pub field_type: ConfigFieldType,
197    /// Whether the field is required
198    pub required: bool,
199    /// Default value (as string)
200    pub default: Option<String>,
201    /// Description
202    pub description: Option<String>,
203}
204
205/// Configuration field types
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum ConfigFieldType {
208    String,
209    Integer,
210    Float,
211    Boolean,
212    StringArray,
213}
214
215// ============================================================================
216// Manifest Parsing
217// ============================================================================
218
219impl PluginManifest {
220    /// Parse a manifest from TOML content
221    pub fn from_toml(content: &str) -> KernelResult<Self> {
222        // Simple TOML-like parser (in production, use the toml crate)
223        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            // Skip empty lines and comments
232            if line.is_empty() || line.starts_with('#') {
233                continue;
234            }
235
236            // Section headers
237            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            // Key-value pairs
251            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        // Validate required fields
270        manifest.validate()?;
271
272        Ok(manifest)
273    }
274
275    /// Parse from a file
276    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    /// Convert to WasmPluginCapabilities
284    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    /// Validate the manifest
298    pub fn validate(&self) -> KernelResult<()> {
299        // Name is required
300        if self.plugin.name.is_empty() {
301            return Err(KernelError::Plugin {
302                message: "plugin name is required".to_string(),
303            });
304        }
305
306        // Name must be valid identifier
307        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        // Version is required
319        if self.plugin.version.is_empty() {
320            return Err(KernelError::Plugin {
321                message: "plugin version is required".to_string(),
322            });
323        }
324
325        // Resource limits must be reasonable
326        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        // All hook functions must be in exports
339        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    /// Get all hook function names
352    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    // Parsing helpers
364    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        // Parse [a, b, c] format
420        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
456// ============================================================================
457// Manifest Builder
458// ============================================================================
459
460/// Builder for creating plugin manifests programmatically
461pub struct ManifestBuilder {
462    manifest: PluginManifest,
463}
464
465impl ManifestBuilder {
466    /// Create a new builder with required fields
467    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    /// Set description
475    pub fn description(mut self, desc: &str) -> Self {
476        self.manifest.plugin.description = desc.to_string();
477        self
478    }
479
480    /// Set author
481    pub fn author(mut self, author: &str) -> Self {
482        self.manifest.plugin.author = author.to_string();
483        self
484    }
485
486    /// Set license
487    pub fn license(mut self, license: &str) -> Self {
488        self.manifest.plugin.license = Some(license.to_string());
489        self
490    }
491
492    /// Add readable table pattern
493    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    /// Add writable table pattern
502    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    /// Enable vector search
511    pub fn with_vector_search(mut self) -> Self {
512        self.manifest.capabilities.can_vector_search = true;
513        self
514    }
515
516    /// Enable index search
517    pub fn with_index_search(mut self) -> Self {
518        self.manifest.capabilities.can_index_search = true;
519        self
520    }
521
522    /// Set memory limit
523    pub fn memory_limit_mb(mut self, mb: u64) -> Self {
524        self.manifest.resources.memory_limit_mb = mb;
525        self
526    }
527
528    /// Set fuel limit
529    pub fn fuel_limit(mut self, fuel: u64) -> Self {
530        self.manifest.resources.fuel_limit = fuel;
531        self
532    }
533
534    /// Set timeout
535    pub fn timeout_ms(mut self, ms: u64) -> Self {
536        self.manifest.resources.timeout_ms = ms;
537        self
538    }
539
540    /// Add exported function
541    pub fn export(mut self, func: &str) -> Self {
542        self.manifest.exports.functions.push(func.to_string());
543        self
544    }
545
546    /// Add before_insert hook
547    pub fn before_insert(mut self, func: &str) -> Self {
548        self.manifest.hooks.before_insert.push(func.to_string());
549        self
550    }
551
552    /// Add after_insert hook
553    pub fn after_insert(mut self, func: &str) -> Self {
554        self.manifest.hooks.after_insert.push(func.to_string());
555        self
556    }
557
558    /// Build the manifest
559    pub fn build(self) -> KernelResult<PluginManifest> {
560        self.manifest.validate()?;
561        Ok(self.manifest)
562    }
563}
564
565// ============================================================================
566// Manifest Serialization
567// ============================================================================
568
569impl PluginManifest {
570    /// Serialize to TOML format
571    pub fn to_toml(&self) -> String {
572        let mut out = String::new();
573
574        // [plugin]
575        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        // [capabilities]
590        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        // [resources]
614        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        // [exports]
624        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        // [hooks]
631        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// ============================================================================
652// Tests
653// ============================================================================
654
655#[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        // Missing name
734        let manifest = PluginManifest::default();
735        assert!(manifest.validate().is_err());
736
737        // Invalid name
738        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        // Valid minimal manifest
744        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        // Parse it back
816        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        // Memory too high
831        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        // Timeout too high
838        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        // Hook function not in exports
848        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        // This should fail because missing_function is not exported
856        assert!(manifest.validate().is_err());
857
858        // Add the function to exports - now should pass
859        manifest
860            .exports
861            .functions
862            .push("missing_function".to_string());
863        assert!(manifest.validate().is_ok());
864    }
865}