Skip to main content

command_stream/
utils.rs

1//! Utility functions and types for command-stream
2//!
3//! This module provides helper functions for command results, virtual command
4//! utilities, and re-exports from specialized utility modules.
5//!
6//! ## Module Organization
7//!
8//! The utilities are organized into focused modules following the same
9//! modular pattern as the JavaScript implementation:
10//!
11//! - `trace` - Logging and tracing utilities
12//! - `ansi` - ANSI escape code handling
13//! - `quote` - Shell quoting utilities
14//! - `utils` (this module) - Command results and virtual command helpers
15
16use crate::result_streams::{CapturedInput, CapturedOutput};
17use std::collections::HashMap;
18use std::env;
19use std::path::{Path, PathBuf};
20
21// Re-export from specialized modules for backwards compatibility
22pub use crate::ansi::{AnsiConfig, AnsiUtils};
23pub use crate::quote::quote;
24pub use crate::trace::{is_trace_enabled, trace, trace_lazy};
25
26/// Re-export invocation-local directory variables inside POSIX shells.
27///
28/// Some shells, notably the macOS system shell, import `OLDPWD` as an
29/// internal variable but drop its export flag during startup. Prefixing the
30/// command keeps both variables visible to the real child process.
31#[cfg(unix)]
32pub(crate) fn with_exported_process_context(
33    command: &str,
34    env: Option<&HashMap<String, String>>,
35) -> String {
36    let Some(env) = env else {
37        return command.to_string();
38    };
39    let assignments = ["PWD", "OLDPWD"]
40        .into_iter()
41        .filter_map(|name| {
42            env.get(name).map(|value| {
43                let value = value.replace('\'', "'\\''");
44                format!("{name}='{value}'")
45            })
46        })
47        .collect::<Vec<_>>();
48
49    if assignments.is_empty() {
50        command.to_string()
51    } else {
52        format!("export {}; {command}", assignments.join(" "))
53    }
54}
55
56#[cfg(not(unix))]
57pub(crate) fn with_exported_process_context(
58    command: &str,
59    _env: Option<&HashMap<String, String>>,
60) -> String {
61    command.to_string()
62}
63
64#[derive(Debug, Clone)]
65struct ShellConfig {
66    cmd: String,
67    args: Vec<String>,
68    raw_command_arg: bool,
69}
70
71fn find_available_shell() -> ShellConfig {
72    #[cfg(windows)]
73    let shells: &[(&str, &[&str], bool)] = &[
74        (r"C:\Program Files\Git\bin\bash.exe", &["-c"], false),
75        (r"C:\Program Files\Git\usr\bin\bash.exe", &["-c"], false),
76        (r"C:\Program Files (x86)\Git\bin\bash.exe", &["-c"], false),
77        ("bash.exe", &["-c"], false),
78        ("wsl.exe", &["bash", "-c"], false),
79        ("powershell.exe", &["-Command"], false),
80        ("pwsh.exe", &["-Command"], false),
81        ("cmd.exe", &["/c"], true),
82    ];
83
84    #[cfg(not(windows))]
85    let shells: &[(&str, &[&str], bool)] = &[
86        ("/bin/sh", &["-c"], false),
87        ("/usr/bin/sh", &["-c"], false),
88        ("/bin/bash", &["-c"], false),
89        ("sh", &["-c"], false),
90    ];
91
92    for (cmd, args, raw_command_arg) in shells {
93        if Path::new(cmd).exists() || which::which(cmd).is_ok() {
94            return ShellConfig {
95                cmd: (*cmd).to_string(),
96                args: args.iter().map(|arg| (*arg).to_string()).collect(),
97                raw_command_arg: *raw_command_arg,
98            };
99        }
100    }
101
102    #[cfg(windows)]
103    return ShellConfig {
104        cmd: "cmd.exe".to_string(),
105        args: vec!["/c".to_string()],
106        raw_command_arg: true,
107    };
108
109    #[cfg(not(windows))]
110    ShellConfig {
111        cmd: "/bin/sh".to_string(),
112        args: vec!["-c".to_string()],
113        raw_command_arg: false,
114    }
115}
116
117#[cfg(windows)]
118fn append_command_arg(process: &mut tokio::process::Command, command: &str, raw_command_arg: bool) {
119    if raw_command_arg {
120        // `cmd.exe /c` does not use the C runtime's argument decoder. Passing
121        // the command through `arg` would therefore expose Rust's backslash
122        // escapes as literal characters. The extra outer quotes are required
123        // to preserve a quoted executable path at the start of the command.
124        use std::os::windows::process::CommandExt;
125        process.as_std_mut().raw_arg(format!("\"{command}\""));
126    } else {
127        process.arg(command);
128    }
129}
130
131#[cfg(not(windows))]
132fn append_command_arg(
133    process: &mut tokio::process::Command,
134    command: &str,
135    _raw_command_arg: bool,
136) {
137    process.arg(command);
138}
139
140/// Build a command using the best platform shell and its argument convention.
141pub(crate) fn shell_command(
142    command: &str,
143    env: Option<&HashMap<String, String>>,
144) -> tokio::process::Command {
145    let shell = find_available_shell();
146    let mut process = tokio::process::Command::new(&shell.cmd);
147    process.args(&shell.args);
148    let command = with_exported_process_context(command, env);
149    append_command_arg(&mut process, &command, shell.raw_command_arg);
150    process
151}
152
153/// Result type for virtual command operations
154#[derive(Debug, Clone)]
155pub struct CommandResult {
156    pub stdout: CapturedOutput,
157    pub stderr: CapturedOutput,
158    pub stdin: CapturedInput,
159    pub code: i32,
160}
161
162impl CommandResult {
163    /// Create a result with readable output snapshots and a writable input record.
164    pub fn new(
165        stdout: impl Into<CapturedOutput>,
166        stderr: impl Into<CapturedOutput>,
167        code: i32,
168    ) -> Self {
169        Self {
170            stdout: stdout.into(),
171            stderr: stderr.into(),
172            stdin: CapturedInput::default(),
173            code,
174        }
175    }
176
177    /// Create a success result with stdout output
178    pub fn success(stdout: impl Into<String>) -> Self {
179        Self::new(stdout.into(), "", 0)
180    }
181
182    /// Create an empty success result
183    pub fn success_empty() -> Self {
184        Self::new("", "", 0)
185    }
186
187    /// Create an error result with stderr output
188    pub fn error(stderr: impl Into<String>) -> Self {
189        Self::new("", stderr.into(), 1)
190    }
191
192    /// Create an error result with custom exit code
193    pub fn error_with_code(stderr: impl Into<String>, code: i32) -> Self {
194        Self::new("", stderr.into(), code)
195    }
196
197    /// Check if the command was successful
198    pub fn is_success(&self) -> bool {
199        self.code == 0
200    }
201
202    /// Exit code of the command.
203    ///
204    /// This is an alias for the [`code`](Self::code) field, mirroring the
205    /// `exitCode` alias exposed by the JavaScript implementation (issue #36).
206    pub fn exit_code(&self) -> i32 {
207        self.code
208    }
209
210    /// Turn a failing result into [`crate::Error::CommandFailed`].
211    ///
212    /// This is the Rust counterpart of the JavaScript `errexit` mode: a
213    /// non-zero status becomes an error whose exit status is readable through
214    /// both [`crate::Error::code`] and [`crate::Error::exit_code`] (issue
215    /// #38). Successful results pass through unchanged.
216    ///
217    /// ```
218    /// use command_stream::utils::CommandResult;
219    ///
220    /// let error = CommandResult::error_with_code("", 42)
221    ///     .error_for_status()
222    ///     .unwrap_err();
223    /// assert_eq!(error.code(), Some(42));
224    /// assert_eq!(error.exit_code(), error.code());
225    /// ```
226    pub fn error_for_status(self) -> crate::Result<CommandResult> {
227        if self.is_success() {
228            return Ok(self);
229        }
230
231        Err(crate::Error::command_failed(
232            self.code,
233            format!("Command failed with exit code {}", self.code),
234        ))
235    }
236}
237
238/// Utility functions for virtual commands
239pub struct VirtualUtils;
240
241impl VirtualUtils {
242    /// Create standardized error response for missing operands
243    pub fn missing_operand_error(command_name: &str) -> CommandResult {
244        CommandResult::error(format!("{}: missing operand", command_name))
245    }
246
247    /// Create standardized error response for missing operands with custom message
248    pub fn missing_operand_error_with_message(command_name: &str, message: &str) -> CommandResult {
249        CommandResult::error(format!("{}: {}", command_name, message))
250    }
251
252    /// Create standardized error response for invalid arguments
253    pub fn invalid_argument_error(command_name: &str, message: &str) -> CommandResult {
254        CommandResult::error(format!("{}: {}", command_name, message))
255    }
256
257    /// Create standardized success response
258    pub fn success(stdout: impl Into<String>) -> CommandResult {
259        CommandResult::success(stdout)
260    }
261
262    /// Create standardized error response
263    pub fn error(stderr: impl Into<String>) -> CommandResult {
264        CommandResult::error(stderr)
265    }
266
267    /// Validate that command has required number of arguments
268    pub fn validate_args(
269        args: &[String],
270        min_count: usize,
271        command_name: &str,
272    ) -> Option<CommandResult> {
273        if args.len() < min_count {
274            if min_count == 1 {
275                return Some(Self::missing_operand_error(command_name));
276            } else {
277                return Some(Self::invalid_argument_error(
278                    command_name,
279                    &format!("requires at least {} arguments", min_count),
280                ));
281            }
282        }
283        None // No error
284    }
285
286    /// Resolve file path with optional cwd parameter
287    pub fn resolve_path(file_path: &str, cwd: Option<&Path>) -> PathBuf {
288        let path = Path::new(file_path);
289        if path.is_absolute() {
290            path.to_path_buf()
291        } else {
292            let base_path = cwd
293                .map(|p| p.to_path_buf())
294                .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
295            base_path.join(path)
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_command_result_success() {
306        let result = CommandResult::success("hello");
307        assert!(result.is_success());
308        assert_eq!(result.stdout, "hello");
309        assert_eq!(result.stderr, "");
310        assert_eq!(result.code, 0);
311    }
312
313    #[test]
314    fn test_command_result_error() {
315        let result = CommandResult::error("something went wrong");
316        assert!(!result.is_success());
317        assert_eq!(result.stdout, "");
318        assert_eq!(result.stderr, "something went wrong");
319        assert_eq!(result.code, 1);
320    }
321
322    #[test]
323    fn test_command_result_error_with_code() {
324        let result = CommandResult::error_with_code("permission denied", 126);
325        assert!(!result.is_success());
326        assert_eq!(result.code, 126);
327    }
328
329    #[test]
330    fn test_resolve_path_absolute() {
331        let absolute_path = if cfg!(windows) {
332            PathBuf::from(r"C:\absolute\path")
333        } else {
334            PathBuf::from("/absolute/path")
335        };
336        let path = VirtualUtils::resolve_path(absolute_path.to_str().unwrap(), None);
337        assert_eq!(path, absolute_path);
338    }
339
340    #[test]
341    fn test_resolve_path_relative() {
342        let cwd = PathBuf::from("/home/user");
343        let path = VirtualUtils::resolve_path("relative/path", Some(&cwd));
344        assert_eq!(path, PathBuf::from("/home/user/relative/path"));
345    }
346
347    #[test]
348    fn test_validate_args_success() {
349        let args = vec!["arg1".to_string()];
350        assert!(VirtualUtils::validate_args(&args, 1, "cmd").is_none());
351    }
352
353    #[test]
354    fn test_validate_args_missing() {
355        let args = vec!["arg1".to_string()];
356        let result = VirtualUtils::validate_args(&args, 2, "cmd");
357        assert!(result.is_some());
358    }
359
360    #[test]
361    fn test_missing_operand_error() {
362        let result = VirtualUtils::missing_operand_error("cat");
363        assert!(!result.is_success());
364        assert!(result.stderr.contains("missing operand"));
365    }
366
367    #[test]
368    fn test_invalid_argument_error() {
369        let result = VirtualUtils::invalid_argument_error("ls", "invalid option");
370        assert!(!result.is_success());
371        assert!(result.stderr.contains("invalid option"));
372    }
373
374    // Re-exported module tests are in their respective modules
375    // These tests verify the re-exports work correctly
376
377    #[test]
378    fn test_reexported_quote() {
379        assert_eq!(quote("hello"), "hello");
380        assert_eq!(quote("hello world"), "'hello world'");
381    }
382
383    #[test]
384    fn test_reexported_ansi_utils() {
385        let text = "\x1b[31mRed text\x1b[0m";
386        assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
387    }
388
389    #[test]
390    fn test_reexported_ansi_config() {
391        let config = AnsiConfig::default();
392        assert!(config.preserve_ansi);
393        assert!(config.preserve_control_chars);
394    }
395
396    #[cfg(unix)]
397    #[test]
398    fn safely_exports_invocation_directory_variables() {
399        let env = HashMap::from([
400            ("PWD".to_string(), "/tmp/new dir".to_string()),
401            (
402                "OLDPWD".to_string(),
403                "/tmp/old' dir\n$() `cmd`; end".to_string(),
404            ),
405        ]);
406
407        assert_eq!(
408            with_exported_process_context("printf done", Some(&env)),
409            "export PWD='/tmp/new dir' OLDPWD='/tmp/old'\\'' dir\n$() `cmd`; end'; printf done"
410        );
411    }
412}