mielin_cli/commands/
script.rs

1//! Script management commands
2
3use crate::output::{render_output, MultiFormatDisplay, OutputFormat};
4use crate::script::{ScriptContext, ScriptEngine};
5use anyhow::Result;
6use clap::Subcommand;
7use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
8use serde::Serialize;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Subcommand)]
13pub enum ScriptCommands {
14    /// List all installed scripts
15    #[command(visible_aliases = &["ls"])]
16    List {
17        /// Filter by tag
18        #[arg(short, long)]
19        tag: Option<String>,
20    },
21
22    /// Show script information
23    #[command(visible_aliases = &["show", "details"])]
24    Info {
25        /// Script name
26        name: String,
27    },
28
29    /// Execute a script
30    #[command(visible_aliases = &["exec", "run"])]
31    Execute {
32        /// Script name
33        name: String,
34
35        /// Arguments as KEY=VALUE pairs
36        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
37        args: Vec<String>,
38    },
39
40    /// Install a script from a file
41    #[command(visible_aliases = &["add"])]
42    Install {
43        /// Path to script file (.rhai)
44        path: PathBuf,
45    },
46
47    /// Uninstall a script
48    #[command(visible_aliases = &["remove", "rm"])]
49    Uninstall {
50        /// Script name
51        name: String,
52
53        /// Skip confirmation
54        #[arg(short = 'y', long)]
55        yes: bool,
56    },
57
58    /// Create a new script template
59    #[command(visible_aliases = &["new"])]
60    Create {
61        /// Script name
62        name: String,
63
64        /// Output file path
65        #[arg(short = 'f', long = "file")]
66        file: PathBuf,
67    },
68
69    /// Reload all scripts
70    Reload,
71
72    /// Show scripts directory path
73    #[command(visible_aliases = &["dir"])]
74    Directory,
75
76    /// Edit a script (opens in $EDITOR)
77    Edit {
78        /// Script name
79        name: String,
80    },
81}
82
83pub async fn handle_script_command(cmd: ScriptCommands, format: OutputFormat) -> Result<()> {
84    match cmd {
85        ScriptCommands::List { tag } => list_scripts(tag, format).await,
86        ScriptCommands::Info { name } => show_script_info(&name, format).await,
87        ScriptCommands::Execute { name, args } => execute_script(&name, args, format).await,
88        ScriptCommands::Install { path } => install_script(&path, format).await,
89        ScriptCommands::Uninstall { name, yes } => uninstall_script(&name, yes, format).await,
90        ScriptCommands::Create { name, file } => create_script_template(&name, &file, format).await,
91        ScriptCommands::Reload => reload_scripts(format).await,
92        ScriptCommands::Directory => show_scripts_directory(format).await,
93        ScriptCommands::Edit { name } => edit_script(&name, format).await,
94    }
95}
96
97#[derive(Debug, Serialize)]
98struct ScriptListEntry {
99    name: String,
100    version: String,
101    description: String,
102    author: String,
103    tags: String,
104}
105
106impl MultiFormatDisplay for Vec<ScriptListEntry> {
107    fn to_table(&self) -> Table {
108        let mut table = Table::new();
109        table
110            .load_preset(UTF8_FULL)
111            .set_content_arrangement(ContentArrangement::Dynamic);
112
113        table.set_header(vec![
114            Cell::new("Name").fg(Color::Cyan),
115            Cell::new("Version").fg(Color::Cyan),
116            Cell::new("Description").fg(Color::Cyan),
117            Cell::new("Author").fg(Color::Cyan),
118            Cell::new("Tags").fg(Color::Cyan),
119        ]);
120
121        for entry in self {
122            table.add_row(vec![
123                Cell::new(&entry.name),
124                Cell::new(&entry.version),
125                Cell::new(&entry.description),
126                Cell::new(&entry.author),
127                Cell::new(&entry.tags),
128            ]);
129        }
130
131        table
132    }
133
134    fn to_quiet(&self) -> String {
135        self.iter()
136            .map(|e| e.name.clone())
137            .collect::<Vec<_>>()
138            .join("\n")
139    }
140}
141
142async fn list_scripts(tag: Option<String>, format: OutputFormat) -> Result<()> {
143    let mut engine = ScriptEngine::new()?;
144    engine.discover_scripts()?;
145
146    let scripts = engine.list_scripts();
147    let mut entries: Vec<ScriptListEntry> = scripts
148        .iter()
149        .map(|s| ScriptListEntry {
150            name: s.metadata.name.clone(),
151            version: s.metadata.version.clone(),
152            description: s.metadata.description.clone(),
153            author: s
154                .metadata
155                .author
156                .clone()
157                .unwrap_or_else(|| "Unknown".to_string()),
158            tags: s.metadata.tags.join(", "),
159        })
160        .collect();
161
162    // Filter by tag if specified
163    if let Some(tag_filter) = tag {
164        entries.retain(|e| {
165            e.tags
166                .split(", ")
167                .any(|t| t.eq_ignore_ascii_case(&tag_filter))
168        });
169    }
170
171    println!("{}", render_output(&entries, format)?);
172    Ok(())
173}
174
175#[derive(Debug, Serialize)]
176struct ScriptInfoDisplay {
177    name: String,
178    version: String,
179    description: String,
180    author: String,
181    required_version: String,
182    tags: Vec<String>,
183    path: String,
184    lines: usize,
185}
186
187impl MultiFormatDisplay for ScriptInfoDisplay {
188    fn to_table(&self) -> Table {
189        let mut table = Table::new();
190        table
191            .load_preset(UTF8_FULL)
192            .set_content_arrangement(ContentArrangement::Dynamic);
193
194        table.add_row(vec![
195            Cell::new("Name").fg(Color::Cyan),
196            Cell::new(&self.name),
197        ]);
198        table.add_row(vec![
199            Cell::new("Version").fg(Color::Cyan),
200            Cell::new(&self.version),
201        ]);
202        table.add_row(vec![
203            Cell::new("Description").fg(Color::Cyan),
204            Cell::new(&self.description),
205        ]);
206        table.add_row(vec![
207            Cell::new("Author").fg(Color::Cyan),
208            Cell::new(&self.author),
209        ]);
210        table.add_row(vec![
211            Cell::new("Required Version").fg(Color::Cyan),
212            Cell::new(&self.required_version),
213        ]);
214        table.add_row(vec![
215            Cell::new("Tags").fg(Color::Cyan),
216            Cell::new(self.tags.join(", ")),
217        ]);
218        table.add_row(vec![
219            Cell::new("Path").fg(Color::Cyan),
220            Cell::new(&self.path),
221        ]);
222        table.add_row(vec![
223            Cell::new("Lines").fg(Color::Cyan),
224            Cell::new(self.lines.to_string()),
225        ]);
226
227        table
228    }
229
230    fn to_quiet(&self) -> String {
231        format!("{} v{}", self.name, self.version)
232    }
233}
234
235async fn show_script_info(name: &str, format: OutputFormat) -> Result<()> {
236    let mut engine = ScriptEngine::new()?;
237    engine.discover_scripts()?;
238
239    let script = engine
240        .get_script(name)
241        .ok_or_else(|| anyhow::anyhow!("Script not found: {}", name))?;
242
243    let info = ScriptInfoDisplay {
244        name: script.metadata.name.clone(),
245        version: script.metadata.version.clone(),
246        description: script.metadata.description.clone(),
247        author: script
248            .metadata
249            .author
250            .clone()
251            .unwrap_or_else(|| "Unknown".to_string()),
252        required_version: script
253            .metadata
254            .required_version
255            .clone()
256            .unwrap_or_else(|| "None".to_string()),
257        tags: script.metadata.tags.clone(),
258        path: script.path.to_string_lossy().to_string(),
259        lines: script.content.lines().count(),
260    };
261
262    println!("{}", render_output(&info, format)?);
263    Ok(())
264}
265
266#[derive(Debug, Serialize)]
267struct ScriptExecutionResult {
268    script: String,
269    exit_code: i32,
270    duration_ms: u64,
271    output: String,
272}
273
274impl MultiFormatDisplay for ScriptExecutionResult {
275    fn to_table(&self) -> Table {
276        let mut table = Table::new();
277        table
278            .load_preset(UTF8_FULL)
279            .set_content_arrangement(ContentArrangement::Dynamic);
280
281        table.add_row(vec![
282            Cell::new("Script").fg(Color::Cyan),
283            Cell::new(&self.script),
284        ]);
285        table.add_row(vec![
286            Cell::new("Exit Code").fg(Color::Cyan),
287            Cell::new(self.exit_code.to_string()),
288        ]);
289        table.add_row(vec![
290            Cell::new("Duration (ms)").fg(Color::Cyan),
291            Cell::new(self.duration_ms.to_string()),
292        ]);
293
294        if !self.output.is_empty() {
295            table.add_row(vec![
296                Cell::new("Output").fg(Color::Cyan),
297                Cell::new(&self.output),
298            ]);
299        }
300
301        table
302    }
303
304    fn to_quiet(&self) -> String {
305        self.output.clone()
306    }
307}
308
309async fn execute_script(name: &str, args: Vec<String>, format: OutputFormat) -> Result<()> {
310    let mut engine = ScriptEngine::new()?;
311    engine.discover_scripts()?;
312
313    // Parse arguments (KEY=VALUE format)
314    let mut arguments = HashMap::new();
315    for arg in args {
316        let parts: Vec<&str> = arg.splitn(2, '=').collect();
317        if parts.len() == 2 {
318            arguments.insert(parts[0].to_string(), parts[1].to_string());
319        } else {
320            anyhow::bail!("Invalid argument format: {}. Expected KEY=VALUE", arg);
321        }
322    }
323
324    let context = ScriptContext {
325        args: arguments,
326        env: std::env::vars().collect(),
327        working_dir: std::env::current_dir()?.to_string_lossy().to_string(),
328        cli_version: env!("CARGO_PKG_VERSION").to_string(),
329    };
330
331    let result = engine.execute_script(name, context)?;
332
333    if result.exit_code != 0 && format != OutputFormat::Quiet {
334        if !result.stderr.is_empty() {
335            eprintln!("{}", result.stderr);
336        }
337        anyhow::bail!("Script failed with exit code: {}", result.exit_code);
338    }
339
340    let exec_result = ScriptExecutionResult {
341        script: name.to_string(),
342        exit_code: result.exit_code,
343        duration_ms: result.duration_ms,
344        output: result.stdout,
345    };
346
347    println!("{}", render_output(&exec_result, format)?);
348    Ok(())
349}
350
351async fn install_script(path: &Path, format: OutputFormat) -> Result<()> {
352    if !path.exists() {
353        anyhow::bail!("Script file not found: {:?}", path);
354    }
355
356    if !path.is_file() {
357        anyhow::bail!("Path is not a file: {:?}", path);
358    }
359
360    if path.extension().and_then(|s| s.to_str()) != Some("rhai") {
361        anyhow::bail!("Invalid script file extension. Expected .rhai");
362    }
363
364    let mut engine = ScriptEngine::new()?;
365    engine.install_script(path)?;
366
367    if format != OutputFormat::Quiet {
368        println!("✓ Script installed successfully");
369    }
370
371    Ok(())
372}
373
374async fn uninstall_script(name: &str, yes: bool, format: OutputFormat) -> Result<()> {
375    let mut engine = ScriptEngine::new()?;
376    engine.discover_scripts()?;
377
378    // Check if script exists
379    if engine.get_script(name).is_none() {
380        anyhow::bail!("Script not found: {}", name);
381    }
382
383    // Confirm uninstall
384    if !yes && format != OutputFormat::Quiet {
385        println!(
386            "Are you sure you want to uninstall script '{}'? [y/N]",
387            name
388        );
389        let mut input = String::new();
390        std::io::stdin().read_line(&mut input)?;
391        if !input.trim().eq_ignore_ascii_case("y") {
392            println!("Uninstall cancelled");
393            return Ok(());
394        }
395    }
396
397    engine.uninstall_script(name)?;
398
399    if format != OutputFormat::Quiet {
400        println!("✓ Script uninstalled successfully");
401    }
402
403    Ok(())
404}
405
406async fn create_script_template(name: &str, file: &Path, format: OutputFormat) -> Result<()> {
407    let engine = ScriptEngine::new()?;
408    engine.create_template(name, file)?;
409
410    if format != OutputFormat::Quiet {
411        println!("✓ Script template created: {:?}", file);
412        println!("  Edit the template and install it with:");
413        println!("  mielinctl script install {:?}", file);
414    }
415
416    Ok(())
417}
418
419async fn reload_scripts(format: OutputFormat) -> Result<()> {
420    let mut engine = ScriptEngine::new()?;
421    let count = engine.discover_scripts()?;
422
423    if format != OutputFormat::Quiet {
424        println!("✓ Reloaded {} script(s)", count);
425    }
426
427    Ok(())
428}
429
430#[derive(Debug, Serialize)]
431struct ScriptsDirectoryInfo {
432    path: String,
433    exists: bool,
434    script_count: usize,
435}
436
437impl MultiFormatDisplay for ScriptsDirectoryInfo {
438    fn to_table(&self) -> Table {
439        let mut table = Table::new();
440        table
441            .load_preset(UTF8_FULL)
442            .set_content_arrangement(ContentArrangement::Dynamic);
443
444        table.add_row(vec![
445            Cell::new("Scripts Directory").fg(Color::Cyan),
446            Cell::new(&self.path),
447        ]);
448
449        table.add_row(vec![
450            Cell::new("Exists").fg(Color::Cyan),
451            if self.exists {
452                Cell::new("Yes").fg(Color::Green)
453            } else {
454                Cell::new("No").fg(Color::Red)
455            },
456        ]);
457
458        table.add_row(vec![
459            Cell::new("Script Count").fg(Color::Cyan),
460            Cell::new(self.script_count.to_string()),
461        ]);
462
463        table
464    }
465
466    fn to_quiet(&self) -> String {
467        self.path.clone()
468    }
469}
470
471async fn show_scripts_directory(format: OutputFormat) -> Result<()> {
472    let scripts_dir = ScriptEngine::get_scripts_dir()?;
473
474    let script_count = if scripts_dir.exists() {
475        std::fs::read_dir(&scripts_dir)
476            .map(|entries| {
477                entries
478                    .filter_map(|e| e.ok())
479                    .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rhai"))
480                    .count()
481            })
482            .unwrap_or(0)
483    } else {
484        0
485    };
486
487    let info = ScriptsDirectoryInfo {
488        path: scripts_dir.to_string_lossy().to_string(),
489        exists: scripts_dir.exists(),
490        script_count,
491    };
492
493    println!("{}", render_output(&info, format)?);
494    Ok(())
495}
496
497async fn edit_script(name: &str, format: OutputFormat) -> Result<()> {
498    let mut engine = ScriptEngine::new()?;
499    engine.discover_scripts()?;
500
501    let script = engine
502        .get_script(name)
503        .ok_or_else(|| anyhow::anyhow!("Script not found: {}", name))?;
504
505    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
506
507    if format != OutputFormat::Quiet {
508        println!("Opening {} in {}...", script.path.display(), editor);
509    }
510
511    let status = std::process::Command::new(&editor)
512        .arg(&script.path)
513        .status()?;
514
515    if !status.success() {
516        anyhow::bail!("Editor exited with non-zero status");
517    }
518
519    if format != OutputFormat::Quiet {
520        println!("✓ Script edited successfully");
521    }
522
523    Ok(())
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_script_list_entry() {
532        let entry = ScriptListEntry {
533            name: "test-script".to_string(),
534            version: "1.0.0".to_string(),
535            description: "A test script".to_string(),
536            author: "Test Author".to_string(),
537            tags: "test, demo".to_string(),
538        };
539
540        let json = serde_json::to_string(&entry).unwrap();
541        assert!(json.contains("test-script"));
542        assert!(json.contains("1.0.0"));
543    }
544
545    #[test]
546    fn test_script_info_display() {
547        let info = ScriptInfoDisplay {
548            name: "test-script".to_string(),
549            version: "1.0.0".to_string(),
550            description: "A test script".to_string(),
551            author: "Test Author".to_string(),
552            required_version: "0.1.0".to_string(),
553            tags: vec!["test".to_string(), "demo".to_string()],
554            path: "/tmp/test.rhai".to_string(),
555            lines: 42,
556        };
557
558        let quiet = info.to_quiet();
559        assert_eq!(quiet, "test-script v1.0.0");
560    }
561
562    #[test]
563    fn test_script_execution_result() {
564        let result = ScriptExecutionResult {
565            script: "test-script".to_string(),
566            exit_code: 0,
567            duration_ms: 100,
568            output: "Success".to_string(),
569        };
570
571        assert_eq!(result.exit_code, 0);
572        assert_eq!(result.output, "Success");
573    }
574}