cargo_run/commands/
script.rs

1//! This module provides the functionality to run scripts defined in `Scripts.toml`.
2
3use std::{collections::HashMap, env, process::{Command, Stdio}, sync::{Arc, Mutex}, time::{Duration, Instant}};
4use serde::Deserialize;
5use emoji::symbols;
6use colored::*;
7use dialoguer::FuzzySelect;
8
9/// Enum representing a script, which can be either a default command or a detailed script with additional metadata.
10#[derive(Deserialize, Debug)]
11#[serde(untagged)]
12pub enum Script {
13    Default(String),
14    Inline {
15        command: Option<String>,
16        requires: Option<Vec<String>>,
17        toolchain: Option<String>,
18        info: Option<String>,
19        env: Option<HashMap<String, String>>,
20        include: Option<Vec<String>>,
21        interpreter: Option<String>,
22    },
23    CILike {
24        script: String,
25        command: Option<String>,
26        requires: Option<Vec<String>>,
27        toolchain: Option<String>,
28        info: Option<String>,
29        env: Option<HashMap<String, String>>,
30        include: Option<Vec<String>>,
31        interpreter: Option<String>,
32    }
33}
34
35/// Struct representing the collection of scripts defined in Scripts.toml.
36#[derive(Deserialize)]
37pub struct Scripts {
38    pub global_env: Option<HashMap<String, String>>,
39    pub scripts: HashMap<String, Script>
40}
41
42use crate::error::{CargoScriptError, create_tool_not_found_error, create_toolchain_not_found_error};
43
44/// Run a script by name, executing any included scripts in sequence.
45///
46/// This function runs a script and any scripts it includes, measuring the execution time
47/// for each script and printing performance metrics.
48///
49/// # Arguments
50///
51/// * `scripts` - A reference to the collection of scripts.
52/// * `script_name` - The name of the script to run.
53/// * `env_overrides` - A vector of command line environment variable overrides.
54/// * `dry_run` - If true, only show what would be executed without actually running it.
55/// * `quiet` - If true, suppress all output except errors.
56/// * `verbose` - If true, show detailed output.
57/// * `show_metrics` - If true, show performance metrics after execution.
58///
59/// # Errors
60///
61/// Returns an error if the script is not found or if execution fails.
62pub fn run_script(scripts: &Scripts, script_name: &str, env_overrides: Vec<String>, dry_run: bool, quiet: bool, verbose: bool, show_metrics: bool) -> Result<(), CargoScriptError> {
63    if dry_run {
64        if !quiet {
65            println!("{}", "DRY-RUN MODE: Preview of what would be executed".bold().yellow());
66            println!("{}\n", "=".repeat(80).yellow());
67        }
68        dry_run_script(scripts, script_name, env_overrides, 0, quiet, verbose)?;
69        if !quiet {
70            println!("\n{}", "No commands were actually executed.".italic().green());
71        }
72        return Ok(());
73    }
74
75    let script_durations = Arc::new(Mutex::new(HashMap::new()));
76
77    fn run_script_with_level(
78        scripts: &Scripts,
79        script_name: &str,
80        env_overrides: Vec<String>,
81        level: usize,
82        script_durations: Arc<Mutex<HashMap<String, Duration>>>,
83        quiet: bool,
84        verbose: bool,
85    ) -> Result<(), CargoScriptError> {
86        let mut env_vars = scripts.global_env.clone().unwrap_or_default();
87        let indent = "  ".repeat(level);
88
89        let script_start_time = Instant::now();
90
91        if let Some(script) = scripts.scripts.get(script_name) {
92            match script {
93                Script::Default(cmd) => {
94                    if !quiet {
95                        let msg = format!(
96                            "{}{}  {}: [ {} ]",
97                            indent,
98                            symbols::other_symbol::CHECK_MARK.glyph,
99                            "Running script".green(),
100                            script_name
101                        );
102                        println!("{}\n", msg);
103                    }
104                    let final_env = get_final_env(&env_vars, &env_overrides);
105                    apply_env_vars(&env_vars, &env_overrides);
106                    execute_command(script_name, None, cmd, None, &final_env)?;
107                }
108                Script::Inline {
109                    command,
110                    info,
111                    env,
112                    include,
113                    interpreter,
114                    requires,
115                    toolchain,
116                    ..
117                } | Script::CILike {
118                    command,
119                    info,
120                    env,
121                    include,
122                    interpreter,
123                    requires,
124                    toolchain,
125                    ..
126                } => {
127                    if let Err(e) = check_requirements(requires.as_deref().unwrap_or(&[]), toolchain.as_ref()) {
128                        return Err(e);
129                    }
130
131                    // Always show description unless quiet (not just in verbose mode)
132                    let description = info.as_deref().map(|desc| {
133                        format!(
134                            "{}  {}: {}",
135                            emoji::objects::book_paper::BOOKMARK_TABS.glyph,
136                            "Description".green(),
137                            desc
138                        )
139                    });
140
141                    if let Some(include_scripts) = include {
142                        if !quiet {
143                            let desc_str = description.as_deref().unwrap_or("");
144                            let msg = format!(
145                                "{}{}  {}: [ {} ]{}",
146                                indent,
147                                symbols::other_symbol::CHECK_MARK.glyph,
148                                "Running include script".green(),
149                                script_name,
150                                if desc_str.is_empty() { String::new() } else { format!("  {}", desc_str) }
151                            );
152                            println!("{}\n", msg);
153                        }
154                        for include_script in include_scripts {
155                            run_script_with_level(
156                                scripts,
157                                include_script,
158                                env_overrides.clone(),
159                                level + 1,
160                                script_durations.clone(),
161                                quiet,
162                                verbose,
163                            )?;
164                        }
165                    }
166
167                    if let Some(cmd) = command {
168                        if !quiet {
169                            let desc_str = description.as_deref().unwrap_or("");
170                            let msg = format!(
171                                "{}{}  {}: [ {} ]{}",
172                                indent,
173                                symbols::other_symbol::CHECK_MARK.glyph,
174                                "Running script".green(),
175                                script_name,
176                                if desc_str.is_empty() { String::new() } else { format!("  {}", desc_str) }
177                            );
178                            println!("{}\n", msg);
179                        }
180
181                        if let Some(script_env) = env {
182                            env_vars.extend(script_env.clone());
183                        }
184                        let final_env = get_final_env(&env_vars, &env_overrides);
185                        apply_env_vars(&env_vars, &env_overrides);
186                        execute_command(script_name, interpreter.as_deref(), cmd, toolchain.as_deref(), &final_env)?;
187                    }
188                }
189            }
190
191            let script_duration = script_start_time.elapsed();
192            if level > 0 || scripts.scripts.get(script_name).map_or(false, |s| matches!(s, Script::Default(_) | Script::Inline { command: Some(_), .. } | Script::CILike { command: Some(_), .. })) {
193                script_durations
194                    .lock()
195                    .unwrap()
196                    .insert(script_name.to_string(), script_duration);
197            }
198            Ok(())
199        } else {
200            let available_scripts: Vec<String> = scripts.scripts.keys().cloned().collect();
201            return Err(CargoScriptError::ScriptNotFound {
202                script_name: script_name.to_string(),
203                available_scripts,
204            });
205        }
206    }
207
208    run_script_with_level(scripts, script_name, env_overrides, 0, script_durations.clone(), quiet, verbose)?;
209
210    // Show performance metrics only if enabled and not in quiet mode
211    if show_metrics && !quiet {
212        let durations = script_durations.lock().unwrap();
213        if !durations.is_empty() {
214            let total_duration: Duration = durations.values().cloned().sum();
215            
216            println!("\n");
217            println!("{}", "Scripts Performance".bold().yellow());
218            println!("{}", "-".repeat(80).yellow());
219            for (script, duration) in durations.iter() {
220                println!("āœ”ļø  Script: {:<25}  šŸ•’ Running time: {:.2?}", script.green(), duration);
221            }
222            println!("\nšŸ•’ Total running time: {:.2?}", total_duration);
223        }
224    }
225    
226    Ok(())
227}
228
229
230/// Get the final environment variables map from global, script-specific, and command line overrides.
231///
232/// This function computes the final environment variables, giving precedence
233/// to command line overrides over script-specific variables, and script-specific variables over global variables.
234///
235/// # Arguments
236///
237/// * `env_vars` - A reference to the global environment variables.
238/// * `env_overrides` - A vector of command line environment variable overrides.
239///
240/// # Returns
241///
242/// A HashMap containing the final environment variables.
243fn get_final_env(env_vars: &HashMap<String, String>, env_overrides: &[String]) -> HashMap<String, String> {
244    let mut final_env = env_vars.clone();
245
246    for override_str in env_overrides {
247        if let Some((key, value)) = override_str.split_once('=') {
248            final_env.insert(key.to_string(), value.to_string());
249        }
250    }
251
252    final_env
253}
254
255/// Apply environment variables from global, script-specific, and command line overrides.
256///
257/// This function sets the environment variables for the script execution, giving precedence
258/// to command line overrides over script-specific variables, and script-specific variables over global variables.
259///
260/// # Arguments
261///
262/// * `env_vars` - A reference to the global environment variables.
263/// * `env_overrides` - A vector of command line environment variable overrides.
264fn apply_env_vars(env_vars: &HashMap<String, String>, env_overrides: &[String]) {
265    let final_env = get_final_env(env_vars, env_overrides);
266
267    for (key, value) in &final_env {
268        // SAFETY: Setting environment variables for child processes is safe.
269        // We're in a single-threaded context when setting these variables,
270        // and they're only used for the child process spawned immediately after.
271        unsafe {
272            env::set_var(key, value);
273        }
274    }
275}
276
277/// Execute a command using the specified interpreter, or the default shell if none is specified.
278///
279/// This function runs the command with the appropriate interpreter, depending on the operating system
280/// and the specified interpreter.
281///
282/// # Arguments
283///
284/// * `script_name` - The name of the script being executed (for error messages).
285/// * `interpreter` - An optional string representing the interpreter to use.
286/// * `command` - The command to execute.
287/// * `toolchain` - An optional string representing the toolchain to use.
288/// * `env_vars` - A reference to the environment variables to set for the command.
289///
290/// # Errors
291///
292/// Returns an error if it fails to execute the command.
293fn execute_command(script_name: &str, interpreter: Option<&str>, command: &str, toolchain: Option<&str>, env_vars: &HashMap<String, String>) -> Result<(), CargoScriptError> {
294    let mut cmd = if let Some(tc) = toolchain {
295        let mut command_with_toolchain = format!("cargo +{} ", tc);
296        command_with_toolchain.push_str(command);
297        let mut cmd = Command::new("sh");
298        cmd.arg("-c")
299            .arg(command_with_toolchain)
300            .stdout(Stdio::inherit())
301            .stderr(Stdio::inherit());
302        for (key, value) in env_vars {
303            cmd.env(key, value);
304        }
305        cmd.spawn()
306            .map_err(|e| CargoScriptError::ExecutionError {
307                script: script_name.to_string(),
308                command: command.to_string(),
309                source: e,
310            })?
311    } else {
312        match interpreter {
313            Some("bash") => {
314                let mut cmd = Command::new("bash");
315                cmd.arg("-c")
316                    .arg(command)
317                    .stdout(Stdio::inherit())
318                    .stderr(Stdio::inherit());
319                for (key, value) in env_vars {
320                    cmd.env(key, value);
321                }
322                cmd.spawn()
323                    .map_err(|e| CargoScriptError::ExecutionError {
324                        script: "unknown".to_string(),
325                        command: command.to_string(),
326                        source: e,
327                    })?
328            },
329            Some("zsh") => {
330                let mut cmd = Command::new("zsh");
331                cmd.arg("-c")
332                    .arg(command)
333                    .stdout(Stdio::inherit())
334                    .stderr(Stdio::inherit());
335                for (key, value) in env_vars {
336                    cmd.env(key, value);
337                }
338                cmd.spawn()
339                    .map_err(|e| CargoScriptError::ExecutionError {
340                        script: "unknown".to_string(),
341                        command: command.to_string(),
342                        source: e,
343                    })?
344            },
345            Some("powershell") => {
346                let mut cmd = Command::new("powershell");
347                cmd.args(&["-NoProfile", "-Command", command])
348                    .stdout(Stdio::inherit())
349                    .stderr(Stdio::inherit());
350                for (key, value) in env_vars {
351                    cmd.env(key, value);
352                }
353                cmd.spawn()
354                    .map_err(|e| CargoScriptError::ExecutionError {
355                        script: "unknown".to_string(),
356                        command: command.to_string(),
357                        source: e,
358                    })?
359            },
360            Some("cmd") => {
361                let mut cmd = Command::new("cmd");
362                cmd.args(&["/C", command])
363                    .stdout(Stdio::inherit())
364                    .stderr(Stdio::inherit());
365                for (key, value) in env_vars {
366                    cmd.env(key, value);
367                }
368                cmd.spawn()
369                    .map_err(|e| CargoScriptError::ExecutionError {
370                        script: "unknown".to_string(),
371                        command: command.to_string(),
372                        source: e,
373                    })?
374            },
375            Some(other) => {
376                let mut cmd = Command::new(other);
377                cmd.arg("-c")
378                    .arg(command)
379                    .stdout(Stdio::inherit())
380                    .stderr(Stdio::inherit());
381                for (key, value) in env_vars {
382                    cmd.env(key, value);
383                }
384                cmd.spawn()
385                    .map_err(|e| CargoScriptError::ExecutionError {
386                        script: "unknown".to_string(),
387                        command: command.to_string(),
388                        source: e,
389                    })?
390            },
391            None => {
392                if cfg!(target_os = "windows") {
393                    let mut cmd = Command::new("cmd");
394                    cmd.args(&["/C", command])
395                        .stdout(Stdio::inherit())
396                        .stderr(Stdio::inherit());
397                    for (key, value) in env_vars {
398                        cmd.env(key, value);
399                    }
400                    cmd.spawn()
401                        .map_err(|e| CargoScriptError::ExecutionError {
402                            script: "unknown".to_string(),
403                            command: command.to_string(),
404                            source: e,
405                        })?
406                } else {
407                    let mut cmd = Command::new("sh");
408                    cmd.arg("-c")
409                        .arg(command)
410                        .stdout(Stdio::inherit())
411                        .stderr(Stdio::inherit());
412                    for (key, value) in env_vars {
413                        cmd.env(key, value);
414                    }
415                    cmd.spawn()
416                        .map_err(|e| CargoScriptError::ExecutionError {
417                            script: "unknown".to_string(),
418                            command: command.to_string(),
419                            source: e,
420                        })?
421                }
422            }
423        }
424    };
425
426    let exit_status = cmd.wait().map_err(|e| CargoScriptError::ExecutionError {
427        script: script_name.to_string(),
428        command: command.to_string(),
429        source: e,
430    })?;
431    
432    // Check if command failed and might be a Windows self-replacement issue
433    if !exit_status.success() {
434        let is_self_replace_attempt = cfg!(target_os = "windows")
435            && (command.contains("cargo install --path .") 
436                || command.contains("cargo install --path")
437                || (command.contains("cargo install") && command.contains("--path")));
438        
439        if is_self_replace_attempt {
440            return Err(CargoScriptError::WindowsSelfReplacementError {
441                script: script_name.to_string(),
442                command: command.to_string(),
443            });
444        }
445    }
446    
447    Ok(())
448}
449
450/// Display what would be executed in dry-run mode without actually running anything.
451///
452/// # Arguments
453///
454/// * `scripts` - A reference to the collection of scripts.
455/// * `script_name` - The name of the script to preview.
456/// * `env_overrides` - A vector of command line environment variable overrides.
457/// * `level` - The nesting level for indentation.
458/// * `quiet` - If true, suppress all output except errors.
459/// * `verbose` - If true, show detailed output.
460fn dry_run_script(
461    scripts: &Scripts,
462    script_name: &str,
463    env_overrides: Vec<String>,
464    level: usize,
465    quiet: bool,
466    verbose: bool,
467) -> Result<(), CargoScriptError> {
468    let indent = "  ".repeat(level);
469    let mut env_vars = scripts.global_env.clone().unwrap_or_default();
470
471    if let Some(script) = scripts.scripts.get(script_name) {
472        match script {
473            Script::Default(cmd) => {
474                if !quiet {
475                    println!(
476                        "{}{}  {}: [ {} ]",
477                        indent,
478                        "šŸ“‹".yellow(),
479                        "Would run script".cyan(),
480                        script_name.bold()
481                    );
482                    println!("{}    Command: {}", indent, cmd.green());
483                    let final_env = get_final_env(&env_vars, &env_overrides);
484                    // In dry-run mode, always show environment variables (unless quiet)
485                    if !final_env.is_empty() {
486                        println!("{}    Environment variables:", indent);
487                        for (key, value) in &final_env {
488                            println!("{}      {} = {}", indent, key.cyan(), value.green());
489                        }
490                    }
491                    if level == 0 {
492                        println!(); // Extra spacing for top-level scripts
493                    }
494                }
495            }
496            Script::Inline {
497                command,
498                info,
499                env,
500                include,
501                interpreter,
502                requires,
503                toolchain,
504                ..
505            } | Script::CILike {
506                command,
507                info,
508                env,
509                include,
510                interpreter,
511                requires,
512                toolchain,
513                ..
514            } => {
515                if !quiet {
516                    // Check requirements (but don't fail in dry-run, just warn)
517                    if verbose {
518                        if let Some(reqs) = requires {
519                            if !reqs.is_empty() {
520                                println!(
521                                    "{}{}  {}: [ {} ]",
522                                    indent,
523                                    "šŸ”".yellow(),
524                                    "Would check requirements".cyan(),
525                                    script_name.bold()
526                                );
527                                for req in reqs {
528                                    println!("{}      - {}", indent, req.green());
529                                }
530                                println!();
531                            }
532                        }
533                    }
534
535                    if verbose {
536                        if let Some(tc) = toolchain {
537                            println!(
538                                "{}{}  {}: {}",
539                                indent,
540                                "šŸ”§".yellow(),
541                                "Would use toolchain".cyan(),
542                                tc.bold().green()
543                            );
544                            println!();
545                        }
546                    }
547
548                    if verbose {
549                        if let Some(desc) = info {
550                            println!(
551                                "{}{}  {}: {}",
552                                indent,
553                                "šŸ“".yellow(),
554                                "Description".cyan(),
555                                desc.green()
556                            );
557                            println!();
558                        }
559                    }
560
561                    if let Some(include_scripts) = include {
562                        println!(
563                            "{}{}  {}: [ {} ]",
564                            indent,
565                            "šŸ“‹".yellow(),
566                            "Would run include scripts".cyan(),
567                            script_name.bold()
568                        );
569                        if verbose {
570                            if let Some(desc) = info {
571                                println!("{}    Description: {}", indent, desc.green());
572                            }
573                        }
574                        println!();
575                        for include_script in include_scripts {
576                            dry_run_script(scripts, include_script, env_overrides.clone(), level + 1, quiet, verbose)?;
577                        }
578                    }
579
580                    if let Some(cmd) = command {
581                        println!(
582                            "{}{}  {}: [ {} ]",
583                            indent,
584                            "šŸ“‹".yellow(),
585                            "Would run script".cyan(),
586                            script_name.bold()
587                        );
588                        
589                        // In dry-run mode, always show interpreter and toolchain (unless quiet)
590                        if let Some(interp) = interpreter {
591                            println!("{}    Interpreter: {}", indent, interp.green());
592                        }
593                        
594                        if let Some(tc) = toolchain {
595                            println!("{}    Toolchain: {}", indent, tc.green());
596                        }
597                        
598                        println!("{}    Command: {}", indent, cmd.green());
599                        
600                        if let Some(script_env) = env {
601                            env_vars.extend(script_env.clone());
602                        }
603                        
604                        let final_env = get_final_env(&env_vars, &env_overrides);
605                        // In dry-run mode, always show environment variables (unless quiet)
606                        if !final_env.is_empty() {
607                            println!("{}    Environment variables:", indent);
608                            for (key, value) in &final_env {
609                                println!("{}      {} = {}", indent, key.cyan(), value.green());
610                            }
611                        }
612                        if level == 0 {
613                            println!(); // Extra spacing for top-level scripts
614                        }
615                    }
616                } else {
617                    // Even in quiet mode, we need to process includes
618                    if let Some(include_scripts) = include {
619                        for include_script in include_scripts {
620                            dry_run_script(scripts, include_script, env_overrides.clone(), level + 1, quiet, verbose)?;
621                        }
622                    }
623                }
624            }
625        }
626    } else {
627        let available_scripts: Vec<String> = scripts.scripts.keys().cloned().collect();
628        return Err(CargoScriptError::ScriptNotFound {
629            script_name: script_name.to_string(),
630            available_scripts,
631        });
632    }
633    
634    Ok(())
635}
636
637/// Interactive script selection using fuzzy finder.
638///
639/// This function displays an interactive fuzzy selector for choosing a script to run.
640///
641/// # Arguments
642///
643/// * `scripts` - A reference to the collection of scripts.
644/// * `quiet` - If true, suppress extra output.
645///
646/// # Returns
647///
648/// The selected script name, or an error if selection was cancelled or failed.
649///
650/// # Errors
651///
652/// Returns an error if no scripts are available or if the selection was cancelled.
653pub fn interactive_select_script(scripts: &Scripts, quiet: bool) -> Result<String, CargoScriptError> {
654    if scripts.scripts.is_empty() {
655        return Err(CargoScriptError::ScriptNotFound {
656            script_name: "".to_string(),
657            available_scripts: vec![],
658        });
659    }
660
661    // Prepare script items with descriptions for display
662    let mut items: Vec<(String, String)> = scripts.scripts
663        .iter()
664        .map(|(name, script)| {
665            let description = match script {
666                Script::Default(_) => "".to_string(),
667                Script::Inline { info, .. } | Script::CILike { info, .. } => {
668                    info.clone().unwrap_or_else(|| "".to_string())
669                }
670            };
671            (name.clone(), description)
672        })
673        .collect();
674    
675    // Sort by name for consistent display
676    items.sort_by(|a, b| a.0.cmp(&b.0));
677
678    // Format items for display
679    let display_items: Vec<String> = items
680        .iter()
681        .map(|(name, desc)| {
682            if desc.is_empty() {
683                name.clone()
684            } else {
685                format!("{} - {}", name, desc)
686            }
687        })
688        .collect();
689
690    if !quiet {
691        println!("{}", "Select a script to run:".cyan().bold());
692        println!();
693    }
694
695    let selection = FuzzySelect::new()
696        .with_prompt("Script")
697        .items(&display_items)
698        .default(0)
699        .interact()
700        .map_err(|e| CargoScriptError::ExecutionError {
701            script: "interactive".to_string(),
702            command: "fuzzy_select".to_string(),
703            source: std::io::Error::new(std::io::ErrorKind::Other, format!("Interactive selection failed: {}", e)),
704        })?;
705
706    Ok(items[selection].0.clone())
707}
708
709/// Check if the required tools and toolchain are installed.
710/// 
711/// This function checks if the required tools and toolchain are installed on the system.
712/// If any of the requirements are not met, an error message is returned.
713/// 
714/// # Arguments
715/// 
716/// * `requires` - A slice of strings representing the required tools.
717/// * `toolchain` - An optional string representing the required toolchain.
718/// 
719/// # Returns
720/// 
721/// An empty result if all requirements are met, otherwise an error.
722/// 
723/// # Errors
724/// 
725/// This function will return an error if any of the requirements are not met.
726fn check_requirements(requires: &[String], toolchain: Option<&String>) -> Result<(), CargoScriptError> {
727    for req in requires {
728        if let Some((tool, version)) = req.split_once(' ') {
729            let output = Command::new(tool)
730                .arg("--version")
731                .output()
732                .map_err(|_| create_tool_not_found_error(tool, Some(version)))?;
733            let output_str = String::from_utf8_lossy(&output.stdout);
734
735            if !output_str.contains(version) {
736                return Err(create_tool_not_found_error(tool, Some(version)));
737            }
738        } else {
739            // Just check if the tool is installed
740            Command::new(req)
741                .output()
742                .map_err(|_| create_tool_not_found_error(req, None))?;
743        }
744    }
745
746    if let Some(tc) = toolchain {
747        let output = Command::new("rustup")
748            .arg("toolchain")
749            .arg("list")
750            .output()
751            .map_err(|_| create_tool_not_found_error("rustup", None))?;
752        let output_str = String::from_utf8_lossy(&output.stdout);
753
754        if !output_str.contains(tc) {
755            return Err(create_toolchain_not_found_error(tc));
756        }
757    }
758
759    Ok(())
760}