command-stream 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "command-stream",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "js/src/$.mjs",
@@ -0,0 +1,194 @@
1
+ //! ANSI control character utilities for command-stream
2
+ //!
3
+ //! This module handles stripping and processing of ANSI escape codes
4
+ //! and control characters from text output.
5
+
6
+ /// ANSI control character utilities
7
+ pub struct AnsiUtils;
8
+
9
+ impl AnsiUtils {
10
+ /// Strip ANSI escape sequences from text
11
+ ///
12
+ /// Removes color codes, cursor movement, and other ANSI escape sequences
13
+ /// while preserving the actual text content.
14
+ ///
15
+ /// # Examples
16
+ ///
17
+ /// ```
18
+ /// use command_stream::ansi::AnsiUtils;
19
+ ///
20
+ /// let text = "\x1b[31mRed text\x1b[0m";
21
+ /// assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
22
+ /// ```
23
+ pub fn strip_ansi(text: &str) -> String {
24
+ let re = regex::Regex::new(r"\x1b\[[0-9;]*[mGKHFJ]").unwrap();
25
+ re.replace_all(text, "").to_string()
26
+ }
27
+
28
+ /// Strip control characters from text, preserving newlines, carriage returns, and tabs
29
+ ///
30
+ /// Removes control characters (ASCII 0x00-0x1F and 0x7F) except:
31
+ /// - Newlines (\n = 0x0A)
32
+ /// - Carriage returns (\r = 0x0D)
33
+ /// - Tabs (\t = 0x09)
34
+ ///
35
+ /// # Examples
36
+ ///
37
+ /// ```
38
+ /// use command_stream::ansi::AnsiUtils;
39
+ ///
40
+ /// let text = "Hello\x00World\nNew line\tTab";
41
+ /// assert_eq!(AnsiUtils::strip_control_chars(text), "HelloWorld\nNew line\tTab");
42
+ /// ```
43
+ pub fn strip_control_chars(text: &str) -> String {
44
+ text.chars()
45
+ .filter(|c| {
46
+ // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
47
+ !matches!(*c as u32,
48
+ 0x00..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F
49
+ )
50
+ })
51
+ .collect()
52
+ }
53
+
54
+ /// Strip both ANSI sequences and control characters
55
+ ///
56
+ /// Combines `strip_ansi` and `strip_control_chars` for complete text cleaning.
57
+ pub fn strip_all(text: &str) -> String {
58
+ Self::strip_control_chars(&Self::strip_ansi(text))
59
+ }
60
+
61
+ /// Clean data for processing (strips ANSI and control chars)
62
+ ///
63
+ /// Alias for `strip_all` - provides semantic clarity when processing
64
+ /// data that needs to be cleaned for further processing.
65
+ pub fn clean_for_processing(data: &str) -> String {
66
+ Self::strip_all(data)
67
+ }
68
+ }
69
+
70
+ /// Configuration for ANSI handling
71
+ ///
72
+ /// Controls how ANSI escape codes and control characters are processed
73
+ /// in command output.
74
+ #[derive(Debug, Clone)]
75
+ pub struct AnsiConfig {
76
+ /// Whether to preserve ANSI escape sequences in output
77
+ pub preserve_ansi: bool,
78
+ /// Whether to preserve control characters in output
79
+ pub preserve_control_chars: bool,
80
+ }
81
+
82
+ impl Default for AnsiConfig {
83
+ fn default() -> Self {
84
+ AnsiConfig {
85
+ preserve_ansi: true,
86
+ preserve_control_chars: true,
87
+ }
88
+ }
89
+ }
90
+
91
+ impl AnsiConfig {
92
+ /// Create a new AnsiConfig that preserves everything (default)
93
+ pub fn new() -> Self {
94
+ Self::default()
95
+ }
96
+
97
+ /// Create a config that strips all ANSI and control characters
98
+ pub fn strip_all() -> Self {
99
+ AnsiConfig {
100
+ preserve_ansi: false,
101
+ preserve_control_chars: false,
102
+ }
103
+ }
104
+
105
+ /// Process output according to config settings
106
+ ///
107
+ /// Applies the configured stripping rules to the input data.
108
+ pub fn process_output(&self, data: &str) -> String {
109
+ if !self.preserve_ansi && !self.preserve_control_chars {
110
+ AnsiUtils::clean_for_processing(data)
111
+ } else if !self.preserve_ansi {
112
+ AnsiUtils::strip_ansi(data)
113
+ } else if !self.preserve_control_chars {
114
+ AnsiUtils::strip_control_chars(data)
115
+ } else {
116
+ data.to_string()
117
+ }
118
+ }
119
+ }
120
+
121
+ #[cfg(test)]
122
+ mod tests {
123
+ use super::*;
124
+
125
+ #[test]
126
+ fn test_strip_ansi() {
127
+ let text = "\x1b[31mRed text\x1b[0m";
128
+ assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
129
+ }
130
+
131
+ #[test]
132
+ fn test_strip_ansi_multiple_codes() {
133
+ let text = "\x1b[1m\x1b[32mBold Green\x1b[0m Normal";
134
+ assert_eq!(AnsiUtils::strip_ansi(text), "Bold Green Normal");
135
+ }
136
+
137
+ #[test]
138
+ fn test_strip_control_chars() {
139
+ let text = "Hello\x00World\nNew line\tTab";
140
+ assert_eq!(
141
+ AnsiUtils::strip_control_chars(text),
142
+ "HelloWorld\nNew line\tTab"
143
+ );
144
+ }
145
+
146
+ #[test]
147
+ fn test_strip_control_chars_preserves_whitespace() {
148
+ let text = "Line1\nLine2\r\nLine3\tTabbed";
149
+ assert_eq!(
150
+ AnsiUtils::strip_control_chars(text),
151
+ "Line1\nLine2\r\nLine3\tTabbed"
152
+ );
153
+ }
154
+
155
+ #[test]
156
+ fn test_strip_all() {
157
+ let text = "\x1b[31mRed\x00text\x1b[0m";
158
+ assert_eq!(AnsiUtils::strip_all(text), "Redtext");
159
+ }
160
+
161
+ #[test]
162
+ fn test_ansi_config_default() {
163
+ let config = AnsiConfig::default();
164
+ let text = "\x1b[31mRed\x00text\x1b[0m";
165
+ assert_eq!(config.process_output(text), text);
166
+ }
167
+
168
+ #[test]
169
+ fn test_ansi_config_strip_all() {
170
+ let config = AnsiConfig::strip_all();
171
+ let text = "\x1b[31mRed\x00text\x1b[0m";
172
+ assert_eq!(config.process_output(text), "Redtext");
173
+ }
174
+
175
+ #[test]
176
+ fn test_ansi_config_strip_ansi_only() {
177
+ let config = AnsiConfig {
178
+ preserve_ansi: false,
179
+ preserve_control_chars: true,
180
+ };
181
+ let text = "\x1b[31mRed text\x1b[0m";
182
+ assert_eq!(config.process_output(text), "Red text");
183
+ }
184
+
185
+ #[test]
186
+ fn test_ansi_config_strip_control_only() {
187
+ let config = AnsiConfig {
188
+ preserve_ansi: true,
189
+ preserve_control_chars: false,
190
+ };
191
+ let text = "Hello\x00World";
192
+ assert_eq!(config.process_output(text), "HelloWorld");
193
+ }
194
+ }
@@ -0,0 +1,305 @@
1
+ //! Event emitter for stream events
2
+ //!
3
+ //! This module provides an EventEmitter-like implementation for ProcessRunner
4
+ //! events, similar to the JavaScript StreamEmitter class.
5
+
6
+ use std::collections::HashMap;
7
+ use std::sync::Arc;
8
+ use tokio::sync::RwLock;
9
+
10
+ use crate::trace::trace_lazy;
11
+
12
+ /// Event types that can be emitted by ProcessRunner
13
+ #[derive(Debug, Clone, Hash, Eq, PartialEq)]
14
+ pub enum EventType {
15
+ /// Stdout data received
16
+ Stdout,
17
+ /// Stderr data received
18
+ Stderr,
19
+ /// Combined data event (contains type and data)
20
+ Data,
21
+ /// Process ended
22
+ End,
23
+ /// Process exited with code
24
+ Exit,
25
+ /// Error occurred
26
+ Error,
27
+ /// Process spawned
28
+ Spawn,
29
+ }
30
+
31
+ impl std::fmt::Display for EventType {
32
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33
+ match self {
34
+ EventType::Stdout => write!(f, "stdout"),
35
+ EventType::Stderr => write!(f, "stderr"),
36
+ EventType::Data => write!(f, "data"),
37
+ EventType::End => write!(f, "end"),
38
+ EventType::Exit => write!(f, "exit"),
39
+ EventType::Error => write!(f, "error"),
40
+ EventType::Spawn => write!(f, "spawn"),
41
+ }
42
+ }
43
+ }
44
+
45
+ /// Event data variants
46
+ #[derive(Debug, Clone)]
47
+ pub enum EventData {
48
+ /// String data (for stdout, stderr)
49
+ String(String),
50
+ /// Exit code
51
+ ExitCode(i32),
52
+ /// Data event with type and content
53
+ TypedData { data_type: String, data: String },
54
+ /// Command result
55
+ Result(crate::CommandResult),
56
+ /// Error message
57
+ Error(String),
58
+ /// No data
59
+ None,
60
+ }
61
+
62
+ /// Type alias for event listeners
63
+ type Listener = Arc<dyn Fn(EventData) + Send + Sync>;
64
+
65
+ /// Event emitter for ProcessRunner events
66
+ ///
67
+ /// Provides on(), once(), off(), and emit() methods similar to Node.js EventEmitter.
68
+ pub struct StreamEmitter {
69
+ listeners: RwLock<HashMap<EventType, Vec<Listener>>>,
70
+ }
71
+
72
+ impl Default for StreamEmitter {
73
+ fn default() -> Self {
74
+ Self::new()
75
+ }
76
+ }
77
+
78
+ impl StreamEmitter {
79
+ /// Create a new event emitter
80
+ pub fn new() -> Self {
81
+ StreamEmitter {
82
+ listeners: RwLock::new(HashMap::new()),
83
+ }
84
+ }
85
+
86
+ /// Register a listener for an event
87
+ ///
88
+ /// # Arguments
89
+ /// * `event` - The event type to listen for
90
+ /// * `listener` - The callback function to invoke
91
+ ///
92
+ /// # Example
93
+ /// ```ignore
94
+ /// emitter.on(EventType::Stdout, |data| {
95
+ /// if let EventData::String(s) = data {
96
+ /// println!("Got stdout: {}", s);
97
+ /// }
98
+ /// });
99
+ /// ```
100
+ pub async fn on<F>(&self, event: EventType, listener: F)
101
+ where
102
+ F: Fn(EventData) + Send + Sync + 'static,
103
+ {
104
+ trace_lazy("StreamEmitter", || {
105
+ format!("on() called for event: {}", event)
106
+ });
107
+
108
+ let mut listeners = self.listeners.write().await;
109
+ listeners
110
+ .entry(event)
111
+ .or_default()
112
+ .push(Arc::new(listener));
113
+ }
114
+
115
+ /// Register a one-time listener for an event
116
+ ///
117
+ /// The listener will be removed after it is invoked once.
118
+ pub async fn once<F>(&self, event: EventType, listener: F)
119
+ where
120
+ F: Fn(EventData) + Send + Sync + 'static,
121
+ {
122
+ trace_lazy("StreamEmitter", || {
123
+ format!("once() called for event: {}", event)
124
+ });
125
+
126
+ // Wrap the listener to track if it's been called
127
+ let called = Arc::new(std::sync::atomic::AtomicBool::new(false));
128
+ let called_clone = called.clone();
129
+
130
+ let once_listener = move |data: EventData| {
131
+ if !called_clone.swap(true, std::sync::atomic::Ordering::SeqCst) {
132
+ listener(data);
133
+ }
134
+ };
135
+
136
+ self.on(event, once_listener).await;
137
+ }
138
+
139
+ /// Emit an event to all registered listeners
140
+ ///
141
+ /// # Arguments
142
+ /// * `event` - The event type to emit
143
+ /// * `data` - The event data to pass to listeners
144
+ pub async fn emit(&self, event: EventType, data: EventData) {
145
+ let listeners = self.listeners.read().await;
146
+
147
+ if let Some(event_listeners) = listeners.get(&event) {
148
+ trace_lazy("StreamEmitter", || {
149
+ format!(
150
+ "Emitting event {} to {} listeners",
151
+ event,
152
+ event_listeners.len()
153
+ )
154
+ });
155
+
156
+ for listener in event_listeners {
157
+ listener(data.clone());
158
+ }
159
+ }
160
+ }
161
+
162
+ /// Remove all listeners for an event
163
+ ///
164
+ /// # Arguments
165
+ /// * `event` - The event type to clear listeners for
166
+ pub async fn off(&self, event: EventType) {
167
+ trace_lazy("StreamEmitter", || {
168
+ format!("off() called for event: {}", event)
169
+ });
170
+
171
+ let mut listeners = self.listeners.write().await;
172
+ listeners.remove(&event);
173
+ }
174
+
175
+ /// Get the number of listeners for an event
176
+ pub async fn listener_count(&self, event: &EventType) -> usize {
177
+ let listeners = self.listeners.read().await;
178
+ listeners.get(event).map(|v| v.len()).unwrap_or(0)
179
+ }
180
+
181
+ /// Remove all listeners for all events
182
+ pub async fn remove_all_listeners(&self) {
183
+ trace_lazy("StreamEmitter", || "Removing all listeners".to_string());
184
+ let mut listeners = self.listeners.write().await;
185
+ listeners.clear();
186
+ }
187
+ }
188
+
189
+ impl std::fmt::Debug for StreamEmitter {
190
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191
+ f.debug_struct("StreamEmitter")
192
+ .field("listeners", &"<RwLock<HashMap<...>>>")
193
+ .finish()
194
+ }
195
+ }
196
+
197
+ #[cfg(test)]
198
+ mod tests {
199
+ use super::*;
200
+ use std::sync::atomic::{AtomicUsize, Ordering};
201
+
202
+ #[tokio::test]
203
+ async fn test_emit_basic() {
204
+ let emitter = StreamEmitter::new();
205
+ let counter = Arc::new(AtomicUsize::new(0));
206
+ let counter_clone = counter.clone();
207
+
208
+ emitter
209
+ .on(EventType::Stdout, move |_| {
210
+ counter_clone.fetch_add(1, Ordering::SeqCst);
211
+ })
212
+ .await;
213
+
214
+ emitter
215
+ .emit(EventType::Stdout, EventData::String("test".to_string()))
216
+ .await;
217
+
218
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
219
+ }
220
+
221
+ #[tokio::test]
222
+ async fn test_once() {
223
+ let emitter = StreamEmitter::new();
224
+ let counter = Arc::new(AtomicUsize::new(0));
225
+ let counter_clone = counter.clone();
226
+
227
+ emitter
228
+ .once(EventType::Exit, move |_| {
229
+ counter_clone.fetch_add(1, Ordering::SeqCst);
230
+ })
231
+ .await;
232
+
233
+ // Emit twice
234
+ emitter.emit(EventType::Exit, EventData::ExitCode(0)).await;
235
+ emitter.emit(EventType::Exit, EventData::ExitCode(0)).await;
236
+
237
+ // Should only be called once
238
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
239
+ }
240
+
241
+ #[tokio::test]
242
+ async fn test_off() {
243
+ let emitter = StreamEmitter::new();
244
+ let counter = Arc::new(AtomicUsize::new(0));
245
+ let counter_clone = counter.clone();
246
+
247
+ emitter
248
+ .on(EventType::Stdout, move |_| {
249
+ counter_clone.fetch_add(1, Ordering::SeqCst);
250
+ })
251
+ .await;
252
+
253
+ emitter.off(EventType::Stdout).await;
254
+ emitter
255
+ .emit(EventType::Stdout, EventData::String("test".to_string()))
256
+ .await;
257
+
258
+ assert_eq!(counter.load(Ordering::SeqCst), 0);
259
+ }
260
+
261
+ #[tokio::test]
262
+ async fn test_listener_count() {
263
+ let emitter = StreamEmitter::new();
264
+
265
+ assert_eq!(emitter.listener_count(&EventType::Stdout).await, 0);
266
+
267
+ emitter.on(EventType::Stdout, |_| {}).await;
268
+ assert_eq!(emitter.listener_count(&EventType::Stdout).await, 1);
269
+
270
+ emitter.on(EventType::Stdout, |_| {}).await;
271
+ assert_eq!(emitter.listener_count(&EventType::Stdout).await, 2);
272
+ }
273
+
274
+ #[tokio::test]
275
+ async fn test_multiple_events() {
276
+ let emitter = StreamEmitter::new();
277
+ let stdout_counter = Arc::new(AtomicUsize::new(0));
278
+ let stderr_counter = Arc::new(AtomicUsize::new(0));
279
+
280
+ let stdout_clone = stdout_counter.clone();
281
+ let stderr_clone = stderr_counter.clone();
282
+
283
+ emitter
284
+ .on(EventType::Stdout, move |_| {
285
+ stdout_clone.fetch_add(1, Ordering::SeqCst);
286
+ })
287
+ .await;
288
+
289
+ emitter
290
+ .on(EventType::Stderr, move |_| {
291
+ stderr_clone.fetch_add(1, Ordering::SeqCst);
292
+ })
293
+ .await;
294
+
295
+ emitter
296
+ .emit(EventType::Stdout, EventData::String("out".to_string()))
297
+ .await;
298
+ emitter
299
+ .emit(EventType::Stderr, EventData::String("err".to_string()))
300
+ .await;
301
+
302
+ assert_eq!(stdout_counter.load(Ordering::SeqCst), 1);
303
+ assert_eq!(stderr_counter.load(Ordering::SeqCst), 1);
304
+ }
305
+ }
package/rust/src/lib.rs CHANGED
@@ -9,40 +9,93 @@
9
9
  //!
10
10
  //! - Async command execution with tokio
11
11
  //! - Streaming output via async iterators
12
+ //! - Event-based output handling (on, once, emit)
12
13
  //! - Virtual commands for common operations (cat, ls, mkdir, etc.)
13
14
  //! - Shell operator support (&&, ||, ;, |)
15
+ //! - Pipeline support with `.pipe()` method and `Pipeline` builder
16
+ //! - Global state management for shell settings
17
+ //! - `cmd!` macro for ergonomic command creation (similar to JS `$` tagged template literals)
14
18
  //! - Cross-platform support
15
19
  //!
20
+ //! ## Module Organization
21
+ //!
22
+ //! The codebase follows a modular architecture similar to the JavaScript implementation:
23
+ //!
24
+ //! - `ansi` - ANSI escape code handling utilities
25
+ //! - `commands` - Virtual command implementations
26
+ //! - `events` - Event emitter for stream events
27
+ //! - `macros` - The `cmd!` macro for ergonomic command creation
28
+ //! - `pipeline` - Pipeline execution support
29
+ //! - `quote` - Shell quoting utilities
30
+ //! - `shell_parser` - Shell command parsing
31
+ //! - `state` - Global state management
32
+ //! - `stream` - Async streaming and iteration support
33
+ //! - `trace` - Logging and tracing utilities
34
+ //! - `utils` - Command results and virtual command helpers
35
+ //!
16
36
  //! ## Quick Start
17
37
  //!
18
38
  //! ```rust,no_run
19
- //! use command_stream::run;
39
+ //! use command_stream::{run, cmd};
20
40
  //!
21
41
  //! #[tokio::main]
22
42
  //! async fn main() -> Result<(), Box<dyn std::error::Error>> {
23
43
  //! // Execute a simple command
24
44
  //! let result = run("echo hello world").await?;
25
45
  //! println!("{}", result.stdout);
46
+ //!
47
+ //! // Using the cmd! macro (similar to JS $ tagged template)
48
+ //! let name = "world";
49
+ //! let result = cmd!("echo hello {}", name).await?;
50
+ //! println!("{}", result.stdout);
51
+ //!
52
+ //! // Using pipelines
53
+ //! use command_stream::Pipeline;
54
+ //! let result = Pipeline::new()
55
+ //! .add("echo hello world")
56
+ //! .add("grep world")
57
+ //! .run()
58
+ //! .await?;
59
+ //!
26
60
  //! Ok(())
27
61
  //! }
28
62
  //! ```
29
63
 
64
+ // Modular utility modules (following JavaScript modular pattern)
65
+ pub mod ansi;
66
+ pub mod events;
67
+ #[doc(hidden)]
68
+ pub mod macros;
69
+ pub mod pipeline;
70
+ pub mod quote;
71
+ pub mod state;
72
+ pub mod stream;
73
+ pub mod trace;
74
+
75
+ // Core modules
30
76
  pub mod commands;
31
77
  pub mod shell_parser;
32
78
  pub mod utils;
33
79
 
34
80
  use std::collections::HashMap;
35
- use std::env;
36
81
  use std::path::PathBuf;
37
82
  use std::process::Stdio;
38
- use std::sync::Arc;
39
83
  use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
40
84
  use tokio::process::{Child, Command};
41
- use tokio::sync::{mpsc, Mutex};
85
+ use tokio::sync::mpsc;
42
86
 
43
87
  pub use commands::{CommandContext, StreamChunk};
44
88
  pub use shell_parser::{parse_shell_command, needs_real_shell, ParsedCommand};
45
- pub use utils::{AnsiConfig, AnsiUtils, CommandResult, VirtualUtils, quote, trace};
89
+ pub use utils::{CommandResult, VirtualUtils};
90
+
91
+ // Re-export modular utilities at crate root for convenient access
92
+ pub use ansi::{AnsiConfig, AnsiUtils};
93
+ pub use events::{EventData, EventType, StreamEmitter};
94
+ pub use pipeline::{Pipeline, PipelineExt, PipelineBuilder};
95
+ pub use quote::quote;
96
+ pub use state::{global_state, reset_global_state, get_shell_settings, set_shell_option, unset_shell_option, GlobalState, ShellSettings};
97
+ pub use stream::{StreamingRunner, OutputStream, OutputChunk, AsyncIterator, IntoStream};
98
+ pub use trace::trace;
46
99
 
47
100
  /// Error type for command-stream operations
48
101
  #[derive(Debug, thiserror::Error)]
@@ -66,21 +119,6 @@ pub enum Error {
66
119
  /// Result type for command-stream operations
67
120
  pub type Result<T> = std::result::Result<T, Error>;
68
121
 
69
- /// Shell settings for controlling execution behavior
70
- #[derive(Debug, Clone, Default)]
71
- pub struct ShellSettings {
72
- /// Exit immediately if a command exits with non-zero status (set -e)
73
- pub errexit: bool,
74
- /// Print commands as they are executed (set -v)
75
- pub verbose: bool,
76
- /// Print trace of commands (set -x)
77
- pub xtrace: bool,
78
- /// Return value of a pipeline is the status of the last command to exit with non-zero (set -o pipefail)
79
- pub pipefail: bool,
80
- /// Treat unset variables as an error (set -u)
81
- pub nounset: bool,
82
- }
83
-
84
122
  /// Options for command execution
85
123
  #[derive(Debug, Clone)]
86
124
  pub struct RunOptions {
@@ -179,8 +217,8 @@ impl ProcessRunner {
179
217
  return Ok(());
180
218
  }
181
219
 
182
- // Parse command for shell operators
183
- let parsed = if self.options.shell_operators && !needs_real_shell(&self.command) {
220
+ // Parse command for shell operators (for future use with virtual command pipelines)
221
+ let _parsed = if self.options.shell_operators && !needs_real_shell(&self.command) {
184
222
  parse_shell_command(&self.command)
185
223
  } else {
186
224
  None
@@ -364,6 +402,16 @@ impl ProcessRunner {
364
402
  pub fn result(&self) -> Option<&CommandResult> {
365
403
  self.result.as_ref()
366
404
  }
405
+
406
+ /// Get the command string
407
+ pub fn command(&self) -> &str {
408
+ &self.command
409
+ }
410
+
411
+ /// Get the options
412
+ pub fn options(&self) -> &RunOptions {
413
+ &self.options
414
+ }
367
415
  }
368
416
 
369
417
  /// Shell configuration
@@ -452,41 +500,4 @@ pub fn run_sync(command: impl Into<String>) -> Result<CommandResult> {
452
500
  rt.block_on(run(command))
453
501
  }
454
502
 
455
- #[cfg(test)]
456
- mod tests {
457
- use super::*;
458
-
459
- #[tokio::test]
460
- async fn test_simple_echo() {
461
- let result = run("echo hello").await.unwrap();
462
- assert!(result.is_success());
463
- assert!(result.stdout.contains("hello"));
464
- }
465
-
466
- #[tokio::test]
467
- async fn test_virtual_echo() {
468
- let mut runner = ProcessRunner::new("echo test virtual", RunOptions::default());
469
- let result = runner.run().await.unwrap();
470
- assert!(result.is_success());
471
- assert!(result.stdout.contains("test virtual"));
472
- }
473
-
474
- #[tokio::test]
475
- async fn test_process_runner() {
476
- let mut runner = ProcessRunner::new("echo hello world", RunOptions {
477
- mirror: false,
478
- ..Default::default()
479
- });
480
-
481
- let result = runner.run().await.unwrap();
482
- assert!(result.is_success());
483
- }
484
-
485
- #[tokio::test]
486
- async fn test_virtual_pwd() {
487
- let mut runner = ProcessRunner::new("pwd", RunOptions::default());
488
- let result = runner.run().await.unwrap();
489
- assert!(result.is_success());
490
- assert!(!result.stdout.is_empty());
491
- }
492
- }
503
+ // Tests are located in tests/ directory for better organization