mielin_cli/
script.rs

1//! Scripting Support for MielinCTL
2//!
3//! Provides scripting capabilities using Rhai scripting language.
4//! Scripts can automate CLI operations, perform batch tasks, and
5//! create reusable workflows.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::{debug, info, warn};
13
14/// Script metadata from script headers
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ScriptMetadata {
17    /// Script name
18    pub name: String,
19    /// Script version
20    pub version: String,
21    /// Description
22    pub description: String,
23    /// Author
24    pub author: Option<String>,
25    /// Required CLI version (semver)
26    pub required_version: Option<String>,
27    /// Tags for categorization
28    #[serde(default)]
29    pub tags: Vec<String>,
30}
31
32/// Script execution context
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ScriptContext {
35    /// Script arguments
36    pub args: HashMap<String, String>,
37    /// Environment variables
38    pub env: HashMap<String, String>,
39    /// Working directory
40    pub working_dir: String,
41    /// CLI version
42    pub cli_version: String,
43}
44
45/// Script execution result
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ScriptResult {
48    /// Exit code (0 = success)
49    pub exit_code: i32,
50    /// Standard output
51    pub stdout: String,
52    /// Standard error
53    pub stderr: String,
54    /// Execution duration in milliseconds
55    pub duration_ms: u64,
56    /// Return value from script
57    pub return_value: Option<serde_json::Value>,
58}
59
60/// Script engine for executing Rhai scripts
61pub struct ScriptEngine {
62    /// Scripts directory
63    scripts_dir: PathBuf,
64    /// Loaded scripts cache
65    scripts: HashMap<String, Script>,
66}
67
68/// Represents a loaded script
69#[derive(Debug, Clone)]
70pub struct Script {
71    /// Script metadata
72    pub metadata: ScriptMetadata,
73    /// Script content
74    pub content: String,
75    /// Script file path
76    pub path: PathBuf,
77}
78
79impl ScriptEngine {
80    /// Create a new script engine
81    pub fn new() -> Result<Self> {
82        let scripts_dir = Self::get_scripts_dir()?;
83
84        // Create scripts directory if it doesn't exist
85        if !scripts_dir.exists() {
86            fs::create_dir_all(&scripts_dir).context("Failed to create scripts directory")?;
87            info!("Created scripts directory: {:?}", scripts_dir);
88        }
89
90        Ok(ScriptEngine {
91            scripts_dir,
92            scripts: HashMap::new(),
93        })
94    }
95
96    /// Get the default scripts directory
97    pub fn get_scripts_dir() -> Result<PathBuf> {
98        let config_dir =
99            dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Failed to get config directory"))?;
100        Ok(config_dir.join("mielin").join("scripts"))
101    }
102
103    /// Discover and load all scripts from the scripts directory
104    pub fn discover_scripts(&mut self) -> Result<usize> {
105        debug!("Discovering scripts in {:?}", self.scripts_dir);
106
107        let entries =
108            fs::read_dir(&self.scripts_dir).context("Failed to read scripts directory")?;
109
110        let mut loaded_count = 0;
111
112        for entry in entries {
113            let entry = match entry {
114                Ok(e) => e,
115                Err(e) => {
116                    warn!("Failed to read directory entry: {}", e);
117                    continue;
118                }
119            };
120
121            let path = entry.path();
122            if !path.is_file() {
123                continue;
124            }
125
126            // Only load .rhai files
127            if path.extension().and_then(|s| s.to_str()) != Some("rhai") {
128                continue;
129            }
130
131            match Script::load_from_file(&path) {
132                Ok(script) => {
133                    let name = script.metadata.name.clone();
134                    info!("Loaded script: {} v{}", name, script.metadata.version);
135                    self.scripts.insert(name, script);
136                    loaded_count += 1;
137                }
138                Err(e) => {
139                    warn!("Failed to load script from {:?}: {}", path, e);
140                }
141            }
142        }
143
144        info!("Discovered {} scripts", loaded_count);
145        Ok(loaded_count)
146    }
147
148    /// Get a script by name
149    pub fn get_script(&self, name: &str) -> Option<&Script> {
150        self.scripts.get(name)
151    }
152
153    /// List all loaded scripts
154    pub fn list_scripts(&self) -> Vec<&Script> {
155        self.scripts.values().collect()
156    }
157
158    /// Execute a script with given context
159    pub fn execute_script(&self, name: &str, context: ScriptContext) -> Result<ScriptResult> {
160        let script = self
161            .get_script(name)
162            .ok_or_else(|| anyhow::anyhow!("Script not found: {}", name))?;
163
164        debug!("Executing script: {}", name);
165
166        let start_time = std::time::Instant::now();
167
168        // Execute script using Rhai engine
169        let result = self.execute_rhai_script(script, &context)?;
170
171        let duration_ms = start_time.elapsed().as_millis() as u64;
172
173        Ok(ScriptResult {
174            exit_code: result.exit_code,
175            stdout: result.stdout,
176            stderr: result.stderr,
177            duration_ms,
178            return_value: result.return_value,
179        })
180    }
181
182    /// Execute Rhai script using the Rhai engine
183    fn execute_rhai_script(
184        &self,
185        script: &Script,
186        context: &ScriptContext,
187    ) -> Result<ScriptResult> {
188        debug!("Executing Rhai script: {}", script.metadata.name);
189
190        // Create a new Rhai engine
191        let mut engine = rhai::Engine::new();
192
193        // Create output buffers
194        let mut stdout_buffer = String::new();
195        let mut stderr_buffer = String::new();
196
197        // Register print function to capture stdout
198        let stdout_clone = std::sync::Arc::new(std::sync::Mutex::new(stdout_buffer.clone()));
199        let stdout_ref = stdout_clone.clone();
200        engine.on_print(move |s| {
201            if let Ok(mut buf) = stdout_ref.lock() {
202                buf.push_str(s);
203                buf.push('\n');
204            }
205        });
206
207        // Register debug function to capture stderr
208        let stderr_clone = std::sync::Arc::new(std::sync::Mutex::new(stderr_buffer.clone()));
209        let stderr_ref = stderr_clone.clone();
210        engine.on_debug(move |s, _src, _pos| {
211            if let Ok(mut buf) = stderr_ref.lock() {
212                buf.push_str(s);
213                buf.push('\n');
214            }
215        });
216
217        // Create a scope and inject context variables
218        let mut scope = rhai::Scope::new();
219
220        // Inject args as a map
221        let mut args_map = rhai::Map::new();
222        for (key, value) in &context.args {
223            args_map.insert(key.clone().into(), rhai::Dynamic::from(value.clone()));
224        }
225        scope.push("args", args_map);
226
227        // Inject environment variables as a map
228        let mut env_map = rhai::Map::new();
229        for (key, value) in &context.env {
230            env_map.insert(key.clone().into(), rhai::Dynamic::from(value.clone()));
231        }
232        scope.push("env", env_map);
233
234        // Inject other context variables
235        scope.push("working_dir", context.working_dir.clone());
236        scope.push("cli_version", context.cli_version.clone());
237
238        // Execute the script
239        let result = engine.eval_with_scope::<rhai::Dynamic>(&mut scope, &script.content);
240
241        // Extract stdout and stderr from buffers
242        stdout_buffer = stdout_clone.lock().map(|b| b.clone()).unwrap_or_default();
243        stderr_buffer = stderr_clone.lock().map(|b| b.clone()).unwrap_or_default();
244
245        match result {
246            Ok(value) => {
247                // Convert Rhai dynamic value to JSON
248                let return_value = Self::rhai_to_json(value);
249
250                Ok(ScriptResult {
251                    exit_code: 0,
252                    stdout: stdout_buffer,
253                    stderr: stderr_buffer,
254                    duration_ms: 0, // Duration is tracked by the caller
255                    return_value: Some(return_value),
256                })
257            }
258            Err(e) => {
259                // Script execution failed
260                stderr_buffer.push_str(&format!("Script error: {}\n", e));
261
262                Ok(ScriptResult {
263                    exit_code: 1,
264                    stdout: stdout_buffer,
265                    stderr: stderr_buffer,
266                    duration_ms: 0,
267                    return_value: None,
268                })
269            }
270        }
271    }
272
273    /// Convert Rhai Dynamic value to JSON
274    fn rhai_to_json(value: rhai::Dynamic) -> serde_json::Value {
275        if value.is::<i64>() {
276            serde_json::json!(value.as_int().unwrap_or(0))
277        } else if value.is::<f64>() {
278            serde_json::json!(value.as_float().unwrap_or(0.0))
279        } else if value.is::<bool>() {
280            serde_json::json!(value.as_bool().unwrap_or(false))
281        } else if value.is::<rhai::ImmutableString>() {
282            serde_json::json!(value.to_string())
283        } else if value.is::<rhai::Map>() {
284            let map = value.cast::<rhai::Map>();
285            let mut json_map = serde_json::Map::new();
286            for (k, v) in map {
287                json_map.insert(k.to_string(), Self::rhai_to_json(v));
288            }
289            serde_json::Value::Object(json_map)
290        } else if value.is::<rhai::Array>() {
291            let array = value.cast::<rhai::Array>();
292            let json_array: Vec<serde_json::Value> =
293                array.into_iter().map(Self::rhai_to_json).collect();
294            serde_json::Value::Array(json_array)
295        } else if value.is::<()>() {
296            serde_json::Value::Null
297        } else {
298            // Fallback: convert to string
299            serde_json::json!(value.to_string())
300        }
301    }
302
303    /// Install a script from a file
304    pub fn install_script(&mut self, source_path: &Path) -> Result<()> {
305        let script =
306            Script::load_from_file(source_path).context("Failed to load script from source")?;
307
308        let dest_filename = format!("{}.rhai", script.metadata.name);
309        let dest_path = self.scripts_dir.join(&dest_filename);
310
311        if dest_path.exists() {
312            anyhow::bail!("Script already installed: {}", script.metadata.name);
313        }
314
315        // Copy script file
316        fs::copy(source_path, &dest_path).context("Failed to copy script file")?;
317
318        info!(
319            "Installed script: {} v{}",
320            script.metadata.name, script.metadata.version
321        );
322
323        // Reload scripts
324        self.discover_scripts()?;
325
326        Ok(())
327    }
328
329    /// Uninstall a script
330    pub fn uninstall_script(&mut self, name: &str) -> Result<()> {
331        if !self.scripts.contains_key(name) {
332            anyhow::bail!("Script not found: {}", name);
333        }
334
335        let script_filename = format!("{}.rhai", name);
336        let script_path = self.scripts_dir.join(&script_filename);
337
338        if script_path.exists() {
339            fs::remove_file(&script_path).context("Failed to remove script file")?;
340        }
341
342        self.scripts.remove(name);
343        info!("Uninstalled script: {}", name);
344
345        Ok(())
346    }
347
348    /// Create a new script template
349    pub fn create_template(&self, name: &str, output_path: &Path) -> Result<()> {
350        let template = format!(
351            r#"// Script: {}
352// Version: 1.0.0
353// Description: A new MielinCTL script
354// Author: Your Name
355
356// This is a Rhai script for MielinCTL
357// You can use Rhai syntax to automate CLI operations
358
359fn main(args) {{
360    print("Hello from {}!");
361    print("Arguments: " + args);
362
363    // TODO: Add your script logic here
364
365    return {{
366        status: "success",
367        message: "Script executed successfully"
368    }};
369}}
370
371// Call main function
372main(args)
373"#,
374            name, name
375        );
376
377        fs::write(output_path, template).context("Failed to write script template")?;
378
379        info!("Created script template: {:?}", output_path);
380        Ok(())
381    }
382}
383
384impl Script {
385    /// Load a script from a file
386    pub fn load_from_file(path: &Path) -> Result<Self> {
387        if !path.exists() {
388            anyhow::bail!("Script file not found: {:?}", path);
389        }
390
391        let content = fs::read_to_string(path).context("Failed to read script file")?;
392
393        // Parse metadata from comments
394        let metadata = Self::parse_metadata(&content, path)?;
395
396        Ok(Script {
397            metadata,
398            content,
399            path: path.to_path_buf(),
400        })
401    }
402
403    /// Parse script metadata from comments
404    fn parse_metadata(content: &str, path: &Path) -> Result<ScriptMetadata> {
405        let mut name = path
406            .file_stem()
407            .and_then(|s| s.to_str())
408            .unwrap_or("unknown")
409            .to_string();
410
411        let mut version = "1.0.0".to_string();
412        let mut description = String::new();
413        let mut author = None;
414        let mut required_version = None;
415        let mut tags = Vec::new();
416
417        // Parse comment headers
418        for line in content.lines().take(20) {
419            let line = line.trim();
420            if !line.starts_with("//") {
421                continue;
422            }
423
424            let line = line.trim_start_matches("//").trim();
425
426            if line.starts_with("Script:") {
427                name = line.trim_start_matches("Script:").trim().to_string();
428            } else if line.starts_with("Version:") {
429                version = line.trim_start_matches("Version:").trim().to_string();
430            } else if line.starts_with("Description:") {
431                description = line.trim_start_matches("Description:").trim().to_string();
432            } else if line.starts_with("Author:") {
433                author = Some(line.trim_start_matches("Author:").trim().to_string());
434            } else if line.starts_with("RequiredVersion:") {
435                required_version = Some(
436                    line.trim_start_matches("RequiredVersion:")
437                        .trim()
438                        .to_string(),
439                );
440            } else if line.starts_with("Tags:") {
441                let tags_str = line.trim_start_matches("Tags:").trim();
442                tags = tags_str.split(',').map(|s| s.trim().to_string()).collect();
443            }
444        }
445
446        Ok(ScriptMetadata {
447            name,
448            version,
449            description,
450            author,
451            required_version,
452            tags,
453        })
454    }
455}
456
457impl Default for ScriptEngine {
458    fn default() -> Self {
459        Self::new().expect("Failed to create script engine")
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use std::env;
467
468    #[test]
469    fn test_script_metadata_parsing() {
470        let content = r#"
471// Script: test-script
472// Version: 1.0.0
473// Description: A test script
474// Author: Test Author
475// RequiredVersion: 0.1.0
476// Tags: test, demo
477
478fn main() {
479    print("Hello");
480}
481"#;
482        let path = PathBuf::from("test.rhai");
483        let metadata = Script::parse_metadata(content, &path).unwrap();
484
485        assert_eq!(metadata.name, "test-script");
486        assert_eq!(metadata.version, "1.0.0");
487        assert_eq!(metadata.description, "A test script");
488        assert_eq!(metadata.author, Some("Test Author".to_string()));
489        assert_eq!(metadata.tags.len(), 2);
490    }
491
492    #[test]
493    fn test_script_context_serialization() {
494        let mut args = HashMap::new();
495        args.insert("key".to_string(), "value".to_string());
496
497        let mut env = HashMap::new();
498        env.insert("PATH".to_string(), "/usr/bin".to_string());
499
500        let context = ScriptContext {
501            args,
502            env,
503            working_dir: "/tmp".to_string(),
504            cli_version: "0.1.0".to_string(),
505        };
506
507        let json = serde_json::to_string(&context).unwrap();
508        assert!(json.contains("key"));
509        assert!(json.contains("value"));
510    }
511
512    #[test]
513    fn test_script_result() {
514        let result = ScriptResult {
515            exit_code: 0,
516            stdout: "Success".to_string(),
517            stderr: String::new(),
518            duration_ms: 100,
519            return_value: Some(serde_json::json!({"status": "ok"})),
520        };
521
522        assert_eq!(result.exit_code, 0);
523        assert_eq!(result.stdout, "Success");
524        assert!(result.return_value.is_some());
525    }
526
527    #[test]
528    fn test_script_engine_creation() {
529        let engine = ScriptEngine::new();
530        assert!(engine.is_ok());
531
532        let engine = engine.unwrap();
533        assert_eq!(engine.scripts.len(), 0);
534    }
535
536    #[tokio::test]
537    async fn test_create_template() {
538        let engine = ScriptEngine::new().unwrap();
539        let temp_path = env::temp_dir().join("test_script.rhai");
540
541        let result = engine.create_template("test", &temp_path);
542        assert!(result.is_ok());
543
544        // Clean up
545        if temp_path.exists() {
546            let _ = fs::remove_file(&temp_path);
547        }
548    }
549
550    #[test]
551    fn test_script_metadata_default_values() {
552        let content = r#"
553fn main() {
554    print("Hello");
555}
556"#;
557        let path = PathBuf::from("simple.rhai");
558        let metadata = Script::parse_metadata(content, &path).unwrap();
559
560        assert_eq!(metadata.name, "simple");
561        assert_eq!(metadata.version, "1.0.0");
562        assert_eq!(metadata.description, "");
563        assert!(metadata.author.is_none());
564    }
565}