1use 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 #[command(visible_aliases = &["ls"])]
16 List {
17 #[arg(short, long)]
19 tag: Option<String>,
20 },
21
22 #[command(visible_aliases = &["show", "details"])]
24 Info {
25 name: String,
27 },
28
29 #[command(visible_aliases = &["exec", "run"])]
31 Execute {
32 name: String,
34
35 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
37 args: Vec<String>,
38 },
39
40 #[command(visible_aliases = &["add"])]
42 Install {
43 path: PathBuf,
45 },
46
47 #[command(visible_aliases = &["remove", "rm"])]
49 Uninstall {
50 name: String,
52
53 #[arg(short = 'y', long)]
55 yes: bool,
56 },
57
58 #[command(visible_aliases = &["new"])]
60 Create {
61 name: String,
63
64 #[arg(short = 'f', long = "file")]
66 file: PathBuf,
67 },
68
69 Reload,
71
72 #[command(visible_aliases = &["dir"])]
74 Directory,
75
76 Edit {
78 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 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 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 if engine.get_script(name).is_none() {
380 anyhow::bail!("Script not found: {}", name);
381 }
382
383 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}