1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ScriptMetadata {
17 pub name: String,
19 pub version: String,
21 pub description: String,
23 pub author: Option<String>,
25 pub required_version: Option<String>,
27 #[serde(default)]
29 pub tags: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ScriptContext {
35 pub args: HashMap<String, String>,
37 pub env: HashMap<String, String>,
39 pub working_dir: String,
41 pub cli_version: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ScriptResult {
48 pub exit_code: i32,
50 pub stdout: String,
52 pub stderr: String,
54 pub duration_ms: u64,
56 pub return_value: Option<serde_json::Value>,
58}
59
60pub struct ScriptEngine {
62 scripts_dir: PathBuf,
64 scripts: HashMap<String, Script>,
66}
67
68#[derive(Debug, Clone)]
70pub struct Script {
71 pub metadata: ScriptMetadata,
73 pub content: String,
75 pub path: PathBuf,
77}
78
79impl ScriptEngine {
80 pub fn new() -> Result<Self> {
82 let scripts_dir = Self::get_scripts_dir()?;
83
84 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 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 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 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 pub fn get_script(&self, name: &str) -> Option<&Script> {
150 self.scripts.get(name)
151 }
152
153 pub fn list_scripts(&self) -> Vec<&Script> {
155 self.scripts.values().collect()
156 }
157
158 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 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 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 let mut engine = rhai::Engine::new();
192
193 let mut stdout_buffer = String::new();
195 let mut stderr_buffer = String::new();
196
197 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 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 let mut scope = rhai::Scope::new();
219
220 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 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 scope.push("working_dir", context.working_dir.clone());
236 scope.push("cli_version", context.cli_version.clone());
237
238 let result = engine.eval_with_scope::<rhai::Dynamic>(&mut scope, &script.content);
240
241 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 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, return_value: Some(return_value),
256 })
257 }
258 Err(e) => {
259 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 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 serde_json::json!(value.to_string())
300 }
301 }
302
303 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 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 self.discover_scripts()?;
325
326 Ok(())
327 }
328
329 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 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 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 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 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 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 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}