Skip to main content

sen_plugin_api/
lib.rs

1//! sen-plugin-api: Shared types for sen-rs plugin system
2//!
3//! This crate defines the protocol between host and guest (wasm plugin).
4//! Communication uses MessagePack serialization.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// API version for compatibility checking
10/// - v1: Initial version (command + args only)
11/// - v2: Added capabilities support
12pub const API_VERSION: u32 = 2;
13
14// ============================================================================
15// Capabilities Types
16// ============================================================================
17
18/// Plugin capability declarations
19///
20/// Plugins declare what system resources they need access to.
21/// The host will prompt users to grant these permissions before execution.
22///
23/// # Example
24///
25/// ```rust
26/// use sen_plugin_api::{Capabilities, PathPattern, StdioCapability};
27///
28/// let caps = Capabilities::default()
29///     .with_fs_read(vec![PathPattern::new("./data").recursive()])
30///     .with_fs_write(vec![PathPattern::new("./output")])
31///     .with_stdio(StdioCapability::stdout_stderr());
32/// ```
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Capabilities {
35    /// Filesystem read access paths
36    /// Relative paths resolved from CWD, supports ~ expansion
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub fs_read: Vec<PathPattern>,
39
40    /// Filesystem write access paths
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub fs_write: Vec<PathPattern>,
43
44    /// Environment variable access patterns
45    /// Supports glob: "MY_*", exact: "HOME"
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub env_read: Vec<String>,
48
49    /// Network access patterns (WASI Preview 2)
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub net: Vec<NetPattern>,
52
53    /// Standard I/O access
54    #[serde(default, skip_serializing_if = "StdioCapability::is_none")]
55    pub stdio: StdioCapability,
56}
57
58impl Capabilities {
59    /// Create empty capabilities (no permissions)
60    pub fn none() -> Self {
61        Self::default()
62    }
63
64    /// Check if no capabilities are requested
65    pub fn is_empty(&self) -> bool {
66        self.fs_read.is_empty()
67            && self.fs_write.is_empty()
68            && self.env_read.is_empty()
69            && self.net.is_empty()
70            && self.stdio.is_none()
71    }
72
73    /// Add filesystem read paths
74    pub fn with_fs_read(mut self, paths: Vec<PathPattern>) -> Self {
75        self.fs_read = paths;
76        self
77    }
78
79    /// Add filesystem write paths
80    pub fn with_fs_write(mut self, paths: Vec<PathPattern>) -> Self {
81        self.fs_write = paths;
82        self
83    }
84
85    /// Add environment variable patterns
86    pub fn with_env_read(mut self, patterns: Vec<String>) -> Self {
87        self.env_read = patterns;
88        self
89    }
90
91    /// Add network patterns
92    pub fn with_net(mut self, patterns: Vec<NetPattern>) -> Self {
93        self.net = patterns;
94        self
95    }
96
97    /// Set stdio capabilities
98    pub fn with_stdio(mut self, stdio: StdioCapability) -> Self {
99        self.stdio = stdio;
100        self
101    }
102
103    /// Check if `self` is a subset of `other` (all requested capabilities are granted)
104    pub fn is_subset_of(&self, other: &Capabilities) -> bool {
105        // Check fs_read
106        for path in &self.fs_read {
107            if !other.fs_read.iter().any(|p| p.contains(path)) {
108                return false;
109            }
110        }
111
112        // Check fs_write
113        for path in &self.fs_write {
114            if !other.fs_write.iter().any(|p| p.contains(path)) {
115                return false;
116            }
117        }
118
119        // Check env_read (simple string match for now)
120        for env in &self.env_read {
121            if !other.env_read.contains(env) {
122                return false;
123            }
124        }
125
126        // Check net
127        for net in &self.net {
128            if !other.net.iter().any(|n| n.contains(net)) {
129                return false;
130            }
131        }
132
133        // Check stdio
134        if self.stdio.stdin && !other.stdio.stdin {
135            return false;
136        }
137        if self.stdio.stdout && !other.stdio.stdout {
138            return false;
139        }
140        if self.stdio.stderr && !other.stdio.stderr {
141            return false;
142        }
143
144        true
145    }
146
147    /// Compute hash for change detection
148    ///
149    /// Uses blake3 for cross-process stability (unlike DefaultHasher which
150    /// may produce different hashes across processes/runs).
151    pub fn compute_hash(&self) -> String {
152        // Use MessagePack serialization for deterministic byte representation
153        match rmp_serde::to_vec(self) {
154            Ok(bytes) => {
155                let hash = blake3::hash(&bytes);
156                // Return first 16 hex chars for reasonable length
157                hash.to_hex()[..16].to_string()
158            }
159            Err(_) => {
160                // Fallback: empty capabilities hash
161                "0000000000000000".to_string()
162            }
163        }
164    }
165}
166
167/// Filesystem path pattern
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct PathPattern {
170    /// Path pattern (e.g., "./data", "/tmp", "~/.config/app")
171    pub pattern: String,
172
173    /// Allow recursive access to subdirectories
174    #[serde(default)]
175    pub recursive: bool,
176}
177
178impl PathPattern {
179    /// Create a new path pattern
180    pub fn new(pattern: impl Into<String>) -> Self {
181        Self {
182            pattern: pattern.into(),
183            recursive: false,
184        }
185    }
186
187    /// Enable recursive access
188    pub fn recursive(mut self) -> Self {
189        self.recursive = true;
190        self
191    }
192
193    /// Check if this pattern contains/covers another pattern
194    pub fn contains(&self, other: &PathPattern) -> bool {
195        if self.pattern == other.pattern {
196            // Same path: recursive covers non-recursive
197            return self.recursive || !other.recursive;
198        }
199
200        // If self is recursive, check if other is under self's path
201        if self.recursive {
202            let self_path = PathBuf::from(&self.pattern);
203            let other_path = PathBuf::from(&other.pattern);
204
205            // Normalize for comparison (handle ./foo vs foo)
206            let self_normalized = self_path.components().collect::<Vec<_>>();
207            let other_normalized = other_path.components().collect::<Vec<_>>();
208
209            if other_normalized.len() >= self_normalized.len() {
210                return other_normalized
211                    .iter()
212                    .take(self_normalized.len())
213                    .eq(self_normalized.iter());
214            }
215        }
216
217        false
218    }
219}
220
221/// Network access pattern
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct NetPattern {
224    /// Host pattern (e.g., "api.example.com", "*.github.com")
225    pub host: String,
226
227    /// Port (None = any port)
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub port: Option<u16>,
230
231    /// Protocol
232    #[serde(default)]
233    pub protocol: NetProtocol,
234}
235
236impl NetPattern {
237    /// Create HTTPS pattern
238    pub fn https(host: impl Into<String>) -> Self {
239        Self {
240            host: host.into(),
241            port: None,
242            protocol: NetProtocol::Https,
243        }
244    }
245
246    /// Create HTTPS pattern with specific port
247    pub fn https_port(host: impl Into<String>, port: u16) -> Self {
248        Self {
249            host: host.into(),
250            port: Some(port),
251            protocol: NetProtocol::Https,
252        }
253    }
254
255    /// Create TCP pattern
256    pub fn tcp(host: impl Into<String>, port: u16) -> Self {
257        Self {
258            host: host.into(),
259            port: Some(port),
260            protocol: NetProtocol::Tcp,
261        }
262    }
263
264    /// Check if this pattern contains/covers another pattern
265    pub fn contains(&self, other: &NetPattern) -> bool {
266        // Protocol must match
267        if self.protocol != other.protocol {
268            return false;
269        }
270
271        // Host matching (simple wildcard support)
272        let host_matches = if self.host.starts_with("*.") {
273            let suffix = &self.host[1..]; // ".github.com"
274            other.host.ends_with(suffix) || other.host == self.host[2..]
275        } else {
276            self.host == other.host
277        };
278
279        if !host_matches {
280            return false;
281        }
282
283        // Port matching (None means any)
284        match (self.port, other.port) {
285            (None, _) => true,
286            (Some(sp), Some(op)) => sp == op,
287            (Some(_), None) => false,
288        }
289    }
290}
291
292/// Network protocol
293#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295#[repr(u8)]
296pub enum NetProtocol {
297    #[default]
298    Https = 0,
299    Http = 1,
300    Tcp = 2,
301}
302
303/// Standard I/O capability flags
304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
305pub struct StdioCapability {
306    /// Access to stdin
307    #[serde(default)]
308    pub stdin: bool,
309
310    /// Access to stdout
311    #[serde(default)]
312    pub stdout: bool,
313
314    /// Access to stderr
315    #[serde(default)]
316    pub stderr: bool,
317}
318
319impl StdioCapability {
320    /// No stdio access
321    pub fn none() -> Self {
322        Self::default()
323    }
324
325    /// Check if no stdio is requested
326    pub fn is_none(&self) -> bool {
327        !self.stdin && !self.stdout && !self.stderr
328    }
329
330    /// Full stdio access
331    pub fn all() -> Self {
332        Self {
333            stdin: true,
334            stdout: true,
335            stderr: true,
336        }
337    }
338
339    /// stdout + stderr only (common for plugins that produce output)
340    pub fn stdout_stderr() -> Self {
341        Self {
342            stdin: false,
343            stdout: true,
344            stderr: true,
345        }
346    }
347
348    /// stdout only
349    pub fn stdout_only() -> Self {
350        Self {
351            stdin: false,
352            stdout: true,
353            stderr: false,
354        }
355    }
356}
357
358// ============================================================================
359// Command Specification Types
360// ============================================================================
361
362/// Command specification returned by plugin's `manifest()` function
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct CommandSpec {
365    /// Command name (used for routing, e.g., "hello" or "db:create")
366    pub name: String,
367
368    /// Short description for help text
369    pub about: String,
370
371    /// Plugin version (semver)
372    #[serde(default)]
373    pub version: Option<String>,
374
375    /// Plugin author
376    #[serde(default)]
377    pub author: Option<String>,
378
379    /// Argument specifications
380    #[serde(default)]
381    pub args: Vec<ArgSpec>,
382
383    /// Nested subcommands
384    #[serde(default)]
385    pub subcommands: Vec<CommandSpec>,
386}
387
388/// Argument specification
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ArgSpec {
391    /// Argument name (positional) or option name
392    pub name: String,
393
394    /// Long option name (e.g., "--output")
395    #[serde(default)]
396    pub long: Option<String>,
397
398    /// Short option name (e.g., "-o")
399    #[serde(default)]
400    pub short: Option<char>,
401
402    /// Whether this argument is required
403    #[serde(default)]
404    pub required: bool,
405
406    /// Help text for this argument
407    #[serde(default)]
408    pub help: String,
409
410    /// Value placeholder name (e.g., "FILE")
411    #[serde(default)]
412    pub value_name: Option<String>,
413
414    /// Default value if not provided
415    #[serde(default)]
416    pub default_value: Option<String>,
417
418    /// List of allowed values
419    #[serde(default)]
420    pub possible_values: Option<Vec<String>>,
421}
422
423/// Result of plugin execution
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub enum ExecuteResult {
426    /// Successful execution with output
427    Success(String),
428
429    /// Execution failed
430    Error(ExecuteError),
431
432    /// Request host to perform an effect (async I/O)
433    ///
434    /// The plugin yields control to the host, which performs the
435    /// requested operation and calls `plugin_resume` with the result.
436    Effect(Effect),
437}
438
439/// Error details from plugin execution
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct ExecuteError {
442    /// Exit code (1 = user error, 101 = system error)
443    pub code: u8,
444
445    /// Error message
446    pub message: String,
447}
448
449// =============================================================================
450// Effect System (Host-side async I/O)
451// =============================================================================
452
453/// Effect request from plugin to host
454///
455/// Plugins cannot perform I/O directly (sandboxed), so they request
456/// the host to perform operations on their behalf.
457///
458/// # Example Flow
459///
460/// ```text
461/// Plugin: execute(args) -> Effect::HttpGet { id: 1, url: "..." }
462/// Host:   performs HTTP GET asynchronously
463/// Host:   plugin_resume(1, EffectResult::Http { ... })
464/// Plugin: resume(1, result) -> Success("done")
465/// ```
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub enum Effect {
468    /// HTTP GET request
469    HttpGet {
470        /// Unique ID for this effect (used in resume)
471        id: u32,
472        /// URL to fetch
473        url: String,
474        /// Optional headers
475        #[serde(default, skip_serializing_if = "Vec::is_empty")]
476        headers: Vec<(String, String)>,
477    },
478
479    /// HTTP POST request
480    HttpPost {
481        /// Unique ID for this effect
482        id: u32,
483        /// URL to post to
484        url: String,
485        /// Request body
486        body: String,
487        /// Content type (default: application/json)
488        #[serde(default, skip_serializing_if = "Option::is_none")]
489        content_type: Option<String>,
490        /// Optional headers
491        #[serde(default, skip_serializing_if = "Vec::is_empty")]
492        headers: Vec<(String, String)>,
493    },
494
495    /// Sleep/delay
496    Sleep {
497        /// Unique ID for this effect
498        id: u32,
499        /// Duration in milliseconds
500        duration_ms: u64,
501    },
502}
503
504impl Effect {
505    /// Get the effect ID
506    pub fn id(&self) -> u32 {
507        match self {
508            Effect::HttpGet { id, .. } => *id,
509            Effect::HttpPost { id, .. } => *id,
510            Effect::Sleep { id, .. } => *id,
511        }
512    }
513
514    /// Create an HTTP GET effect
515    pub fn http_get(id: u32, url: impl Into<String>) -> Self {
516        Effect::HttpGet {
517            id,
518            url: url.into(),
519            headers: vec![],
520        }
521    }
522
523    /// Create an HTTP POST effect
524    pub fn http_post(id: u32, url: impl Into<String>, body: impl Into<String>) -> Self {
525        Effect::HttpPost {
526            id,
527            url: url.into(),
528            body: body.into(),
529            content_type: None,
530            headers: vec![],
531        }
532    }
533
534    /// Create a sleep effect
535    pub fn sleep(id: u32, duration_ms: u64) -> Self {
536        Effect::Sleep { id, duration_ms }
537    }
538}
539
540/// Result of an effect, passed back to plugin via resume
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub enum EffectResult {
543    /// HTTP response
544    Http(HttpResponse),
545
546    /// Sleep completed
547    SleepComplete,
548
549    /// Effect failed
550    Error(String),
551}
552
553/// HTTP response data
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct HttpResponse {
556    /// HTTP status code
557    pub status: u16,
558
559    /// Response body as string
560    pub body: String,
561
562    /// Response headers
563    #[serde(default, skip_serializing_if = "Vec::is_empty")]
564    pub headers: Vec<(String, String)>,
565}
566
567impl HttpResponse {
568    /// Check if status is successful (2xx)
569    pub fn is_success(&self) -> bool {
570        (200..300).contains(&self.status)
571    }
572}
573
574impl ExecuteResult {
575    /// Create an HTTP GET effect
576    pub fn http_get(id: u32, url: impl Into<String>) -> Self {
577        ExecuteResult::Effect(Effect::http_get(id, url))
578    }
579
580    /// Create an HTTP POST effect
581    pub fn http_post(id: u32, url: impl Into<String>, body: impl Into<String>) -> Self {
582        ExecuteResult::Effect(Effect::http_post(id, url, body))
583    }
584
585    /// Create a sleep effect
586    pub fn sleep(id: u32, duration_ms: u64) -> Self {
587        ExecuteResult::Effect(Effect::sleep(id, duration_ms))
588    }
589}
590
591/// Plugin manifest with API version
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct PluginManifest {
594    /// API version for compatibility
595    pub api_version: u32,
596
597    /// Command specification
598    pub command: CommandSpec,
599
600    /// Capability requirements (v2+)
601    #[serde(default, skip_serializing_if = "Capabilities::is_empty")]
602    pub capabilities: Capabilities,
603}
604
605impl PluginManifest {
606    /// Create a new plugin manifest with current API version
607    pub fn new(command: CommandSpec) -> Self {
608        Self {
609            api_version: API_VERSION,
610            command,
611            capabilities: Capabilities::default(),
612        }
613    }
614
615    /// Create a new plugin manifest with capabilities
616    pub fn with_capabilities(command: CommandSpec, capabilities: Capabilities) -> Self {
617        Self {
618            api_version: API_VERSION,
619            command,
620            capabilities,
621        }
622    }
623
624    /// Add capabilities to an existing manifest
625    pub fn capabilities(mut self, caps: Capabilities) -> Self {
626        self.capabilities = caps;
627        self
628    }
629}
630
631impl ExecuteResult {
632    /// Create a success result
633    pub fn success(output: impl Into<String>) -> Self {
634        Self::Success(output.into())
635    }
636
637    /// Create a user error (exit code 1)
638    pub fn user_error(message: impl Into<String>) -> Self {
639        Self::Error(ExecuteError {
640            code: 1,
641            message: message.into(),
642        })
643    }
644
645    /// Create a system error (exit code 101)
646    pub fn system_error(message: impl Into<String>) -> Self {
647        Self::Error(ExecuteError {
648            code: 101,
649            message: message.into(),
650        })
651    }
652}
653
654impl CommandSpec {
655    /// Create a new command spec
656    pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
657        Self {
658            name: name.into(),
659            about: about.into(),
660            version: None,
661            author: None,
662            args: Vec::new(),
663            subcommands: Vec::new(),
664        }
665    }
666
667    /// Add version
668    pub fn version(mut self, version: impl Into<String>) -> Self {
669        self.version = Some(version.into());
670        self
671    }
672
673    /// Add an argument
674    pub fn arg(mut self, arg: ArgSpec) -> Self {
675        self.args.push(arg);
676        self
677    }
678
679    /// Add a subcommand
680    pub fn subcommand(mut self, cmd: CommandSpec) -> Self {
681        self.subcommands.push(cmd);
682        self
683    }
684}
685
686impl ArgSpec {
687    /// Create a positional argument
688    pub fn positional(name: impl Into<String>) -> Self {
689        Self {
690            name: name.into(),
691            long: None,
692            short: None,
693            required: false,
694            help: String::new(),
695            value_name: None,
696            default_value: None,
697            possible_values: None,
698        }
699    }
700
701    /// Create an option with long name
702    pub fn option(name: impl Into<String>, long: impl Into<String>) -> Self {
703        Self {
704            name: name.into(),
705            long: Some(long.into()),
706            short: None,
707            required: false,
708            help: String::new(),
709            value_name: None,
710            default_value: None,
711            possible_values: None,
712        }
713    }
714
715    /// Set as required
716    pub fn required(mut self) -> Self {
717        self.required = true;
718        self
719    }
720
721    /// Set help text
722    pub fn help(mut self, help: impl Into<String>) -> Self {
723        self.help = help.into();
724        self
725    }
726
727    /// Set short option
728    pub fn short(mut self, short: char) -> Self {
729        self.short = Some(short);
730        self
731    }
732
733    /// Set default value
734    pub fn default(mut self, value: impl Into<String>) -> Self {
735        self.default_value = Some(value.into());
736        self
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn test_command_spec_serialization() {
746        let spec = CommandSpec::new("hello", "Says hello")
747            .version("1.0.0")
748            .arg(
749                ArgSpec::positional("name")
750                    .help("Name to greet")
751                    .default("World"),
752            );
753
754        let bytes = rmp_serde::to_vec(&spec).unwrap();
755        let decoded: CommandSpec = rmp_serde::from_slice(&bytes).unwrap();
756
757        assert_eq!(decoded.name, "hello");
758        assert_eq!(decoded.about, "Says hello");
759        assert_eq!(decoded.args.len(), 1);
760    }
761
762    #[test]
763    fn test_execute_result_serialization() {
764        let result = ExecuteResult::success("Hello, World!");
765        let bytes = rmp_serde::to_vec(&result).unwrap();
766        let decoded: ExecuteResult = rmp_serde::from_slice(&bytes).unwrap();
767
768        match decoded {
769            ExecuteResult::Success(s) => assert_eq!(s, "Hello, World!"),
770            _ => panic!("Expected success"),
771        }
772    }
773
774    // ========================================================================
775    // Capabilities Tests
776    // ========================================================================
777
778    #[test]
779    fn test_capabilities_empty() {
780        let caps = Capabilities::none();
781        assert!(caps.is_empty());
782
783        let caps_with_fs = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
784        assert!(!caps_with_fs.is_empty());
785    }
786
787    #[test]
788    fn test_capabilities_serialization() {
789        let caps = Capabilities::default()
790            .with_fs_read(vec![PathPattern::new("./data").recursive()])
791            .with_fs_write(vec![PathPattern::new("./output")])
792            .with_env_read(vec!["HOME".into(), "PATH".into()])
793            .with_net(vec![NetPattern::https("api.example.com")])
794            .with_stdio(StdioCapability::stdout_stderr());
795
796        // Use named serialization for struct fields (consistent with existing codebase)
797        let bytes = rmp_serde::to_vec_named(&caps).unwrap();
798        let decoded: Capabilities = rmp_serde::from_slice(&bytes).unwrap();
799
800        assert_eq!(decoded.fs_read.len(), 1);
801        assert!(decoded.fs_read[0].recursive);
802        assert_eq!(decoded.fs_write.len(), 1);
803        assert_eq!(decoded.env_read.len(), 2);
804        assert_eq!(decoded.net.len(), 1);
805        assert!(decoded.stdio.stdout);
806        assert!(!decoded.stdio.stdin);
807    }
808
809    #[test]
810    fn test_capabilities_subset() {
811        let requested = Capabilities::default()
812            .with_fs_read(vec![PathPattern::new("./data")])
813            .with_stdio(StdioCapability::stdout_only());
814
815        let granted = Capabilities::default()
816            .with_fs_read(vec![PathPattern::new("./data").recursive()])
817            .with_fs_write(vec![PathPattern::new("./output")])
818            .with_stdio(StdioCapability::stdout_stderr());
819
820        assert!(requested.is_subset_of(&granted));
821
822        // Request more than granted
823        let over_requested =
824            Capabilities::default().with_fs_read(vec![PathPattern::new("./secret")]);
825
826        assert!(!over_requested.is_subset_of(&granted));
827    }
828
829    #[test]
830    fn test_path_pattern_contains() {
831        let parent = PathPattern::new("./data").recursive();
832        let child = PathPattern::new("./data/subdir");
833
834        assert!(parent.contains(&child));
835        assert!(!child.contains(&parent));
836
837        let same = PathPattern::new("./data");
838        assert!(parent.contains(&same));
839        assert!(!same.contains(&parent)); // non-recursive doesn't cover recursive request
840    }
841
842    #[test]
843    fn test_net_pattern_contains() {
844        let wildcard = NetPattern::https("*.github.com");
845        let specific = NetPattern::https("api.github.com");
846
847        assert!(wildcard.contains(&specific));
848        assert!(!specific.contains(&wildcard));
849
850        let with_port = NetPattern::https_port("api.example.com", 443);
851        let any_port = NetPattern::https("api.example.com");
852
853        assert!(any_port.contains(&with_port));
854        assert!(!with_port.contains(&any_port));
855    }
856
857    #[test]
858    fn test_manifest_with_capabilities() {
859        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
860
861        let manifest =
862            PluginManifest::with_capabilities(CommandSpec::new("data-export", "Export data"), caps);
863
864        assert_eq!(manifest.api_version, API_VERSION);
865        assert_eq!(manifest.capabilities.fs_read.len(), 1);
866
867        // Serialization roundtrip
868        let bytes = rmp_serde::to_vec(&manifest).unwrap();
869        let decoded: PluginManifest = rmp_serde::from_slice(&bytes).unwrap();
870
871        assert_eq!(decoded.capabilities.fs_read.len(), 1);
872    }
873
874    #[test]
875    fn test_capabilities_hash() {
876        let caps1 = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
877        let caps2 = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
878        let caps3 = Capabilities::default().with_fs_read(vec![PathPattern::new("./other")]);
879
880        assert_eq!(caps1.compute_hash(), caps2.compute_hash());
881        assert_ne!(caps1.compute_hash(), caps3.compute_hash());
882    }
883}