cargo_run/
start.rs

1//! This module contains the main logic for the cargo-script CLI tool.
2//!
3//! It parses the command-line arguments and executes the appropriate commands.
4use crate::commands::{init::init_script_file, script::run_script, Commands, script::Scripts, show::show_scripts, completions::generate_completions, validate::{validate_scripts, print_validation_results}};
5use crate::error::CargoScriptError;
6use std::fs;
7use clap::{Parser, CommandFactory, ArgAction};
8use colored::*;
9
10/// Command-line arguments structure for the cargo-script CLI tool.
11#[derive(Parser, Debug)]
12#[command(
13    name = "cargo-script",
14    about = "A powerful CLI tool for managing project scripts in Rust",
15    long_about = "Think npm scripts, make, or just — but built specifically for the Rust ecosystem with modern CLI best practices.",
16    after_help = "EXAMPLES:\n  cargo script build                              Run the 'build' script\n  cargo script run test                          Explicitly run the 'test' script\n  cargo script test --env RUST_LOG=debug        Run with environment variable\n  cargo script test --dry-run                    Preview what would run\n  cargo script test --no-metrics                 Run without performance metrics\n  cargo script --interactive                     Interactive script selection\n  cargo script show                              List all available scripts\n  cargo script show --filter test                Filter scripts by name/description\n  cargo script init                              Initialize Scripts.toml\n  cargo script validate                         Validate Scripts.toml\n\nFor more information, visit: https://github.com/rsaz/cargo-script",
17    version,
18    subcommand_required = false,
19    arg_required_else_help = false,
20)]
21pub struct Cli {
22    #[command(subcommand)]
23    command: Option<Commands>,
24    /// Script name to run (when not using 'run' subcommand)
25    #[arg(value_name = "SCRIPT_NAME")]
26    script_name: Option<String>,
27    /// Optional path to the Scripts.toml file.
28    #[arg(long, default_value = "Scripts.toml", global = true)]
29    scripts_path: String,
30    /// Suppress all output except errors
31    #[arg(short, long, global = true)]
32    quiet: bool,
33    /// Show detailed output
34    #[arg(short = 'v', long, global = true)]
35    verbose: bool,
36    /// Environment variables to set (only used when script_name is provided)
37    #[arg(short, long, value_name = "KEY=VALUE", action = ArgAction::Append, global = true)]
38    env: Vec<String>,
39    /// Preview what would be executed without actually running it (only used when script_name is provided)
40    #[arg(long, global = true)]
41    dry_run: bool,
42    /// Don't show performance metrics after execution (only used when script_name is provided)
43    #[arg(long, global = true)]
44    no_metrics: bool,
45    /// Interactive script selection (only used when script_name is provided)
46    #[arg(short, long, global = true)]
47    interactive: bool,
48}
49
50/// Run function that handles errors gracefully.
51pub fn run_with_error_handling() {
52    if let Err(e) = run() {
53        eprintln!("{}", e);
54        std::process::exit(1);
55    }
56}
57
58/// Run function that parses command-line arguments and executes the specified command.
59///
60/// This function initializes the CLI, parses the command-line arguments, and routes
61/// the commands to their respective handlers.
62///
63/// # Errors
64///
65/// Returns an error if it fails to read or parse the `Scripts.toml` file.
66pub fn run() -> Result<(), CargoScriptError> {
67    // Handle Cargo subcommand invocation
68    // When invoked as `cargo script`, Cargo passes "script" as the first argument
69    // We need to remove it before parsing
70    let args: Vec<String> = std::env::args().collect();
71    let cli = if args.len() > 1 && args[1] == "script" {
72        // Remove "script" argument when invoked as `cargo script`
73        // Also need to include the binary name for clap
74        let mut cargo_args = vec![args[0].clone()]; // binary name
75        cargo_args.extend(args.into_iter().skip(2)); // skip "cargo-script" and "script"
76        
77        // If no arguments after "script", default to show
78        if cargo_args.len() == 1 {
79            // Only binary name, no subcommand - default to show
80            return handle_show_command("Scripts.toml", false, false, None);
81        }
82        
83        // Check for help flag before parsing
84        if cargo_args.len() == 2 && (cargo_args[1] == "--help" || cargo_args[1] == "-h") {
85            let mut app = Cli::command();
86            app.print_help().unwrap();
87            std::process::exit(0);
88        }
89        
90        Cli::try_parse_from(cargo_args).unwrap_or_else(|e| {
91            // Let clap handle the error (will show usage)
92            e.exit()
93        })
94    } else {
95        // Normal invocation: `cargo-script` or `cgs`
96        Cli::parse()
97    };
98    
99    // Determine the actual command to execute
100    // If no command but script_name is provided, treat as Run
101    // If no command and no script_name, default to Show
102    // Error if both command and script_name are provided
103    let command = match (&cli.command, &cli.script_name) {
104        (Some(_cmd), Some(_)) => {
105            // Both command and script_name provided - this is an error
106            eprintln!("{}", "Error: Cannot specify both a subcommand and a script name".red().bold());
107            eprintln!("{}", "Use either 'cargo script <script_name>' or 'cargo script <subcommand>'".white());
108            std::process::exit(1);
109        }
110        (Some(cmd), None) => cmd.clone(),
111        (None, Some(script_name)) => {
112            // Treat script_name as Run command, using global flags
113            Commands::Run {
114                script: Some(script_name.clone()),
115                env: cli.env.clone(),
116                dry_run: cli.dry_run,
117                quiet: false, // Use global quiet flag instead
118                verbose: false, // Use global verbose flag instead
119                no_metrics: cli.no_metrics,
120                interactive: cli.interactive,
121            }
122        }
123        (None, None) => {
124            // If --interactive is set, treat as Run with interactive mode
125            // Otherwise default to Show
126            if cli.interactive {
127                Commands::Run {
128                    script: None,
129                    env: cli.env.clone(),
130                    dry_run: cli.dry_run,
131                    quiet: false,
132                    verbose: false,
133                    no_metrics: cli.no_metrics,
134                    interactive: true,
135                }
136            } else {
137                Commands::Show {
138                    quiet: cli.quiet,
139                    verbose: cli.verbose,
140                    filter: None,
141                }
142            }
143        }
144    };
145    
146    // Conditional banner display:
147    // - Never show for completions (interferes with output)
148    // - Never show in quiet mode
149    // - Show in verbose mode
150    // - Show if Scripts.toml doesn't exist (first run)
151    // - Don't show for dry-run (cleaner output)
152    // - Show for Init, Show, and Validate commands (helpful context)
153    let should_show_banner = !cli.quiet 
154        && !matches!(&command, Commands::Completions { .. })
155        && !matches!(&command, Commands::Run { dry_run: true, .. })
156        && (cli.verbose 
157            || !std::path::Path::new(&cli.scripts_path).exists()
158            || matches!(&command, Commands::Init | Commands::Show { .. } | Commands::Validate { .. }));
159    
160    if should_show_banner {
161        let init_msg = format!("A CLI tool to run custom scripts in Rust, defined in [ Scripts.toml ] {}", emoji::objects::computer::FLOPPY_DISK.glyph);
162        print_framed_message(&init_msg);
163    }
164    
165    let scripts_path = &cli.scripts_path;
166
167    match &command {
168        Commands::Run { script, env, dry_run, quiet, verbose, no_metrics, interactive } => {
169            // Merge global and command-specific flags
170            // When Run is explicitly used, its flags take precedence
171            // When script_name is used (short form), global flags are used
172            let final_quiet = cli.quiet || *quiet;
173            let final_verbose = cli.verbose || *verbose;
174            let final_dry_run = cli.dry_run || *dry_run;
175            let final_no_metrics = cli.no_metrics || *no_metrics;
176            let final_env = if !env.is_empty() { env.clone() } else { cli.env.clone() };
177            let show_metrics = !final_no_metrics; // Show metrics by default unless --no-metrics is set
178            
179            let scripts_content = fs::read_to_string(scripts_path)
180                .map_err(|e| CargoScriptError::ScriptFileNotFound {
181                    path: scripts_path.clone(),
182                    source: e,
183                })?;
184            
185            let scripts: Scripts = toml::from_str(&scripts_content)
186                .map_err(|e| {
187                    let message = e.message().to_string();
188                    let line = e.span().map(|s| s.start);
189                    CargoScriptError::InvalidToml {
190                        path: scripts_path.clone(),
191                        message,
192                        line,
193                    }
194                })?;
195            
196            // Handle interactive mode or when script is not provided
197            let script_name = if *interactive || script.is_none() {
198                crate::commands::script::interactive_select_script(&scripts, final_quiet)?
199            } else {
200                script.clone().ok_or_else(|| CargoScriptError::ScriptNotFound {
201                    script_name: "".to_string(),
202                    available_scripts: scripts.scripts.keys().cloned().collect(),
203                })?
204            };
205            
206            run_script(&scripts, &script_name, final_env, final_dry_run, final_quiet, final_verbose, show_metrics)?;
207        }
208        Commands::Init => {
209            init_script_file(Some(scripts_path));
210        }
211        Commands::Show { quiet, verbose: _verbose, filter } => {
212            // Merge global and command-specific verbosity flags
213            let final_quiet = cli.quiet || *quiet;
214            handle_show_command(scripts_path, final_quiet, cli.verbose, filter.as_deref())?;
215        }
216        Commands::Completions { shell } => {
217            let mut app = Cli::command();
218            generate_completions(shell.clone(), &mut app);
219        }
220        Commands::Validate { quiet, verbose: _verbose } => {
221            // Merge global and command-specific verbosity flags
222            let final_quiet = cli.quiet || *quiet;
223            
224            let scripts_content = fs::read_to_string(scripts_path)
225                .map_err(|e| CargoScriptError::ScriptFileNotFound {
226                    path: scripts_path.clone(),
227                    source: e,
228                })?;
229            
230            let scripts: Scripts = toml::from_str(&scripts_content)
231                .map_err(|e| {
232                    let message = e.message().to_string();
233                    let line = e.span().map(|s| s.start);
234                    CargoScriptError::InvalidToml {
235                        path: scripts_path.clone(),
236                        message,
237                        line,
238                    }
239                })?;
240            
241            let validation_result = validate_scripts(&scripts);
242            if !final_quiet {
243                print_validation_results(&validation_result);
244            }
245            
246            if !validation_result.is_valid() {
247                std::process::exit(1);
248            }
249        }
250    }
251    
252    Ok(())
253}
254
255/// Helper function to handle the Show command
256fn handle_show_command(scripts_path: &str, quiet: bool, _verbose: bool, filter: Option<&str>) -> Result<(), CargoScriptError> {
257    let scripts_content = fs::read_to_string(scripts_path)
258        .map_err(|e| CargoScriptError::ScriptFileNotFound {
259            path: scripts_path.to_string(),
260            source: e,
261        })?;
262    
263    let scripts: Scripts = toml::from_str(&scripts_content)
264        .map_err(|e| {
265            let message = e.message().to_string();
266            let line = e.span().map(|s| s.start);
267            CargoScriptError::InvalidToml {
268                path: scripts_path.to_string(),
269                message,
270                line,
271            }
272        })?;
273    
274    if !quiet {
275        show_scripts(&scripts, filter);
276    }
277    
278    Ok(())
279}
280
281/// Prints a framed message with a dashed line frame.
282///
283/// This function prints a framed message to the console, making it more visually
284/// appealing and easier to read.
285///
286/// # Arguments
287///
288/// * `message` - A string slice that holds the message to be framed.
289///
290fn print_framed_message(message: &str) {
291    let framed_message = format!("| {} |", message);
292    let frame = "-".repeat(framed_message.len()-2);
293    println!("\n{}\n{}\n{}\n", frame.yellow(), framed_message.yellow(), frame.yellow());
294}