Skip to main content

brush_shell/
entry.rs

1//! Implements the command-line interface for the `brush` shell.
2
3use crate::args::CommandLineArgs;
4use crate::args::InputBackendType;
5use crate::brushctl::ShellBuilderBrushBuiltinExt as _;
6use crate::bundled;
7use crate::config;
8use crate::error_formatter;
9use crate::events;
10use crate::productinfo;
11use brush_builtins::ShellBuilderExt as _;
12#[cfg(feature = "experimental-builtins")]
13use brush_experimental_builtins::ShellBuilderExt as _;
14use clap::CommandFactory;
15use std::sync::LazyLock;
16use std::{path::Path, sync::Arc};
17use tokio::sync::Mutex;
18
19#[allow(unused_imports, reason = "only used in some configs")]
20use std::io::IsTerminal;
21
22static TRACE_EVENT_CONFIG: LazyLock<Arc<tokio::sync::Mutex<Option<events::TraceEventConfig>>>> =
23    LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
24
25type BrushShellExtensions = brush_core::extensions::ShellExtensionsImpl<error_formatter::Formatter>;
26type BrushShell = brush_core::Shell<BrushShellExtensions>;
27
28// WARN: this implementation shadows `clap::Parser::parse_from` one so it must be defined
29// after the `use clap::Parser`
30impl CommandLineArgs {
31    // Work around clap's limitation handling `--` like a regular value
32    // TODO(cmdline): We can safely remove this `impl` after the issue is resolved
33    // https://github.com/clap-rs/clap/issues/5055
34    // This function takes precedence over [`clap::Parser::parse_from`]
35    fn try_parse_from(itr: impl IntoIterator<Item = String>) -> Result<Self, clap::Error> {
36        let mut args: Vec<String> = itr.into_iter().collect();
37
38        // In bash, `-c` treats `--` as an option terminator and takes its
39        // command string from the first argument *after* `--`. (Other
40        // value-taking flags like `-o` and `-O` instead consume `--` as their
41        // literal value in bash, rejecting it as an invalid option name.)
42        //
43        // Remove the `--` so that `-c` naturally consumes the next token as its
44        // value via clap. Other value-taking flags are unaffected: for them
45        // try_parse_known splits at `--` before clap sees it, so they still
46        // produce an error for invocations like `-o --`/`-O --` (via a missing
47        // value rather than an invalid option name). In both cases, we
48        // intentionally do not treat `--` as an option terminator for those
49        // flags.
50        if let Some(dd_idx) = args.iter().position(|a| a == "--") {
51            if let Some(flag_idx) = dd_idx
52                .checked_sub(1)
53                .filter(|&i| Self::has_pending_c_flag(&args[i]))
54            {
55                // Remove the option-terminating `--`.
56                args.remove(dd_idx);
57
58                // If the command value (now at dd_idx) is itself `--`, merge it
59                // into the flag as an attached value (e.g., "-c" + "--" → "-c--").
60                // Clap parses `-c--` as `-c` with value `"--"` (standard POSIX
61                // short-option-with-attached-value syntax). This prevents
62                // try_parse_known from splitting at it again.
63                if args.get(dd_idx).map(String::as_str) == Some("--") {
64                    let value = args.remove(dd_idx);
65                    args[flag_idx].push_str(&value);
66                }
67            }
68        }
69
70        let (mut this, script_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
71
72        // Collect any args from after `--` (handled by try_parse_known) into
73        // script_args, which become positional parameters ($0, $1, ...).
74        if let Some(args) = script_args {
75            this.script_args.extend(args);
76        }
77
78        Ok(this)
79    }
80
81    /// Returns true if `arg` is `-c` or a combined short-flag group ending in
82    /// `c` (like `-ec`) where all preceding characters are boolean flags.
83    ///
84    /// This specifically targets `-c` because it is the only short flag with
85    /// special `--` option-terminator behavior in bash. Other value-taking flags
86    /// (`-o`, `-O`) consume `--` as their literal value instead.
87    ///
88    /// Uses clap's argument definitions to validate preceding flags, avoiding
89    /// a hardcoded list of boolean flag characters.
90    fn has_pending_c_flag(arg: &str) -> bool {
91        // Must be a short flag group ending in 'c': "-c", "-ec", "-xec", etc.
92        let Some(flags) = arg.strip_prefix('-') else {
93            return false;
94        };
95        let Some(preceding) = flags.strip_suffix('c') else {
96            return false;
97        };
98        // Reject long-option-like args (e.g., "--c").
99        if preceding.starts_with('-') {
100            return false;
101        }
102
103        // For "-c" alone, preceding is empty and the check below is vacuously
104        // true. For combined flags like `-ec`, verify all chars before the
105        // trailing `c` are boolean flags. If any preceding char takes a value
106        // (like `o`), then `c` is consumed as that flag's value, not as `-c`.
107        let cmd = Self::command();
108        preceding.chars().all(|ch| {
109            cmd.get_arguments().any(|a| {
110                a.get_short() == Some(ch)
111                    && !matches!(
112                        a.get_action(),
113                        clap::ArgAction::Set | clap::ArgAction::Append
114                    )
115            })
116        })
117    }
118}
119
120/// Main entry point for the `brush` shell.
121pub fn run() {
122    //
123    // Install the bundled-command registry so it's available both for
124    // bundled dispatch (handled next) and for builtin shim registration
125    // during shell construction. With no bundled-providing features enabled
126    // the registry is empty and both code paths become no-ops.
127    //
128    bundled::install_default_providers();
129
130    //
131    // If we were invoked as `brush <DISPATCH_FLAG> <name> [args...]`, run the
132    // bundled command and exit before doing any shell setup. This is the
133    // hot path for in-binary coreutils invocations.
134    //
135    if let Some(code) = bundled::maybe_dispatch() {
136        std::process::exit(code);
137    }
138
139    //
140    // Install panic handlers to clean up on panic.
141    //
142    install_panic_handlers();
143
144    //
145    // Parse args.
146    //
147    let mut args: Vec<_> = std::env::args().collect();
148
149    // Work around clap's limitations handling +O options.
150    for arg in &mut args {
151        if arg.starts_with("+O") {
152            arg.insert_str(0, "--");
153        }
154    }
155
156    let parsed_args = match CommandLineArgs::try_parse_from(args.iter().cloned()) {
157        Ok(parsed_args) => parsed_args,
158        Err(e) => {
159            let _ = e.print();
160
161            // Check for whether this is something we'd truly consider fatal. clap returns
162            // errors for `--help`, `--version`, etc.
163            let exit_code = match e.kind() {
164                clap::error::ErrorKind::DisplayVersion => 0,
165                clap::error::ErrorKind::DisplayHelp => 0,
166                _ => 2,
167            };
168
169            std::process::exit(exit_code);
170        }
171    };
172
173    //
174    // Run.
175    //
176    #[cfg(any(unix, windows))]
177    let mut builder = tokio::runtime::Builder::new_multi_thread();
178    #[cfg(not(any(unix, windows)))]
179    let mut builder = tokio::runtime::Builder::new_current_thread();
180
181    let Ok(runtime) = builder.enable_all().build() else {
182        tracing::error!("error: failed to create Tokio runtime");
183        std::process::exit(1);
184    };
185
186    let result = runtime.block_on(run_async(args, parsed_args));
187
188    let exit_code = match result {
189        Ok(code) => code,
190        Err(err) => {
191            tracing::error!("error: {err:#}");
192            1
193        }
194    };
195
196    std::process::exit(i32::from(exit_code));
197}
198
199/// Installs panic handlers to report our panic and cleanly exit on panic.
200fn install_panic_handlers() {
201    //
202    // Set up panic handler. On release builds, it will capture panic details to a
203    // temporary .toml file and report a human-readable message to the screen.
204    //
205    human_panic::setup_panic!(
206        human_panic::Metadata::new(productinfo::PRODUCT_NAME, productinfo::PRODUCT_VERSION)
207            .homepage(env!("CARGO_PKG_HOMEPAGE"))
208            .support("please post a GitHub issue at https://github.com/reubeno/brush/issues/new")
209    );
210
211    //
212    // If stdout is connected to a terminal, then register a new panic handler that
213    // resets the terminal and then invokes the previously registered handler. In
214    // dev/debug builds, the previously registered handler will be the default
215    // handler; in release builds, it will be the one registered by `human_panic`.
216    //
217    if std::io::stdout().is_terminal() {
218        let original_panic_handler = std::panic::take_hook();
219        std::panic::set_hook(Box::new(move |panic_info| {
220            // Best-effort attempt to reset the terminal to defaults.
221            let _ = try_reset_terminal_to_defaults();
222
223            // Invoke the original handler
224            original_panic_handler(panic_info);
225        }));
226    }
227}
228
229#[cfg(feature = "experimental")]
230pub(crate) const DEFAULT_ENABLE_HIGHLIGHTING: bool = true;
231#[cfg(not(feature = "experimental"))]
232pub(crate) const DEFAULT_ENABLE_HIGHLIGHTING: bool = false;
233
234/// Run the brush shell. Returns the exit code.
235///
236/// # Arguments
237///
238/// * `cli_args` - The command-line arguments to the shell, in string form.
239/// * `args` - The already-parsed command-line arguments.
240#[doc(hidden)]
241async fn run_async(
242    cli_args: Vec<String>,
243    args: CommandLineArgs,
244) -> Result<u8, brush_interactive::ShellError> {
245    // Initializing tracing.
246    let mut event_config = TRACE_EVENT_CONFIG.lock().await;
247    *event_config = Some(events::TraceEventConfig::init(
248        &args.enabled_debug_events,
249        &args.disabled_events,
250    ));
251    drop(event_config);
252
253    // Load configuration file.
254    let file_config = config::load_config(args.no_config, args.config_file.as_deref())
255        .into_config_or_log()
256        .map_err(|e| brush_interactive::ShellError::IoError(std::io::Error::other(e)))?;
257
258    // Instantiate an appropriately configured shell and wrap it in an `Arc`. Note that we do
259    // *not* run any code in the shell yet. We'll delay loading profiles and such until after
260    // we've set up everything else (in `run_in_shell`).
261    let shell: BrushShell = instantiate_shell(&args, cli_args).await?;
262    let shell = Arc::new(Mutex::new(shell));
263
264    // Run with the selected input backend. Each branch instantiates the concrete
265    // backend type and calls `run_in_shell`, preserving static dispatch.
266    let default_backend = get_default_input_backend_type(&args);
267    let selected_backend = args.input_backend.unwrap_or(default_backend);
268
269    // Build UI options by merging config file with CLI args.
270    #[allow(unused_variables, reason = "not used when no backend features enabled")]
271    let ui_options = file_config.to_ui_options(&args);
272
273    let result = match selected_backend {
274        #[cfg(all(feature = "reedline", any(unix, windows)))]
275        InputBackendType::Reedline => {
276            let mut input_backend =
277                brush_interactive::ReedlineInputBackend::new(&ui_options, &shell)?;
278            run_in_shell(&shell, args.clone(), &mut input_backend, &ui_options).await
279        }
280        #[cfg(any(not(feature = "reedline"), not(any(unix, windows))))]
281        InputBackendType::Reedline => Err(brush_interactive::ShellError::InputBackendNotSupported),
282
283        #[cfg(feature = "basic")]
284        InputBackendType::Basic => {
285            let mut input_backend = brush_interactive::BasicInputBackend;
286            run_in_shell(&shell, args.clone(), &mut input_backend, &ui_options).await
287        }
288        #[cfg(not(feature = "basic"))]
289        InputBackendType::Basic => Err(brush_interactive::ShellError::InputBackendNotSupported),
290
291        #[cfg(feature = "minimal")]
292        InputBackendType::Minimal => {
293            let mut input_backend = brush_interactive::MinimalInputBackend;
294            run_in_shell(&shell, args.clone(), &mut input_backend, &ui_options).await
295        }
296        #[cfg(not(feature = "minimal"))]
297        InputBackendType::Minimal => Err(brush_interactive::ShellError::InputBackendNotSupported),
298    };
299
300    // Display any error that percolated up.
301    let exit_code = match result {
302        Ok(code) => code,
303        Err(brush_interactive::ShellError::ShellError(e)) => {
304            let shell = shell.lock().await;
305            let mut stderr = shell.stderr();
306            let _ = shell.display_error(&mut stderr, &e);
307            drop(shell);
308            1
309        }
310        Err(err) => {
311            tracing::error!("error: {err:#}");
312            1
313        }
314    };
315
316    Ok(exit_code)
317}
318
319/// Determines whether `run_in_shell` will run the shell interactively. Must be sync'd with it.
320const fn will_run_interactively(args: &CommandLineArgs) -> bool {
321    if args.command.is_some() {
322        false
323    } else if args.read_commands_from_stdin {
324        true
325    } else {
326        args.script_args.is_empty()
327    }
328}
329
330/// Runs the shell according to the provided command-line arguments.
331/// Also responsible for loading profiles and rc files as appropriate.
332///
333/// # Arguments
334///
335/// * `shell_ref` - A reference to the shell to run.
336/// * `args` - The parsed command-line arguments.
337/// * `input_backend` - The input backend to use.
338/// * `ui_options` - The user interface options to use.
339async fn run_in_shell(
340    shell_ref: &brush_interactive::ShellRef<impl brush_core::ShellExtensions>,
341    args: CommandLineArgs,
342    input_backend: &mut impl brush_interactive::InputBackend,
343    ui_options: &brush_interactive::UIOptions,
344) -> Result<u8, brush_interactive::ShellError> {
345    // First load profile and rc files as appropriate.
346    initialize_shell(shell_ref, &args).await?;
347
348    // If a command was specified via -c, then run that command and then exit.
349    if let Some(command) = args.command {
350        shell_ref.lock().await.run_dash_c_command(command).await?;
351
352    // If -s was provided, then read commands from stdin. If there was a script (and optionally
353    // args) passed on the command line via positional arguments, then we copy over the
354    // parameters but do *not* execute it.
355    } else if args.read_commands_from_stdin {
356        let interactive_options = ui_options.into();
357        brush_interactive::InteractiveShell::new(shell_ref, input_backend, &interactive_options)?
358            .run_interactively()
359            .await?;
360
361    // If a script path was provided, then run the script.
362    } else if !args.script_args.is_empty() {
363        // The path to a script was provided on the command line; run the script.
364        shell_ref
365            .lock()
366            .await
367            .run_script(
368                Path::new(&args.script_args[0]),
369                args.script_args.iter().skip(1),
370            )
371            .await?;
372
373    // If we got down here, then we don't have any commands to run. We'll be reading
374    // them in from stdin one way or the other.
375    } else {
376        let interactive_options = ui_options.into();
377        brush_interactive::InteractiveShell::new(shell_ref, input_backend, &interactive_options)?
378            .run_interactively()
379            .await?;
380    }
381
382    // Make sure to return the last result observed in the shell.
383    let result = shell_ref.lock().await.last_exit_status();
384
385    Ok(result)
386}
387
388/// Initializes a shell by loading profile and rc files as appropriate.
389///
390/// # Arguments
391///
392/// * `shell_ref` - A reference to the shell to initialize.
393/// * `args` - The parsed command-line arguments.
394async fn initialize_shell(
395    shell_ref: &brush_interactive::ShellRef<impl brush_core::ShellExtensions>,
396    args: &CommandLineArgs,
397) -> Result<(), brush_interactive::ShellError> {
398    // Compute desired profile-loading behavior.
399    let profile = if args.no_profile {
400        brush_core::ProfileLoadBehavior::Skip
401    } else {
402        brush_core::ProfileLoadBehavior::LoadDefault
403    };
404
405    // Compute desired rc-loading behavior.
406    let rc = if args.no_rc {
407        brush_core::RcLoadBehavior::Skip
408    } else if let Some(rc_file) = &args.rc_file {
409        brush_core::RcLoadBehavior::LoadCustom(rc_file.clone())
410    } else {
411        brush_core::RcLoadBehavior::LoadDefault
412    };
413
414    shell_ref.lock().await.load_config(&profile, &rc).await?;
415
416    Ok(())
417}
418
419/// Instantiates a shell from command-line arguments. Does *not* run any code in the shell.
420///
421/// # Arguments
422///
423/// * `args` - The parsed command-line arguments.
424/// * `cli_args` - The raw command-line arguments.
425async fn instantiate_shell(
426    args: &CommandLineArgs,
427    cli_args: Vec<String>,
428) -> Result<BrushShell, brush_interactive::ShellError> {
429    #[cfg(feature = "experimental-load")]
430    let mut shell = if let Some(load_file) = &args.load_file {
431        instantiate_shell_from_file(load_file.as_path())?
432    } else {
433        instantiate_shell_from_args(args, cli_args).await?
434    };
435
436    #[cfg(not(feature = "experimental-load"))]
437    let mut shell = instantiate_shell_from_args(args, cli_args).await?;
438
439    // Register shims for any bundled commands in the installed registry.
440    // Done here (not inside the inner instantiators) so both paths are
441    // covered from a single site.
442    bundled::register_shims(&mut shell);
443
444    Ok(shell)
445}
446
447#[cfg(feature = "experimental-load")]
448fn instantiate_shell_from_file(
449    file_path: &Path,
450) -> Result<BrushShell, brush_interactive::ShellError> {
451    let mut shell: BrushShell = serde_json::from_reader(std::fs::File::open(file_path)?)
452        .map_err(|e| brush_interactive::ShellError::IoError(std::io::Error::other(e)))?;
453
454    // NOTE: We need to manually register builtins because we can't serialize/deserialize them.
455    // TODO(serde): we should consider whether we could/should at least track *which* are enabled.
456    let builtin_set = if shell.options().sh_mode {
457        brush_builtins::BuiltinSet::ShMode
458    } else {
459        brush_builtins::BuiltinSet::BashMode
460    };
461
462    let builtins = brush_builtins::default_builtins(builtin_set);
463
464    for (builtin_name, builtin) in builtins {
465        shell.register_builtin(&builtin_name, builtin);
466    }
467
468    // Add experimental builtins (if enabled).
469    #[cfg(feature = "experimental-builtins")]
470    for (builtin_name, builtin) in brush_experimental_builtins::experimental_builtins() {
471        shell.register_builtin(&builtin_name, builtin);
472    }
473
474    Ok(shell)
475}
476
477/// Instantiates a shell from command-line arguments. Does *not* run any code in the shell.
478///
479/// # Arguments
480///
481/// * `args` - The parsed command-line arguments.
482/// * `cli_args` - The raw command-line arguments.
483async fn instantiate_shell_from_args(
484    args: &CommandLineArgs,
485    cli_args: Vec<String>,
486) -> Result<BrushShell, brush_interactive::ShellError> {
487    // Compute login flag.
488    let login = args.login || cli_args.first().is_some_and(|argv0| argv0.starts_with('-'));
489
490    // Compute shell name.
491    let shell_name = if args.command.is_some() && !args.script_args.is_empty() {
492        Some(args.script_args[0].clone())
493    } else if !cli_args.is_empty() {
494        Some(cli_args[0].clone())
495    } else if args.sh_mode {
496        // Simulate having been run as "sh".
497        Some(String::from("sh"))
498    } else {
499        None
500    };
501
502    // Compute positional shell arguments.
503    let shell_args = if args.command.is_some() {
504        Some(args.script_args.iter().skip(1).cloned().collect())
505    } else if args.read_commands_from_stdin {
506        Some(args.script_args.clone())
507    } else {
508        None
509    };
510
511    // Commands are read from stdin if -s was provided, or if no command was specified (either via
512    // -c or as a positional argument).
513    let read_commands_from_stdin = (args.read_commands_from_stdin && args.command.is_none())
514        || (args.script_args.is_empty() && args.command.is_none());
515
516    let builtin_set = if args.sh_mode {
517        brush_builtins::BuiltinSet::ShMode
518    } else {
519        brush_builtins::BuiltinSet::BashMode
520    };
521
522    // Identify the file descriptors to inherit.
523    let fds = args
524        .inherited_fds
525        .iter()
526        .filter_map(|&fd| brush_core::sys::fd::try_get_file_for_open_fd(fd).map(|file| (fd, file)))
527        .collect();
528
529    // Select parser implementation to use.
530    #[cfg(feature = "experimental-parser")]
531    let parser_impl = if args.experimental_parser {
532        brush_core::parser::ParserImpl::Winnow
533    } else {
534        brush_core::parser::ParserImpl::Peg
535    };
536
537    #[cfg(not(feature = "experimental-parser"))]
538    let parser_impl = brush_core::parser::ParserImpl::Peg;
539
540    // Set up the shell builder with the requested options.
541    // NOTE: We skip loading profile and rc files here; that will be handled later after we've
542    // fully instantiated everything we want set before running any code.
543    let shell = brush_core::Shell::builder_with_extensions::<BrushShellExtensions>()
544        .disable_options(args.disabled_options.clone())
545        .disable_shopt_options(args.disabled_shopt_options.clone())
546        .disallow_overwriting_regular_files_via_output_redirection(
547            args.disallow_overwriting_regular_files_via_output_redirection,
548        )
549        .enable_options(args.enabled_options.clone())
550        .enable_shopt_options(args.enabled_shopt_options.clone())
551        .do_not_execute_commands(args.do_not_execute_commands)
552        .exit_after_one_command(args.exit_after_one_command)
553        .login(login)
554        .interactive(args.is_interactive())
555        .command_string_mode(args.command.is_some())
556        .no_editing(args.no_editing)
557        .profile(brush_core::ProfileLoadBehavior::Skip)
558        .rc(brush_core::RcLoadBehavior::Skip)
559        .do_not_inherit_env(args.do_not_inherit_env)
560        .fds(fds)
561        .maybe_shell_args(shell_args)
562        .posix(args.posix || args.sh_mode)
563        .print_commands_and_arguments(args.print_commands_and_arguments)
564        .read_commands_from_stdin(read_commands_from_stdin)
565        .maybe_shell_name(shell_name)
566        .shell_product_display_str(productinfo::get_product_display_str())
567        .sh_mode(args.sh_mode)
568        .treat_unset_variables_as_error(args.treat_unset_variables_as_error)
569        .exit_on_nonzero_command_exit(args.exit_on_nonzero_command_exit)
570        .disable_pathname_expansion(args.disable_pathname_expansion)
571        .verbose(args.verbose)
572        .parser(parser_impl)
573        .error_formatter(new_error_behavior(args))
574        .shell_version(env!("CARGO_PKG_VERSION").to_string());
575
576    // Add builtins.
577    let shell = shell.default_builtins(builtin_set).brush_builtins();
578
579    // Add experimental builtins (if enabled).
580    #[cfg(feature = "experimental-builtins")]
581    let shell = shell.experimental_builtins();
582
583    // Build the shell.
584    let mut shell = shell.build().await?;
585
586    // Make adjustments.
587    if let Some(xtrace_file_path) = &args.xtrace_file_path {
588        enable_xtrace_to_file(&mut shell, xtrace_file_path)?;
589    }
590
591    Ok(shell)
592}
593
594fn enable_xtrace_to_file(
595    shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
596    file_path: &Path,
597) -> Result<(), brush_interactive::ShellError> {
598    let file = std::fs::OpenOptions::new()
599        .create(true)
600        .write(true)
601        .truncate(true)
602        .open(file_path)
603        .map_err(|e| {
604            brush_interactive::ShellError::FailedToCreateXtraceFile(file_path.to_path_buf(), e)
605        })?;
606
607    let file = brush_core::openfiles::OpenFile::from(file);
608    let file_fd = shell.open_files_mut().add(file)?;
609
610    shell.options_mut().print_commands_and_arguments = true;
611    shell.set_env_global(
612        "BASH_XTRACEFD",
613        brush_core::ShellVariable::new(file_fd.to_string()),
614    )?;
615
616    Ok(())
617}
618
619const fn new_error_behavior(args: &CommandLineArgs) -> error_formatter::Formatter {
620    error_formatter::Formatter {
621        use_color: !args.disable_color,
622    }
623}
624
625fn get_default_input_backend_type(args: &CommandLineArgs) -> InputBackendType {
626    #[cfg(any(unix, windows))]
627    {
628        // If stdin isn't a terminal, then `reedline` doesn't do the right thing
629        // (reference: https://github.com/nushell/reedline/issues/509). Switch to
630        // the minimal input backend instead for that scenario.
631        if std::io::stdin().is_terminal() && will_run_interactively(args) {
632            InputBackendType::Reedline
633        } else {
634            InputBackendType::Minimal
635        }
636    }
637    #[cfg(not(any(unix, windows)))]
638    {
639        let _args = args;
640        InputBackendType::Minimal
641    }
642}
643
644pub(crate) fn get_event_config() -> Arc<tokio::sync::Mutex<Option<events::TraceEventConfig>>> {
645    TRACE_EVENT_CONFIG.clone()
646}
647
648fn try_reset_terminal_to_defaults() -> Result<(), std::io::Error> {
649    #[cfg(any(unix, windows))]
650    {
651        // Reset the console.
652        let exec_result = crossterm::execute!(
653            std::io::stdout(),
654            crossterm::terminal::LeaveAlternateScreen,
655            crossterm::terminal::EnableLineWrap,
656            crossterm::style::ResetColor,
657            crossterm::event::DisableMouseCapture,
658            crossterm::event::DisableBracketedPaste,
659            crossterm::cursor::Show,
660            crossterm::cursor::MoveToNextLine(1),
661        );
662
663        let raw_result = crossterm::terminal::disable_raw_mode();
664
665        exec_result?;
666        raw_result?;
667    }
668
669    Ok(())
670}
671
672#[cfg(test)]
673#[allow(clippy::panic_in_result_fn)]
674mod tests {
675    use super::*;
676    use anyhow::Result;
677    use pretty_assertions::{assert_eq, assert_matches};
678
679    fn args(strs: &[&str]) -> Vec<String> {
680        strs.iter().map(|s| s.to_string()).collect()
681    }
682
683    #[test]
684    fn parse_empty_args() -> Result<()> {
685        let parsed_args = CommandLineArgs::try_parse_from(args(&["brush"]))?;
686        assert_matches!(parsed_args.script_args.as_slice(), []);
687        Ok(())
688    }
689
690    #[test]
691    fn parse_script_and_args() -> Result<()> {
692        let parsed_args = CommandLineArgs::try_parse_from(args(&[
693            "brush",
694            "some-script",
695            "-x",
696            "1",
697            "--option",
698        ]))?;
699        assert_eq!(
700            parsed_args.script_args,
701            ["some-script", "-x", "1", "--option"]
702        );
703        Ok(())
704    }
705
706    #[test]
707    fn parse_script_and_args_with_double_dash_in_script_args() -> Result<()> {
708        let parsed_args = CommandLineArgs::try_parse_from(args(&["brush", "some-script", "--"]))?;
709        assert_eq!(parsed_args.script_args, ["some-script", "--"]);
710        Ok(())
711    }
712
713    #[test]
714    fn parse_unknown_args() {
715        let result = CommandLineArgs::try_parse_from(args(&["brush", "--unknown-option"]));
716        assert!(result.is_err());
717    }
718
719    #[test]
720    fn parse_c_with_double_dash_separator() -> Result<()> {
721        let parsed_args =
722            CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "echo hello", "arg0"]))?;
723        assert_eq!(parsed_args.command, Some("echo hello".to_string()));
724        assert_eq!(parsed_args.script_args, ["arg0"]);
725        Ok(())
726    }
727
728    #[test]
729    fn parse_c_with_double_dash_no_command() {
730        assert!(CommandLineArgs::try_parse_from(args(&["brush", "-c", "--"])).is_err());
731    }
732
733    #[test]
734    fn parse_c_with_double_dash_command_is_double_dash() -> Result<()> {
735        let parsed_args =
736            CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "--", "echo", "hi"]))?;
737        assert_eq!(parsed_args.command, Some("--".to_string()));
738        assert_eq!(parsed_args.script_args, ["echo", "hi"]);
739        Ok(())
740    }
741
742    #[test]
743    fn parse_ec_with_double_dash_separator() -> Result<()> {
744        let parsed_args =
745            CommandLineArgs::try_parse_from(args(&["brush", "-ec", "--", "echo hello", "arg0"]))?;
746        assert_eq!(parsed_args.command, Some("echo hello".to_string()));
747        assert!(parsed_args.exit_on_nonzero_command_exit);
748        assert_eq!(parsed_args.script_args, ["arg0"]);
749        Ok(())
750    }
751
752    #[test]
753    fn parse_c_with_value_before_double_dash_unchanged() -> Result<()> {
754        let parsed_args =
755            CommandLineArgs::try_parse_from(args(&["brush", "-c", "echo hi", "--", "arg0"]))?;
756        assert_eq!(parsed_args.command, Some("echo hi".to_string()));
757        assert_eq!(parsed_args.script_args, ["--", "arg0"]);
758        Ok(())
759    }
760
761    #[test]
762    fn parse_o_with_double_dash_is_not_transformed() {
763        // Unlike -c, bash's -o consumes -- as its literal value (invalid option
764        // name), not as an option terminator. Verify we don't transform it.
765        let result = CommandLineArgs::try_parse_from(args(&["brush", "-o", "--"]));
766        // Here, try_parse_from / try_parse_known splits at --, so -o ends up
767        // without a value and parsing correctly fails. The key assertion is
768        // that we MUST NOT reinterpret -- as an option terminator for -o and
769        // then take any later argument as its value.
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn parse_oc_not_treated_as_pending_c() -> Result<()> {
775        // -oc means -o with value "c", not -o flag + -c flag. The --
776        // should NOT be treated as an option terminator for -c.
777        let parsed_args = CommandLineArgs::try_parse_from(args(&["brush", "-oc", "--", "echo"]))?;
778        // -o consumed "c" as its value; -- split the rest; no -c command.
779        assert!(parsed_args.command.is_none());
780        assert_eq!(parsed_args.script_args, ["--", "echo"]);
781        Ok(())
782    }
783
784    #[test]
785    fn parse_bool_flag_before_double_dash_not_transformed() -> Result<()> {
786        // -e is a boolean flag, not -c. The -- should NOT be removed;
787        // everything from -- onward becomes positional (including -c).
788        let parsed_args =
789            CommandLineArgs::try_parse_from(args(&["brush", "-e", "--", "-c", "echo"]))?;
790        assert!(parsed_args.command.is_none());
791        assert!(parsed_args.exit_on_nonzero_command_exit);
792        assert_eq!(parsed_args.script_args, ["--", "-c", "echo"]);
793        Ok(())
794    }
795
796    #[test]
797    fn parse_c_with_double_dash_and_later_double_dash() -> Result<()> {
798        // After removing the first --, -c gets "echo". The second -- is
799        // handled by try_parse_known and appears in script_args.
800        let parsed_args =
801            CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "echo", "--", "more"]))?;
802        assert_eq!(parsed_args.command, Some("echo".to_string()));
803        assert_eq!(parsed_args.script_args, ["--", "more"]);
804        Ok(())
805    }
806
807    #[test]
808    fn has_pending_c_flag_edge_cases() {
809        // Direct tests for the detection function.
810        assert!(CommandLineArgs::has_pending_c_flag("-c"));
811        assert!(CommandLineArgs::has_pending_c_flag("-ec"));
812        assert!(!CommandLineArgs::has_pending_c_flag("-C")); // uppercase, different flag
813        assert!(!CommandLineArgs::has_pending_c_flag("-oc")); // -o takes a value
814        assert!(!CommandLineArgs::has_pending_c_flag("--c")); // long-option-like
815        assert!(!CommandLineArgs::has_pending_c_flag("-")); // bare dash
816        assert!(!CommandLineArgs::has_pending_c_flag("c")); // no leading dash
817        assert!(!CommandLineArgs::has_pending_c_flag("")); // empty
818    }
819}