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 std::collections::HashMap;
17use std::env;
18use std::path::{Path, PathBuf};
19
20// Re-export from specialized modules for backwards compatibility
21pub use crate::ansi::{AnsiConfig, AnsiUtils};
22pub use crate::quote::quote;
23pub use crate::trace::{is_trace_enabled, trace, trace_lazy};
24
25/// Re-export invocation-local directory variables inside POSIX shells.
26///
27/// Some shells, notably the macOS system shell, import `OLDPWD` as an
28/// internal variable but drop its export flag during startup. Prefixing the
29/// command keeps both variables visible to the real child process.
30#[cfg(unix)]
31pub(crate) fn with_exported_process_context(
32    command: &str,
33    env: Option<&HashMap<String, String>>,
34) -> String {
35    let Some(env) = env else {
36        return command.to_string();
37    };
38    let assignments = ["PWD", "OLDPWD"]
39        .into_iter()
40        .filter_map(|name| {
41            env.get(name).map(|value| {
42                let value = value.replace('\'', "'\\''");
43                format!("{name}='{value}'")
44            })
45        })
46        .collect::<Vec<_>>();
47
48    if assignments.is_empty() {
49        command.to_string()
50    } else {
51        format!("export {}; {command}", assignments.join(" "))
52    }
53}
54
55#[cfg(not(unix))]
56pub(crate) fn with_exported_process_context(
57    command: &str,
58    _env: Option<&HashMap<String, String>>,
59) -> String {
60    command.to_string()
61}
62
63#[derive(Debug, Clone)]
64struct ShellConfig {
65    cmd: String,
66    args: Vec<String>,
67    raw_command_arg: bool,
68}
69
70fn find_available_shell() -> ShellConfig {
71    #[cfg(windows)]
72    let shells: &[(&str, &[&str], bool)] = &[
73        (r"C:\Program Files\Git\bin\bash.exe", &["-c"], false),
74        (r"C:\Program Files\Git\usr\bin\bash.exe", &["-c"], false),
75        (r"C:\Program Files (x86)\Git\bin\bash.exe", &["-c"], false),
76        ("bash.exe", &["-c"], false),
77        ("wsl.exe", &["bash", "-c"], false),
78        ("powershell.exe", &["-Command"], false),
79        ("pwsh.exe", &["-Command"], false),
80        ("cmd.exe", &["/c"], true),
81    ];
82
83    #[cfg(not(windows))]
84    let shells: &[(&str, &[&str], bool)] = &[
85        ("/bin/sh", &["-c"], false),
86        ("/usr/bin/sh", &["-c"], false),
87        ("/bin/bash", &["-c"], false),
88        ("sh", &["-c"], false),
89    ];
90
91    for (cmd, args, raw_command_arg) in shells {
92        if Path::new(cmd).exists() || which::which(cmd).is_ok() {
93            return ShellConfig {
94                cmd: (*cmd).to_string(),
95                args: args.iter().map(|arg| (*arg).to_string()).collect(),
96                raw_command_arg: *raw_command_arg,
97            };
98        }
99    }
100
101    #[cfg(windows)]
102    return ShellConfig {
103        cmd: "cmd.exe".to_string(),
104        args: vec!["/c".to_string()],
105        raw_command_arg: true,
106    };
107
108    #[cfg(not(windows))]
109    ShellConfig {
110        cmd: "/bin/sh".to_string(),
111        args: vec!["-c".to_string()],
112        raw_command_arg: false,
113    }
114}
115
116#[cfg(windows)]
117fn append_command_arg(process: &mut tokio::process::Command, command: &str, raw_command_arg: bool) {
118    if raw_command_arg {
119        // `cmd.exe /c` does not use the C runtime's argument decoder. Passing
120        // the command through `arg` would therefore expose Rust's backslash
121        // escapes as literal characters. The extra outer quotes are required
122        // to preserve a quoted executable path at the start of the command.
123        use std::os::windows::process::CommandExt;
124        process.as_std_mut().raw_arg(format!("\"{command}\""));
125    } else {
126        process.arg(command);
127    }
128}
129
130#[cfg(not(windows))]
131fn append_command_arg(
132    process: &mut tokio::process::Command,
133    command: &str,
134    _raw_command_arg: bool,
135) {
136    process.arg(command);
137}
138
139/// Build a command using the best platform shell and its argument convention.
140pub(crate) fn shell_command(
141    command: &str,
142    env: Option<&HashMap<String, String>>,
143) -> tokio::process::Command {
144    let shell = find_available_shell();
145    let mut process = tokio::process::Command::new(&shell.cmd);
146    process.args(&shell.args);
147    let command = with_exported_process_context(command, env);
148    append_command_arg(&mut process, &command, shell.raw_command_arg);
149    process
150}
151
152/// Result type for virtual command operations
153#[derive(Debug, Clone)]
154pub struct CommandResult {
155    pub stdout: String,
156    pub stderr: String,
157    pub code: i32,
158}
159
160impl CommandResult {
161    /// Create a success result with stdout output
162    pub fn success(stdout: impl Into<String>) -> Self {
163        CommandResult {
164            stdout: stdout.into(),
165            stderr: String::new(),
166            code: 0,
167        }
168    }
169
170    /// Create an empty success result
171    pub fn success_empty() -> Self {
172        CommandResult {
173            stdout: String::new(),
174            stderr: String::new(),
175            code: 0,
176        }
177    }
178
179    /// Create an error result with stderr output
180    pub fn error(stderr: impl Into<String>) -> Self {
181        CommandResult {
182            stdout: String::new(),
183            stderr: stderr.into(),
184            code: 1,
185        }
186    }
187
188    /// Create an error result with custom exit code
189    pub fn error_with_code(stderr: impl Into<String>, code: i32) -> Self {
190        CommandResult {
191            stdout: String::new(),
192            stderr: stderr.into(),
193            code,
194        }
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}