Skip to main content

chasm_cli/plugins/
mod.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Plugin System Module
4//!
5//! Provides an extensible plugin architecture for CSM:
6//! - Plugin discovery and loading
7//! - Plugin lifecycle management
8//! - Event hooks and callbacks
9//! - Configuration management
10//! - Sandboxed execution
11
12use anyhow::{anyhow, Result};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18use tokio::sync::RwLock;
19
20// =============================================================================
21// Plugin Manifest and Metadata
22// =============================================================================
23
24/// Plugin manifest (plugin.json)
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PluginManifest {
27    /// Plugin unique identifier
28    pub id: String,
29    /// Plugin name
30    pub name: String,
31    /// Plugin version (semver)
32    pub version: String,
33    /// Plugin description
34    pub description: Option<String>,
35    /// Author information
36    pub author: Option<PluginAuthor>,
37    /// Plugin homepage URL
38    pub homepage: Option<String>,
39    /// Repository URL
40    pub repository: Option<String>,
41    /// License identifier
42    pub license: Option<String>,
43    /// Minimum CSM version required
44    pub csm_version: String,
45    /// Plugin entry point
46    pub main: String,
47    /// Required permissions
48    pub permissions: Vec<Permission>,
49    /// Event hooks this plugin registers
50    pub hooks: Vec<String>,
51    /// Configuration schema
52    pub config_schema: Option<serde_json::Value>,
53    /// Dependencies on other plugins
54    pub dependencies: Vec<PluginDependency>,
55    /// Plugin category
56    pub category: PluginCategory,
57    /// Keywords for discovery
58    pub keywords: Vec<String>,
59}
60
61/// Plugin author information
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PluginAuthor {
64    pub name: String,
65    pub email: Option<String>,
66    pub url: Option<String>,
67}
68
69/// Plugin dependency
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PluginDependency {
72    /// Dependency plugin ID
73    pub id: String,
74    /// Version requirement (semver range)
75    pub version: String,
76    /// Whether dependency is optional
77    pub optional: bool,
78}
79
80/// Plugin category
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum PluginCategory {
84    /// Provider integration
85    Provider,
86    /// Export format
87    Export,
88    /// Analysis/insights
89    Analysis,
90    /// UI enhancement
91    Ui,
92    /// Automation
93    Automation,
94    /// Storage backend
95    Storage,
96    /// Authentication
97    Auth,
98    /// Other
99    Other,
100}
101
102/// Plugin permission
103#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Permission {
106    /// Read session data
107    SessionRead,
108    /// Write/modify session data
109    SessionWrite,
110    /// Delete sessions
111    SessionDelete,
112    /// Read configuration
113    ConfigRead,
114    /// Write configuration
115    ConfigWrite,
116    /// Network access
117    Network,
118    /// File system access (within plugin directory)
119    FileSystem,
120    /// Execute shell commands (restricted)
121    Shell,
122    /// Access sensitive data (encryption keys, etc.)
123    Sensitive,
124    /// Background execution
125    Background,
126    /// System notifications
127    Notifications,
128}
129
130// =============================================================================
131// Plugin Instance and State
132// =============================================================================
133
134/// Plugin state
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum PluginState {
138    /// Plugin is loaded but not activated
139    Loaded,
140    /// Plugin is active and running
141    Active,
142    /// Plugin is disabled
143    Disabled,
144    /// Plugin encountered an error
145    Error,
146    /// Plugin is being updated
147    Updating,
148}
149
150/// Plugin instance
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct PluginInstance {
153    /// Plugin manifest
154    pub manifest: PluginManifest,
155    /// Current state
156    pub state: PluginState,
157    /// Installation path
158    pub path: PathBuf,
159    /// Installed at timestamp
160    pub installed_at: DateTime<Utc>,
161    /// Last activated timestamp
162    pub last_activated: Option<DateTime<Utc>>,
163    /// Plugin configuration
164    pub config: serde_json::Value,
165    /// Error message if in error state
166    pub error: Option<String>,
167    /// Usage statistics
168    pub stats: PluginStats,
169}
170
171/// Plugin usage statistics
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct PluginStats {
174    /// Number of times activated
175    pub activation_count: u64,
176    /// Total execution time (milliseconds)
177    pub total_execution_ms: u64,
178    /// Number of errors
179    pub error_count: u64,
180    /// Last error timestamp
181    pub last_error: Option<DateTime<Utc>>,
182}
183
184// =============================================================================
185// Plugin Events and Hooks
186// =============================================================================
187
188/// Event that plugins can hook into
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub enum PluginEvent {
191    /// Session created
192    SessionCreated { session_id: String },
193    /// Session updated
194    SessionUpdated { session_id: String },
195    /// Session deleted
196    SessionDeleted { session_id: String },
197    /// Session imported
198    SessionImported { session_id: String, provider: String },
199    /// Session exported
200    SessionExported { session_id: String, format: String },
201    /// Harvest completed
202    HarvestCompleted { session_count: usize, provider: String },
203    /// Sync completed
204    SyncCompleted { direction: String, changes: usize },
205    /// User action
206    UserAction { action: String, context: serde_json::Value },
207    /// Application startup
208    AppStartup,
209    /// Application shutdown
210    AppShutdown,
211    /// Configuration changed
212    ConfigChanged { key: String },
213    /// Custom event
214    Custom { name: String, data: serde_json::Value },
215}
216
217/// Hook registration
218#[derive(Debug, Clone)]
219pub struct HookRegistration {
220    /// Plugin ID
221    pub plugin_id: String,
222    /// Event type pattern
223    pub event_pattern: String,
224    /// Priority (lower = higher priority)
225    pub priority: i32,
226    /// Handler ID
227    pub handler_id: String,
228}
229
230/// Result of a hook invocation
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct HookResult {
233    /// Plugin ID
234    pub plugin_id: String,
235    /// Success status
236    pub success: bool,
237    /// Result data
238    pub data: Option<serde_json::Value>,
239    /// Error message if failed
240    pub error: Option<String>,
241    /// Execution time (ms)
242    pub execution_ms: u64,
243}
244
245// =============================================================================
246// Plugin API Context
247// =============================================================================
248
249/// API context provided to plugins
250pub struct PluginContext {
251    /// Plugin ID
252    pub plugin_id: String,
253    /// Granted permissions
254    pub permissions: Vec<Permission>,
255    /// Plugin data directory
256    pub data_dir: PathBuf,
257    /// Plugin configuration
258    pub config: serde_json::Value,
259}
260
261impl PluginContext {
262    /// Check if plugin has a permission
263    pub fn has_permission(&self, permission: &Permission) -> bool {
264        self.permissions.contains(permission)
265    }
266
267    /// Get plugin data path
268    pub fn get_data_path(&self, filename: &str) -> PathBuf {
269        self.data_dir.join(filename)
270    }
271
272    /// Read plugin data file
273    pub fn read_data(&self, filename: &str) -> Result<String> {
274        if !self.has_permission(&Permission::FileSystem) {
275            return Err(anyhow!("Permission denied: FileSystem"));
276        }
277        let path = self.get_data_path(filename);
278        Ok(std::fs::read_to_string(path)?)
279    }
280
281    /// Write plugin data file
282    pub fn write_data(&self, filename: &str, content: &str) -> Result<()> {
283        if !self.has_permission(&Permission::FileSystem) {
284            return Err(anyhow!("Permission denied: FileSystem"));
285        }
286        std::fs::create_dir_all(&self.data_dir)?;
287        let path = self.get_data_path(filename);
288        Ok(std::fs::write(path, content)?)
289    }
290}
291
292// =============================================================================
293// Plugin Manager
294// =============================================================================
295
296/// Plugin manager handles plugin lifecycle
297pub struct PluginManager {
298    /// Plugins directory
299    plugins_dir: PathBuf,
300    /// Loaded plugins
301    plugins: Arc<RwLock<HashMap<String, PluginInstance>>>,
302    /// Registered hooks
303    hooks: Arc<RwLock<Vec<HookRegistration>>>,
304    /// Plugin configurations
305    configs: Arc<RwLock<HashMap<String, serde_json::Value>>>,
306}
307
308impl PluginManager {
309    /// Create a new plugin manager
310    pub fn new(plugins_dir: PathBuf) -> Self {
311        Self {
312            plugins_dir,
313            plugins: Arc::new(RwLock::new(HashMap::new())),
314            hooks: Arc::new(RwLock::new(Vec::new())),
315            configs: Arc::new(RwLock::new(HashMap::new())),
316        }
317    }
318
319    /// Initialize the plugin manager
320    pub async fn init(&self) -> Result<()> {
321        std::fs::create_dir_all(&self.plugins_dir)?;
322        self.discover_plugins().await?;
323        Ok(())
324    }
325
326    /// Discover and load plugins
327    pub async fn discover_plugins(&self) -> Result<Vec<String>> {
328        let mut discovered = Vec::new();
329        
330        if !self.plugins_dir.exists() {
331            return Ok(discovered);
332        }
333        
334        for entry in std::fs::read_dir(&self.plugins_dir)? {
335            let entry = entry?;
336            let path = entry.path();
337            
338            if path.is_dir() {
339                let manifest_path = path.join("plugin.json");
340                if manifest_path.exists() {
341                    match self.load_plugin(&path).await {
342                        Ok(plugin_id) => discovered.push(plugin_id),
343                        Err(e) => {
344                            log::warn!("Failed to load plugin at {:?}: {}", path, e);
345                        }
346                    }
347                }
348            }
349        }
350        
351        Ok(discovered)
352    }
353
354    /// Load a plugin from a directory
355    pub async fn load_plugin(&self, plugin_path: &PathBuf) -> Result<String> {
356        let manifest_path = plugin_path.join("plugin.json");
357        let manifest_content = std::fs::read_to_string(&manifest_path)?;
358        let manifest: PluginManifest = serde_json::from_str(&manifest_content)?;
359        
360        // Validate manifest
361        self.validate_manifest(&manifest)?;
362        
363        // Check dependencies
364        self.check_dependencies(&manifest).await?;
365        
366        let instance = PluginInstance {
367            manifest: manifest.clone(),
368            state: PluginState::Loaded,
369            path: plugin_path.clone(),
370            installed_at: Utc::now(),
371            last_activated: None,
372            config: serde_json::Value::Object(serde_json::Map::new()),
373            error: None,
374            stats: PluginStats::default(),
375        };
376        
377        let plugin_id = manifest.id.clone();
378        self.plugins.write().await.insert(plugin_id.clone(), instance);
379        
380        log::info!("Loaded plugin: {} v{}", manifest.name, manifest.version);
381        Ok(plugin_id)
382    }
383
384    /// Validate plugin manifest
385    fn validate_manifest(&self, manifest: &PluginManifest) -> Result<()> {
386        // Validate ID format
387        if manifest.id.is_empty() || manifest.id.len() > 64 {
388            return Err(anyhow!("Invalid plugin ID"));
389        }
390        
391        // Validate version (semver)
392        if semver::Version::parse(&manifest.version).is_err() {
393            return Err(anyhow!("Invalid version format: {}", manifest.version));
394        }
395        
396        // Validate CSM version requirement
397        let current_version = env!("CARGO_PKG_VERSION");
398        let req = semver::VersionReq::parse(&manifest.csm_version)
399            .map_err(|_| anyhow!("Invalid csm_version: {}", manifest.csm_version))?;
400        let current = semver::Version::parse(current_version)?;
401        
402        if !req.matches(&current) {
403            return Err(anyhow!(
404                "Plugin requires CSM {}, but current version is {}",
405                manifest.csm_version,
406                current_version
407            ));
408        }
409        
410        Ok(())
411    }
412
413    /// Check plugin dependencies
414    async fn check_dependencies(&self, manifest: &PluginManifest) -> Result<()> {
415        let plugins = self.plugins.read().await;
416        
417        for dep in &manifest.dependencies {
418            if dep.optional {
419                continue;
420            }
421            
422            let plugin = plugins.get(&dep.id);
423            match plugin {
424                None => {
425                    return Err(anyhow!("Missing required dependency: {}", dep.id));
426                }
427                Some(p) => {
428                    let req = semver::VersionReq::parse(&dep.version)?;
429                    let ver = semver::Version::parse(&p.manifest.version)?;
430                    if !req.matches(&ver) {
431                        return Err(anyhow!(
432                            "Dependency {} version {} does not match requirement {}",
433                            dep.id, p.manifest.version, dep.version
434                        ));
435                    }
436                }
437            }
438        }
439        
440        Ok(())
441    }
442
443    /// Activate a plugin
444    pub async fn activate(&self, plugin_id: &str) -> Result<()> {
445        let mut plugins = self.plugins.write().await;
446        let plugin = plugins.get_mut(plugin_id)
447            .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
448        
449        if plugin.state == PluginState::Active {
450            return Ok(());
451        }
452        
453        // Register hooks
454        for hook in &plugin.manifest.hooks {
455            self.register_hook(plugin_id, hook, 0).await?;
456        }
457        
458        plugin.state = PluginState::Active;
459        plugin.last_activated = Some(Utc::now());
460        plugin.stats.activation_count += 1;
461        
462        log::info!("Activated plugin: {}", plugin_id);
463        Ok(())
464    }
465
466    /// Deactivate a plugin
467    pub async fn deactivate(&self, plugin_id: &str) -> Result<()> {
468        let mut plugins = self.plugins.write().await;
469        let plugin = plugins.get_mut(plugin_id)
470            .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
471        
472        // Unregister hooks
473        self.unregister_hooks(plugin_id).await?;
474        
475        plugin.state = PluginState::Disabled;
476        
477        log::info!("Deactivated plugin: {}", plugin_id);
478        Ok(())
479    }
480
481    /// Uninstall a plugin
482    pub async fn uninstall(&self, plugin_id: &str) -> Result<()> {
483        // Deactivate first
484        self.deactivate(plugin_id).await.ok();
485        
486        let mut plugins = self.plugins.write().await;
487        let plugin = plugins.remove(plugin_id)
488            .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
489        
490        // Remove plugin directory
491        if plugin.path.exists() {
492            std::fs::remove_dir_all(&plugin.path)?;
493        }
494        
495        log::info!("Uninstalled plugin: {}", plugin_id);
496        Ok(())
497    }
498
499    /// Register a hook
500    async fn register_hook(&self, plugin_id: &str, event_pattern: &str, priority: i32) -> Result<()> {
501        let mut hooks = self.hooks.write().await;
502        hooks.push(HookRegistration {
503            plugin_id: plugin_id.to_string(),
504            event_pattern: event_pattern.to_string(),
505            priority,
506            handler_id: uuid::Uuid::new_v4().to_string(),
507        });
508        Ok(())
509    }
510
511    /// Unregister all hooks for a plugin
512    async fn unregister_hooks(&self, plugin_id: &str) -> Result<()> {
513        let mut hooks = self.hooks.write().await;
514        hooks.retain(|h| h.plugin_id != plugin_id);
515        Ok(())
516    }
517
518    /// Emit an event to all registered hooks
519    pub async fn emit(&self, event: PluginEvent) -> Vec<HookResult> {
520        let hooks = self.hooks.read().await;
521        let plugins = self.plugins.read().await;
522        let mut results = Vec::new();
523        
524        let event_name = self.get_event_name(&event);
525        
526        // Sort hooks by priority
527        let mut matching_hooks: Vec<_> = hooks.iter()
528            .filter(|h| self.matches_pattern(&h.event_pattern, &event_name))
529            .collect();
530        matching_hooks.sort_by_key(|h| h.priority);
531        
532        for hook in matching_hooks {
533            let plugin = match plugins.get(&hook.plugin_id) {
534                Some(p) if p.state == PluginState::Active => p,
535                _ => continue,
536            };
537            
538            let start = std::time::Instant::now();
539            
540            // In a real implementation, this would call the plugin's handler
541            // For now, we just record the invocation
542            let result = HookResult {
543                plugin_id: hook.plugin_id.clone(),
544                success: true,
545                data: Some(serde_json::json!({
546                    "event": event_name,
547                    "handled": true
548                })),
549                error: None,
550                execution_ms: start.elapsed().as_millis() as u64,
551            };
552            
553            results.push(result);
554        }
555        
556        results
557    }
558
559    fn get_event_name(&self, event: &PluginEvent) -> String {
560        match event {
561            PluginEvent::SessionCreated { .. } => "session.created".to_string(),
562            PluginEvent::SessionUpdated { .. } => "session.updated".to_string(),
563            PluginEvent::SessionDeleted { .. } => "session.deleted".to_string(),
564            PluginEvent::SessionImported { .. } => "session.imported".to_string(),
565            PluginEvent::SessionExported { .. } => "session.exported".to_string(),
566            PluginEvent::HarvestCompleted { .. } => "harvest.completed".to_string(),
567            PluginEvent::SyncCompleted { .. } => "sync.completed".to_string(),
568            PluginEvent::UserAction { action, .. } => format!("user.{}", action),
569            PluginEvent::AppStartup => "app.startup".to_string(),
570            PluginEvent::AppShutdown => "app.shutdown".to_string(),
571            PluginEvent::ConfigChanged { .. } => "config.changed".to_string(),
572            PluginEvent::Custom { name, .. } => format!("custom.{}", name),
573        }
574    }
575
576    fn matches_pattern(&self, pattern: &str, event_name: &str) -> bool {
577        if pattern == "*" {
578            return true;
579        }
580        if pattern.ends_with("*") {
581            let prefix = &pattern[..pattern.len() - 1];
582            return event_name.starts_with(prefix);
583        }
584        pattern == event_name
585    }
586
587    /// Get plugin instance
588    pub async fn get_plugin(&self, plugin_id: &str) -> Option<PluginInstance> {
589        self.plugins.read().await.get(plugin_id).cloned()
590    }
591
592    /// List all plugins
593    pub async fn list_plugins(&self) -> Vec<PluginInstance> {
594        self.plugins.read().await.values().cloned().collect()
595    }
596
597    /// Get plugin configuration
598    pub async fn get_config(&self, plugin_id: &str) -> Option<serde_json::Value> {
599        self.plugins.read().await
600            .get(plugin_id)
601            .map(|p| p.config.clone())
602    }
603
604    /// Set plugin configuration
605    pub async fn set_config(&self, plugin_id: &str, config: serde_json::Value) -> Result<()> {
606        let mut plugins = self.plugins.write().await;
607        let plugin = plugins.get_mut(plugin_id)
608            .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
609        
610        // Validate against schema if present
611        if let Some(schema) = &plugin.manifest.config_schema {
612            self.validate_config(&config, schema)?;
613        }
614        
615        plugin.config = config;
616        Ok(())
617    }
618
619    fn validate_config(&self, _config: &serde_json::Value, _schema: &serde_json::Value) -> Result<()> {
620        // In a real implementation, use JSON Schema validation
621        Ok(())
622    }
623
624    /// Create plugin context
625    pub async fn create_context(&self, plugin_id: &str) -> Result<PluginContext> {
626        let plugins = self.plugins.read().await;
627        let plugin = plugins.get(plugin_id)
628            .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
629        
630        Ok(PluginContext {
631            plugin_id: plugin_id.to_string(),
632            permissions: plugin.manifest.permissions.clone(),
633            data_dir: plugin.path.join("data"),
634            config: plugin.config.clone(),
635        })
636    }
637}
638
639// =============================================================================
640// Plugin Registry (for discovery/installation)
641// =============================================================================
642
643/// Plugin registry entry
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct RegistryEntry {
646    /// Plugin ID
647    pub id: String,
648    /// Latest version
649    pub version: String,
650    /// Plugin name
651    pub name: String,
652    /// Description
653    pub description: String,
654    /// Author
655    pub author: String,
656    /// Download URL
657    pub download_url: String,
658    /// Download count
659    pub downloads: u64,
660    /// Rating (0-5)
661    pub rating: f32,
662    /// Category
663    pub category: PluginCategory,
664    /// Keywords
665    pub keywords: Vec<String>,
666    /// Updated at
667    pub updated_at: DateTime<Utc>,
668}
669
670/// Plugin registry client
671pub struct PluginRegistry {
672    /// Registry URL
673    registry_url: String,
674}
675
676impl PluginRegistry {
677    pub fn new(registry_url: String) -> Self {
678        Self { registry_url }
679    }
680
681    /// Search for plugins
682    pub async fn search(&self, query: &str, category: Option<PluginCategory>) -> Result<Vec<RegistryEntry>> {
683        // In a real implementation, this would make an HTTP request
684        // For now, return empty results
685        Ok(Vec::new())
686    }
687
688    /// Get plugin details
689    pub async fn get_plugin(&self, plugin_id: &str) -> Result<RegistryEntry> {
690        Err(anyhow!("Plugin not found in registry: {}", plugin_id))
691    }
692
693    /// Download and install plugin
694    pub async fn install(&self, plugin_id: &str, manager: &PluginManager) -> Result<()> {
695        let entry = self.get_plugin(plugin_id).await?;
696        
697        // Download plugin
698        let plugin_dir = manager.plugins_dir.join(plugin_id);
699        std::fs::create_dir_all(&plugin_dir)?;
700        
701        // In a real implementation, download and extract the plugin
702        // For now, just create the directory
703        
704        manager.load_plugin(&plugin_dir).await?;
705        Ok(())
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use tempfile::tempdir;
713
714    #[tokio::test]
715    async fn test_plugin_manager_init() {
716        let temp_dir = tempdir().unwrap();
717        let manager = PluginManager::new(temp_dir.path().to_path_buf());
718        
719        assert!(manager.init().await.is_ok());
720    }
721
722    #[test]
723    fn test_event_name() {
724        let manager = PluginManager::new(PathBuf::from("."));
725        
726        let event = PluginEvent::SessionCreated { session_id: "test".to_string() };
727        assert_eq!(manager.get_event_name(&event), "session.created");
728    }
729
730    #[test]
731    fn test_pattern_matching() {
732        let manager = PluginManager::new(PathBuf::from("."));
733        
734        assert!(manager.matches_pattern("*", "session.created"));
735        assert!(manager.matches_pattern("session.*", "session.created"));
736        assert!(manager.matches_pattern("session.created", "session.created"));
737        assert!(!manager.matches_pattern("session.updated", "session.created"));
738    }
739}