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/// Append a command string using the platform shell's argument convention.
64pub(crate) fn append_shell_command(
65    process: &mut tokio::process::Command,
66    command: &str,
67    env: Option<&HashMap<String, String>>,
68) {
69    let command = with_exported_process_context(command, env);
70
71    #[cfg(windows)]
72    {
73        // `cmd.exe /c` does not use the C runtime's argument decoder. Passing
74        // the command through `arg` would therefore expose Rust's backslash
75        // escapes as literal characters. The extra outer quotes are required
76        // to preserve a quoted executable path at the start of the command.
77        use std::os::windows::process::CommandExt;
78        process.as_std_mut().raw_arg(format!("\"{command}\""));
79    }
80
81    #[cfg(not(windows))]
82    process.arg(command);
83}
84
85/// Result type for virtual command operations
86#[derive(Debug, Clone)]
87pub struct CommandResult {
88    pub stdout: String,
89    pub stderr: String,
90    pub code: i32,
91}
92
93impl CommandResult {
94    /// Create a success result with stdout output
95    pub fn success(stdout: impl Into<String>) -> Self {
96        CommandResult {
97            stdout: stdout.into(),
98            stderr: String::new(),
99            code: 0,
100        }
101    }
102
103    /// Create an empty success result
104    pub fn success_empty() -> Self {
105        CommandResult {
106            stdout: String::new(),
107            stderr: String::new(),
108            code: 0,
109        }
110    }
111
112    /// Create an error result with stderr output
113    pub fn error(stderr: impl Into<String>) -> Self {
114        CommandResult {
115            stdout: String::new(),
116            stderr: stderr.into(),
117            code: 1,
118        }
119    }
120
121    /// Create an error result with custom exit code
122    pub fn error_with_code(stderr: impl Into<String>, code: i32) -> Self {
123        CommandResult {
124            stdout: String::new(),
125            stderr: stderr.into(),
126            code,
127        }
128    }
129
130    /// Check if the command was successful
131    pub fn is_success(&self) -> bool {
132        self.code == 0
133    }
134
135    /// Exit code of the command.
136    ///
137    /// This is an alias for the [`code`](Self::code) field, mirroring the
138    /// `exitCode` alias exposed by the JavaScript implementation (issue #36).
139    pub fn exit_code(&self) -> i32 {
140        self.code
141    }
142
143    /// Turn a failing result into [`crate::Error::CommandFailed`].
144    ///
145    /// This is the Rust counterpart of the JavaScript `errexit` mode: a
146    /// non-zero status becomes an error whose exit status is readable through
147    /// both [`crate::Error::code`] and [`crate::Error::exit_code`] (issue
148    /// #38). Successful results pass through unchanged.
149    ///
150    /// ```
151    /// use command_stream::utils::CommandResult;
152    ///
153    /// let error = CommandResult::error_with_code("", 42)
154    ///     .error_for_status()
155    ///     .unwrap_err();
156    /// assert_eq!(error.code(), Some(42));
157    /// assert_eq!(error.exit_code(), error.code());
158    /// ```
159    pub fn error_for_status(self) -> crate::Result<CommandResult> {
160        if self.is_success() {
161            return Ok(self);
162        }
163
164        Err(crate::Error::command_failed(
165            self.code,
166            format!("Command failed with exit code {}", self.code),
167        ))
168    }
169}
170
171/// Utility functions for virtual commands
172pub struct VirtualUtils;
173
174impl VirtualUtils {
175    /// Create standardized error response for missing operands
176    pub fn missing_operand_error(command_name: &str) -> CommandResult {
177        CommandResult::error(format!("{}: missing operand", command_name))
178    }
179
180    /// Create standardized error response for missing operands with custom message
181    pub fn missing_operand_error_with_message(command_name: &str, message: &str) -> CommandResult {
182        CommandResult::error(format!("{}: {}", command_name, message))
183    }
184
185    /// Create standardized error response for invalid arguments
186    pub fn invalid_argument_error(command_name: &str, message: &str) -> CommandResult {
187        CommandResult::error(format!("{}: {}", command_name, message))
188    }
189
190    /// Create standardized success response
191    pub fn success(stdout: impl Into<String>) -> CommandResult {
192        CommandResult::success(stdout)
193    }
194
195    /// Create standardized error response
196    pub fn error(stderr: impl Into<String>) -> CommandResult {
197        CommandResult::error(stderr)
198    }
199
200    /// Validate that command has required number of arguments
201    pub fn validate_args(
202        args: &[String],
203        min_count: usize,
204        command_name: &str,
205    ) -> Option<CommandResult> {
206        if args.len() < min_count {
207            if min_count == 1 {
208                return Some(Self::missing_operand_error(command_name));
209            } else {
210                return Some(Self::invalid_argument_error(
211                    command_name,
212                    &format!("requires at least {} arguments", min_count),
213                ));
214            }
215        }
216        None // No error
217    }
218
219    /// Resolve file path with optional cwd parameter
220    pub fn resolve_path(file_path: &str, cwd: Option<&Path>) -> PathBuf {
221        let path = Path::new(file_path);
222        if path.is_absolute() {
223            path.to_path_buf()
224        } else {
225            let base_path = cwd
226                .map(|p| p.to_path_buf())
227                .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
228            base_path.join(path)
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_command_result_success() {
239        let result = CommandResult::success("hello");
240        assert!(result.is_success());
241        assert_eq!(result.stdout, "hello");
242        assert_eq!(result.stderr, "");
243        assert_eq!(result.code, 0);
244    }
245
246    #[test]
247    fn test_command_result_error() {
248        let result = CommandResult::error("something went wrong");
249        assert!(!result.is_success());
250        assert_eq!(result.stdout, "");
251        assert_eq!(result.stderr, "something went wrong");
252        assert_eq!(result.code, 1);
253    }
254
255    #[test]
256    fn test_command_result_error_with_code() {
257        let result = CommandResult::error_with_code("permission denied", 126);
258        assert!(!result.is_success());
259        assert_eq!(result.code, 126);
260    }
261
262    #[test]
263    fn test_resolve_path_absolute() {
264        let absolute_path = if cfg!(windows) {
265            PathBuf::from(r"C:\absolute\path")
266        } else {
267            PathBuf::from("/absolute/path")
268        };
269        let path = VirtualUtils::resolve_path(absolute_path.to_str().unwrap(), None);
270        assert_eq!(path, absolute_path);
271    }
272
273    #[test]
274    fn test_resolve_path_relative() {
275        let cwd = PathBuf::from("/home/user");
276        let path = VirtualUtils::resolve_path("relative/path", Some(&cwd));
277        assert_eq!(path, PathBuf::from("/home/user/relative/path"));
278    }
279
280    #[test]
281    fn test_validate_args_success() {
282        let args = vec!["arg1".to_string()];
283        assert!(VirtualUtils::validate_args(&args, 1, "cmd").is_none());
284    }
285
286    #[test]
287    fn test_validate_args_missing() {
288        let args = vec!["arg1".to_string()];
289        let result = VirtualUtils::validate_args(&args, 2, "cmd");
290        assert!(result.is_some());
291    }
292
293    #[test]
294    fn test_missing_operand_error() {
295        let result = VirtualUtils::missing_operand_error("cat");
296        assert!(!result.is_success());
297        assert!(result.stderr.contains("missing operand"));
298    }
299
300    #[test]
301    fn test_invalid_argument_error() {
302        let result = VirtualUtils::invalid_argument_error("ls", "invalid option");
303        assert!(!result.is_success());
304        assert!(result.stderr.contains("invalid option"));
305    }
306
307    // Re-exported module tests are in their respective modules
308    // These tests verify the re-exports work correctly
309
310    #[test]
311    fn test_reexported_quote() {
312        assert_eq!(quote("hello"), "hello");
313        assert_eq!(quote("hello world"), "'hello world'");
314    }
315
316    #[test]
317    fn test_reexported_ansi_utils() {
318        let text = "\x1b[31mRed text\x1b[0m";
319        assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
320    }
321
322    #[test]
323    fn test_reexported_ansi_config() {
324        let config = AnsiConfig::default();
325        assert!(config.preserve_ansi);
326        assert!(config.preserve_control_chars);
327    }
328
329    #[cfg(unix)]
330    #[test]
331    fn safely_exports_invocation_directory_variables() {
332        let env = HashMap::from([
333            ("PWD".to_string(), "/tmp/new dir".to_string()),
334            (
335                "OLDPWD".to_string(),
336                "/tmp/old' dir\n$() `cmd`; end".to_string(),
337            ),
338        ]);
339
340        assert_eq!(
341            with_exported_process_context("printf done", Some(&env)),
342            "export PWD='/tmp/new dir' OLDPWD='/tmp/old'\\'' dir\n$() `cmd`; end'; printf done"
343        );
344    }
345}