gestura_core_scripting/
lib.rs

1//! Multi-language scripting engine for Gestura automation.
2//!
3//! `gestura-core-scripting` provides a runtime-managed scripting surface for
4//! loading, validating, and executing user scripts across multiple languages.
5//! It is designed for automation and extensibility while keeping execution
6//! metadata and requested permissions explicit.
7//!
8//! ## Supported languages
9//!
10//! - Lua
11//! - Python
12//! - JavaScript
13//!
14//! ## Main concepts
15//!
16//! - `Script`: script metadata, source, permissions, triggers, and execution stats
17//! - `ScriptPermission`: requested capabilities such as filesystem or network access
18//! - `ScriptTrigger`: declarative activation sources such as voice, gestures, or schedules
19//! - `ScriptContext`: runtime execution input including variables, session, and timeout
20//! - `ScriptingEngine`: loader, validator, runtime initializer, and executor
21//!
22//! ## Architecture role
23//!
24//! This crate owns the scripting-domain runtime and metadata model. It does not
25//! itself decide policy for *when* scripts should run in the broader product;
26//! higher-level orchestration remains in `gestura-core` and the presentation
27//! layers.
28//!
29//! ## Example
30//!
31//! ```rust,ignore
32//! use gestura_core::scripting::{ScriptingEngine, ScriptContext, ScriptPermission};
33//!
34//! let engine = ScriptingEngine::new(script_directory);
35//! engine.initialize().await?;
36//!
37//! let script_id = engine.load_script(&script_path).await?;
38//! let result = engine.execute_script(&script_id, context).await?;
39//! ```
40//!
41//! ## Stable import paths
42//!
43//! Most application code should import through `gestura_core::scripting::*`.
44
45use gestura_core_foundation::error::AppError;
46use std::collections::HashMap;
47use std::path::PathBuf;
48use std::sync::Arc;
49use tokio::sync::RwLock;
50
51mod runtime;
52
53pub use runtime::{JavaScriptRuntime, LuaRuntime, PythonRuntime};
54
55/// Supported scripting languages
56#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
57pub enum ScriptLanguage {
58    Lua,
59    Python,
60    JavaScript,
61}
62
63/// Script metadata
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct Script {
66    pub id: String,
67    pub name: String,
68    pub description: String,
69    pub language: ScriptLanguage,
70    pub source_code: String,
71    pub entry_point: String,
72    pub author: String,
73    pub version: String,
74    pub permissions: Vec<ScriptPermission>,
75    pub triggers: Vec<ScriptTrigger>,
76    pub is_enabled: bool,
77    pub created_at: chrono::DateTime<chrono::Utc>,
78    pub last_modified: chrono::DateTime<chrono::Utc>,
79    pub execution_count: u32,
80    pub last_error: Option<String>,
81}
82
83/// Script permissions
84#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
85pub enum ScriptPermission {
86    FileSystem(String), // Path pattern
87    Network(String),    // Host pattern
88    SystemCommands,
89    VoiceControl,
90    GestureControl,
91    RingControl,
92    Notifications,
93    ClipboardAccess,
94    WindowManagement,
95    DatabaseAccess,
96}
97
98/// Script triggers
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
100pub enum ScriptTrigger {
101    VoiceCommand(String),
102    Gesture(String),
103    TimeSchedule(String), // Cron-like expression
104    ApplicationEvent(String),
105    FileSystemEvent(String),
106    NetworkEvent(String),
107    UserAction(String),
108    SystemEvent(String),
109}
110
111/// Script execution context
112#[derive(Debug, Clone)]
113pub struct ScriptContext {
114    pub script_id: String,
115    pub user_id: String,
116    pub session_id: String,
117    pub variables: HashMap<String, serde_json::Value>,
118    pub permissions: Vec<ScriptPermission>,
119    pub execution_timeout: std::time::Duration,
120}
121
122/// Script execution result
123#[derive(Debug, Clone, serde::Serialize)]
124pub struct ScriptExecutionResult {
125    pub script_id: String,
126    pub success: bool,
127    pub return_value: Option<serde_json::Value>,
128    pub error_message: Option<String>,
129    pub execution_time_ms: u64,
130    pub output: String,
131    pub warnings: Vec<String>,
132}
133
134/// Active script execution
135#[derive(Debug, Clone)]
136struct ScriptExecution {
137    #[allow(dead_code)]
138    execution_id: String,
139    #[allow(dead_code)]
140    script_id: String,
141    #[allow(dead_code)]
142    started_at: chrono::DateTime<chrono::Utc>,
143    #[allow(dead_code)]
144    context: ScriptContext,
145}
146
147/// Scripting engine
148///
149/// Manages script loading, validation, and execution across multiple
150/// scripting languages.
151pub struct ScriptingEngine {
152    scripts: Arc<RwLock<HashMap<String, Script>>>,
153    active_executions: Arc<RwLock<HashMap<String, ScriptExecution>>>,
154    lua_runtime: Arc<RwLock<Option<LuaRuntime>>>,
155    python_runtime: Arc<RwLock<Option<PythonRuntime>>>,
156    js_runtime: Arc<RwLock<Option<JavaScriptRuntime>>>,
157    #[allow(dead_code)]
158    script_directory: PathBuf,
159}
160
161impl ScriptingEngine {
162    /// Create a new scripting engine
163    pub fn new(script_directory: PathBuf) -> Self {
164        Self {
165            scripts: Arc::new(RwLock::new(HashMap::new())),
166            active_executions: Arc::new(RwLock::new(HashMap::new())),
167            lua_runtime: Arc::new(RwLock::new(None)),
168            python_runtime: Arc::new(RwLock::new(None)),
169            js_runtime: Arc::new(RwLock::new(None)),
170            script_directory,
171        }
172    }
173
174    /// Initialize scripting runtimes
175    pub async fn initialize(&self) -> Result<(), AppError> {
176        // Initialize Lua runtime
177        {
178            let mut lua_runtime = self.lua_runtime.write().await;
179            *lua_runtime = Some(LuaRuntime::new()?);
180        }
181
182        // Initialize Python runtime
183        {
184            let mut python_runtime = self.python_runtime.write().await;
185            *python_runtime = Some(PythonRuntime::new()?);
186        }
187
188        // Initialize JavaScript runtime
189        {
190            let mut js_runtime = self.js_runtime.write().await;
191            *js_runtime = Some(JavaScriptRuntime::new()?);
192        }
193
194        tracing::info!("Scripting engine initialized with Lua, Python, and JavaScript support");
195        Ok(())
196    }
197
198    /// Load script from file
199    pub async fn load_script(&self, script_path: &PathBuf) -> Result<String, AppError> {
200        let content = tokio::fs::read_to_string(script_path)
201            .await
202            .map_err(AppError::Io)?;
203
204        // Parse script metadata from comments
205        let metadata = self.parse_script_metadata(&content, script_path)?;
206        let script_id = metadata.id.clone();
207
208        // Validate script
209        self.validate_script(&metadata).await?;
210
211        // Store script
212        let mut scripts = self.scripts.write().await;
213        scripts.insert(script_id.clone(), metadata);
214
215        tracing::info!("Loaded script: {}", script_id);
216        Ok(script_id)
217    }
218
219    /// Parse script metadata from source code comments
220    fn parse_script_metadata(
221        &self,
222        content: &str,
223        script_path: &std::path::Path,
224    ) -> Result<Script, AppError> {
225        let language = self.detect_language(script_path)?;
226        let script_id = uuid::Uuid::new_v4().to_string();
227
228        // Extract metadata from comments (simplified parser)
229        let mut name = script_path
230            .file_stem()
231            .and_then(|s| s.to_str())
232            .unwrap_or("Unnamed Script")
233            .to_string();
234        let mut description = "No description".to_string();
235        let mut author = "Unknown".to_string();
236        let mut version = "1.0.0".to_string();
237        let mut permissions = Vec::new();
238        let mut triggers = Vec::new();
239
240        for line in content.lines() {
241            let line = line.trim();
242            if (line.starts_with("--") || line.starts_with("#") || line.starts_with("//"))
243                && let Some(meta) = line.split_once("@")
244            {
245                let (_, meta_content) = meta;
246                if let Some((key, value)) = meta_content.split_once(" ") {
247                    match key {
248                        "name" => name = value.to_string(),
249                        "description" => description = value.to_string(),
250                        "author" => author = value.to_string(),
251                        "version" => version = value.to_string(),
252                        "permission" => {
253                            if let Ok(perm) = self.parse_permission(value) {
254                                permissions.push(perm);
255                            }
256                        }
257                        "trigger" => {
258                            if let Ok(trigger) = self.parse_trigger(value) {
259                                triggers.push(trigger);
260                            }
261                        }
262                        _ => {}
263                    }
264                }
265            }
266        }
267
268        Ok(Script {
269            id: script_id,
270            name,
271            description,
272            language,
273            source_code: content.to_string(),
274            entry_point: "main".to_string(),
275            author,
276            version,
277            permissions,
278            triggers,
279            is_enabled: true,
280            created_at: chrono::Utc::now(),
281            last_modified: chrono::Utc::now(),
282            execution_count: 0,
283            last_error: None,
284        })
285    }
286
287    /// Detect script language from file extension
288    fn detect_language(&self, script_path: &std::path::Path) -> Result<ScriptLanguage, AppError> {
289        match script_path.extension().and_then(|ext| ext.to_str()) {
290            Some("lua") => Ok(ScriptLanguage::Lua),
291            Some("py") => Ok(ScriptLanguage::Python),
292            Some("js") => Ok(ScriptLanguage::JavaScript),
293            _ => Err(AppError::InvalidInput(
294                "Unsupported script language".to_string(),
295            )),
296        }
297    }
298
299    /// Parse permission from string
300    fn parse_permission(&self, perm_str: &str) -> Result<ScriptPermission, AppError> {
301        if let Some(stripped) = perm_str.strip_prefix("filesystem:") {
302            Ok(ScriptPermission::FileSystem(stripped.to_string()))
303        } else if let Some(stripped) = perm_str.strip_prefix("network:") {
304            Ok(ScriptPermission::Network(stripped.to_string()))
305        } else {
306            match perm_str {
307                "system_commands" => Ok(ScriptPermission::SystemCommands),
308                "voice_control" => Ok(ScriptPermission::VoiceControl),
309                "gesture_control" => Ok(ScriptPermission::GestureControl),
310                "ring_control" => Ok(ScriptPermission::RingControl),
311                "notifications" => Ok(ScriptPermission::Notifications),
312                "clipboard" => Ok(ScriptPermission::ClipboardAccess),
313                "window_management" => Ok(ScriptPermission::WindowManagement),
314                "database" => Ok(ScriptPermission::DatabaseAccess),
315                _ => Err(AppError::InvalidInput(format!(
316                    "Unknown permission: {}",
317                    perm_str
318                ))),
319            }
320        }
321    }
322
323    /// Parse trigger from string
324    fn parse_trigger(&self, trigger_str: &str) -> Result<ScriptTrigger, AppError> {
325        if let Some(stripped) = trigger_str.strip_prefix("voice:") {
326            Ok(ScriptTrigger::VoiceCommand(stripped.to_string()))
327        } else if let Some(stripped) = trigger_str.strip_prefix("gesture:") {
328            Ok(ScriptTrigger::Gesture(stripped.to_string()))
329        } else if let Some(stripped) = trigger_str.strip_prefix("schedule:") {
330            Ok(ScriptTrigger::TimeSchedule(stripped.to_string()))
331        } else if let Some(stripped) = trigger_str.strip_prefix("app:") {
332            Ok(ScriptTrigger::ApplicationEvent(stripped.to_string()))
333        } else {
334            Err(AppError::InvalidInput(format!(
335                "Unknown trigger: {}",
336                trigger_str
337            )))
338        }
339    }
340
341    /// Validate script before loading
342    async fn validate_script(&self, script: &Script) -> Result<(), AppError> {
343        // Check for dangerous permissions
344        for permission in &script.permissions {
345            match permission {
346                ScriptPermission::SystemCommands => {
347                    tracing::warn!("Script '{}' requests system command access", script.name);
348                }
349                ScriptPermission::FileSystem(path) if path.contains("..") => {
350                    return Err(AppError::PermissionDenied(
351                        "Invalid file system permission".to_string(),
352                    ));
353                }
354                _ => {}
355            }
356        }
357
358        // Basic syntax validation (simplified)
359        match script.language {
360            ScriptLanguage::Lua => {
361                if !script.source_code.contains("function") && !script.source_code.contains("=") {
362                    tracing::warn!("Lua script may have syntax issues");
363                }
364            }
365            ScriptLanguage::Python => {
366                if script.source_code.contains("import os")
367                    && !script
368                        .permissions
369                        .contains(&ScriptPermission::SystemCommands)
370                {
371                    return Err(AppError::PermissionDenied(
372                        "Script uses 'os' module without system permission".to_string(),
373                    ));
374                }
375            }
376            ScriptLanguage::JavaScript => {
377                if script.source_code.contains("require(")
378                    && !script
379                        .permissions
380                        .contains(&ScriptPermission::SystemCommands)
381                {
382                    tracing::warn!("JavaScript script uses require() without system permission");
383                }
384            }
385        }
386
387        Ok(())
388    }
389
390    /// Execute a script
391    pub async fn execute_script(
392        &self,
393        script_id: &str,
394        context: ScriptContext,
395    ) -> Result<ScriptExecutionResult, AppError> {
396        let start_time = std::time::Instant::now();
397        let execution_id = uuid::Uuid::new_v4().to_string();
398
399        // Get script
400        let scripts = self.scripts.read().await;
401        let script = scripts
402            .get(script_id)
403            .ok_or_else(|| AppError::NotFound(format!("Script not found: {}", script_id)))?
404            .clone();
405
406        if !script.is_enabled {
407            return Err(AppError::PermissionDenied("Script is disabled".to_string()));
408        }
409
410        // Check permissions
411        for required_perm in &script.permissions {
412            if !context.permissions.contains(required_perm) {
413                return Err(AppError::PermissionDenied(format!(
414                    "Missing permission: {:?}",
415                    required_perm
416                )));
417            }
418        }
419
420        // Track execution
421        let execution = ScriptExecution {
422            execution_id: execution_id.clone(),
423            script_id: script_id.to_string(),
424            started_at: chrono::Utc::now(),
425            context: context.clone(),
426        };
427
428        {
429            let mut active = self.active_executions.write().await;
430            active.insert(execution_id.clone(), execution);
431        }
432
433        drop(scripts);
434
435        // Execute based on language
436        let result = match script.language {
437            ScriptLanguage::Lua => self.execute_lua_script(&script).await,
438            ScriptLanguage::Python => self.execute_python_script(&script).await,
439            ScriptLanguage::JavaScript => self.execute_js_script(&script).await,
440        };
441
442        // Clean up execution tracking
443        {
444            let mut active = self.active_executions.write().await;
445            active.remove(&execution_id);
446        }
447
448        // Update script statistics
449        {
450            let mut scripts_mut = self.scripts.write().await;
451            if let Some(script_mut) = scripts_mut.get_mut(script_id) {
452                script_mut.execution_count += 1;
453                if let Err(ref error) = result {
454                    script_mut.last_error = Some(error.to_string());
455                }
456            }
457        }
458
459        let execution_time = start_time.elapsed().as_millis() as u64;
460
461        match result {
462            Ok((return_value, output, warnings)) => Ok(ScriptExecutionResult {
463                script_id: script_id.to_string(),
464                success: true,
465                return_value,
466                error_message: None,
467                execution_time_ms: execution_time,
468                output,
469                warnings,
470            }),
471            Err(error) => Ok(ScriptExecutionResult {
472                script_id: script_id.to_string(),
473                success: false,
474                return_value: None,
475                error_message: Some(error.to_string()),
476                execution_time_ms: execution_time,
477                output: String::new(),
478                warnings: Vec::new(),
479            }),
480        }
481    }
482
483    /// Execute Lua script
484    async fn execute_lua_script(
485        &self,
486        script: &Script,
487    ) -> Result<(Option<serde_json::Value>, String, Vec<String>), AppError> {
488        let lua_runtime = self.lua_runtime.read().await;
489        if let Some(runtime) = lua_runtime.as_ref() {
490            runtime.execute(&script.source_code).await
491        } else {
492            Err(AppError::NotFound(
493                "Lua runtime not initialized".to_string(),
494            ))
495        }
496    }
497
498    /// Execute Python script
499    async fn execute_python_script(
500        &self,
501        script: &Script,
502    ) -> Result<(Option<serde_json::Value>, String, Vec<String>), AppError> {
503        let python_runtime = self.python_runtime.read().await;
504        if let Some(runtime) = python_runtime.as_ref() {
505            runtime.execute(&script.source_code).await
506        } else {
507            Err(AppError::NotFound(
508                "Python runtime not initialized".to_string(),
509            ))
510        }
511    }
512
513    /// Execute JavaScript script
514    async fn execute_js_script(
515        &self,
516        script: &Script,
517    ) -> Result<(Option<serde_json::Value>, String, Vec<String>), AppError> {
518        let js_runtime = self.js_runtime.read().await;
519        if let Some(runtime) = js_runtime.as_ref() {
520            runtime.execute(&script.source_code).await
521        } else {
522            Err(AppError::NotFound(
523                "JavaScript runtime not initialized".to_string(),
524            ))
525        }
526    }
527
528    /// Get all scripts
529    pub async fn get_scripts(&self) -> Vec<Script> {
530        let scripts = self.scripts.read().await;
531        scripts.values().cloned().collect()
532    }
533
534    /// Get script statistics
535    pub async fn get_stats(&self) -> serde_json::Value {
536        let scripts = self.scripts.read().await;
537        let active = self.active_executions.read().await;
538
539        let total_scripts = scripts.len();
540        let enabled_scripts = scripts.values().filter(|s| s.is_enabled).count();
541        let active_executions = active.len();
542        let total_executions: u32 = scripts.values().map(|s| s.execution_count).sum();
543
544        serde_json::json!({
545            "total_scripts": total_scripts,
546            "enabled_scripts": enabled_scripts,
547            "active_executions": active_executions,
548            "total_executions": total_executions
549        })
550    }
551}
552
553/// Global scripting engine instance
554static SCRIPTING_ENGINE: tokio::sync::OnceCell<ScriptingEngine> =
555    tokio::sync::OnceCell::const_new();
556
557/// Get the global scripting engine
558pub async fn get_scripting_engine() -> &'static ScriptingEngine {
559    SCRIPTING_ENGINE
560        .get_or_init(|| async {
561            let script_dir = std::env::current_dir()
562                .unwrap_or_else(|_| PathBuf::from("."))
563                .join("scripts");
564            ScriptingEngine::new(script_dir)
565        })
566        .await
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use tempfile::TempDir;
573
574    #[tokio::test]
575    async fn test_script_loading() {
576        let temp_dir = TempDir::new().unwrap();
577        let engine = ScriptingEngine::new(temp_dir.path().to_path_buf());
578
579        let script_path = temp_dir.path().join("test.lua");
580        let script_content = r#"
581-- @name Test Script
582-- @description A test script
583-- @author Test Author
584-- @version 1.0.0
585-- @permission voice_control
586-- @trigger voice:test
587
588function main()
589    print("Hello from Lua!")
590    return "success"
591end
592"#;
593
594        tokio::fs::write(&script_path, script_content)
595            .await
596            .unwrap();
597
598        let script_id = engine.load_script(&script_path).await.unwrap();
599        let scripts = engine.get_scripts().await;
600
601        assert_eq!(scripts.len(), 1);
602        assert_eq!(scripts[0].id, script_id);
603        assert_eq!(scripts[0].name, "Test Script");
604    }
605
606    #[tokio::test]
607    async fn test_script_execution() {
608        let temp_dir = TempDir::new().unwrap();
609        let engine = ScriptingEngine::new(temp_dir.path().to_path_buf());
610        engine.initialize().await.unwrap();
611
612        let script_path = temp_dir.path().join("test.lua");
613        let script_content = "print('Hello World!')";
614
615        tokio::fs::write(&script_path, script_content)
616            .await
617            .unwrap();
618
619        let script_id = engine.load_script(&script_path).await.unwrap();
620
621        let context = ScriptContext {
622            script_id: script_id.clone(),
623            user_id: "user1".to_string(),
624            session_id: "session1".to_string(),
625            variables: HashMap::new(),
626            permissions: vec![ScriptPermission::VoiceControl],
627            execution_timeout: std::time::Duration::from_secs(30),
628        };
629
630        let result = engine.execute_script(&script_id, context).await.unwrap();
631        assert!(result.success);
632    }
633
634    #[test]
635    fn test_script_language_enum() {
636        assert_eq!(ScriptLanguage::Lua, ScriptLanguage::Lua);
637        assert_ne!(ScriptLanguage::Lua, ScriptLanguage::Python);
638    }
639
640    #[test]
641    fn test_script_permission_enum() {
642        assert_eq!(
643            ScriptPermission::VoiceControl,
644            ScriptPermission::VoiceControl
645        );
646        assert!(matches!(
647            ScriptPermission::FileSystem("/tmp".to_string()),
648            ScriptPermission::FileSystem(_)
649        ));
650    }
651}