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/rust/src/utils.rs CHANGED
@@ -1,55 +1,25 @@
1
1
  //! Utility functions and types for command-stream
2
2
  //!
3
- //! This module provides helper functions for tracing, error handling,
4
- //! and common file system operations used by virtual commands.
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
5
15
 
6
16
  use std::env;
7
17
  use std::path::{Path, PathBuf};
8
18
 
9
- /// Check if tracing is enabled via environment variables
10
- ///
11
- /// Tracing can be controlled via:
12
- /// - COMMAND_STREAM_TRACE=true/false (explicit control)
13
- /// - COMMAND_STREAM_VERBOSE=true (enables tracing unless TRACE=false)
14
- pub fn is_trace_enabled() -> bool {
15
- let trace_env = env::var("COMMAND_STREAM_TRACE").ok();
16
- let verbose_env = env::var("COMMAND_STREAM_VERBOSE")
17
- .map(|v| v == "true")
18
- .unwrap_or(false);
19
-
20
- match trace_env.as_deref() {
21
- Some("false") => false,
22
- Some("true") => true,
23
- _ => verbose_env,
24
- }
25
- }
26
-
27
- /// Trace function for verbose logging
28
- ///
29
- /// Outputs trace messages to stderr when tracing is enabled.
30
- /// Messages are prefixed with timestamp and category.
31
- pub fn trace(category: &str, message: &str) {
32
- if !is_trace_enabled() {
33
- return;
34
- }
35
-
36
- let timestamp = chrono::Utc::now().to_rfc3339();
37
- eprintln!("[TRACE {}] [{}] {}", timestamp, category, message);
38
- }
39
-
40
- /// Trace function with lazy message evaluation
41
- ///
42
- /// Only evaluates the message function if tracing is enabled.
43
- pub fn trace_lazy<F>(category: &str, message_fn: F)
44
- where
45
- F: FnOnce() -> String,
46
- {
47
- if !is_trace_enabled() {
48
- return;
49
- }
50
-
51
- trace(category, &message_fn());
52
- }
19
+ // Re-export from specialized modules for backwards compatibility
20
+ pub use crate::trace::{is_trace_enabled, trace, trace_lazy};
21
+ pub use crate::ansi::{AnsiConfig, AnsiUtils};
22
+ pub use crate::quote::quote;
53
23
 
54
24
  /// Result type for virtual command operations
55
25
  #[derive(Debug, Clone)]
@@ -167,101 +137,6 @@ impl VirtualUtils {
167
137
  }
168
138
  }
169
139
 
170
- /// ANSI control character utilities
171
- pub struct AnsiUtils;
172
-
173
- impl AnsiUtils {
174
- /// Strip ANSI escape sequences from text
175
- pub fn strip_ansi(text: &str) -> String {
176
- let re = regex::Regex::new(r"\x1b\[[0-9;]*[mGKHFJ]").unwrap();
177
- re.replace_all(text, "").to_string()
178
- }
179
-
180
- /// Strip control characters from text, preserving newlines, carriage returns, and tabs
181
- pub fn strip_control_chars(text: &str) -> String {
182
- text.chars()
183
- .filter(|c| {
184
- // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
185
- !matches!(*c as u32,
186
- 0x00..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F
187
- )
188
- })
189
- .collect()
190
- }
191
-
192
- /// Strip both ANSI sequences and control characters
193
- pub fn strip_all(text: &str) -> String {
194
- Self::strip_control_chars(&Self::strip_ansi(text))
195
- }
196
-
197
- /// Clean data for processing (strips ANSI and control chars)
198
- pub fn clean_for_processing(data: &str) -> String {
199
- Self::strip_all(data)
200
- }
201
- }
202
-
203
- /// Configuration for ANSI handling
204
- #[derive(Debug, Clone)]
205
- pub struct AnsiConfig {
206
- pub preserve_ansi: bool,
207
- pub preserve_control_chars: bool,
208
- }
209
-
210
- impl Default for AnsiConfig {
211
- fn default() -> Self {
212
- AnsiConfig {
213
- preserve_ansi: true,
214
- preserve_control_chars: true,
215
- }
216
- }
217
- }
218
-
219
- impl AnsiConfig {
220
- /// Process output according to config settings
221
- pub fn process_output(&self, data: &str) -> String {
222
- if !self.preserve_ansi && !self.preserve_control_chars {
223
- AnsiUtils::clean_for_processing(data)
224
- } else if !self.preserve_ansi {
225
- AnsiUtils::strip_ansi(data)
226
- } else if !self.preserve_control_chars {
227
- AnsiUtils::strip_control_chars(data)
228
- } else {
229
- data.to_string()
230
- }
231
- }
232
- }
233
-
234
- /// Quote a value for safe shell usage
235
- pub fn quote(value: &str) -> String {
236
- if value.is_empty() {
237
- return "''".to_string();
238
- }
239
-
240
- // If already properly quoted, check if we can use as-is
241
- if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
242
- let inner = &value[1..value.len() - 1];
243
- if !inner.contains('\'') {
244
- return value.to_string();
245
- }
246
- }
247
-
248
- if value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
249
- // If already double-quoted, wrap in single quotes
250
- return format!("'{}'", value);
251
- }
252
-
253
- // Check if the string needs quoting at all
254
- // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus
255
- let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
256
-
257
- if safe_pattern.is_match(value) {
258
- return value.to_string();
259
- }
260
-
261
- // Default behavior: wrap in single quotes and escape any internal single quotes
262
- format!("'{}'", value.replace('\'', "'\\''"))
263
- }
264
-
265
140
  #[cfg(test)]
266
141
  mod tests {
267
142
  use super::*;
@@ -284,6 +159,13 @@ mod tests {
284
159
  assert_eq!(result.code, 1);
285
160
  }
286
161
 
162
+ #[test]
163
+ fn test_command_result_error_with_code() {
164
+ let result = CommandResult::error_with_code("permission denied", 126);
165
+ assert!(!result.is_success());
166
+ assert_eq!(result.code, 126);
167
+ }
168
+
287
169
  #[test]
288
170
  fn test_resolve_path_absolute() {
289
171
  let path = VirtualUtils::resolve_path("/absolute/path", None);
@@ -298,38 +180,51 @@ mod tests {
298
180
  }
299
181
 
300
182
  #[test]
301
- fn test_strip_ansi() {
302
- let text = "\x1b[31mRed text\x1b[0m";
303
- assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
183
+ fn test_validate_args_success() {
184
+ let args = vec!["arg1".to_string()];
185
+ assert!(VirtualUtils::validate_args(&args, 1, "cmd").is_none());
304
186
  }
305
187
 
306
188
  #[test]
307
- fn test_strip_control_chars() {
308
- let text = "Hello\x00World\nNew line\tTab";
309
- assert_eq!(AnsiUtils::strip_control_chars(text), "HelloWorld\nNew line\tTab");
189
+ fn test_validate_args_missing() {
190
+ let args = vec!["arg1".to_string()];
191
+ let result = VirtualUtils::validate_args(&args, 2, "cmd");
192
+ assert!(result.is_some());
310
193
  }
311
194
 
312
195
  #[test]
313
- fn test_quote_empty() {
314
- assert_eq!(quote(""), "''");
196
+ fn test_missing_operand_error() {
197
+ let result = VirtualUtils::missing_operand_error("cat");
198
+ assert!(!result.is_success());
199
+ assert!(result.stderr.contains("missing operand"));
315
200
  }
316
201
 
317
202
  #[test]
318
- fn test_quote_safe_chars() {
319
- assert_eq!(quote("hello"), "hello");
320
- assert_eq!(quote("/path/to/file"), "/path/to/file");
203
+ fn test_invalid_argument_error() {
204
+ let result = VirtualUtils::invalid_argument_error("ls", "invalid option");
205
+ assert!(!result.is_success());
206
+ assert!(result.stderr.contains("invalid option"));
321
207
  }
322
208
 
209
+ // Re-exported module tests are in their respective modules
210
+ // These tests verify the re-exports work correctly
211
+
323
212
  #[test]
324
- fn test_quote_special_chars() {
213
+ fn test_reexported_quote() {
214
+ assert_eq!(quote("hello"), "hello");
325
215
  assert_eq!(quote("hello world"), "'hello world'");
326
- assert_eq!(quote("it's"), "'it'\\''s'");
327
216
  }
328
217
 
329
218
  #[test]
330
- fn test_validate_args() {
331
- let args = vec!["arg1".to_string()];
332
- assert!(VirtualUtils::validate_args(&args, 1, "cmd").is_none());
333
- assert!(VirtualUtils::validate_args(&args, 2, "cmd").is_some());
219
+ fn test_reexported_ansi_utils() {
220
+ let text = "\x1b[31mRed text\x1b[0m";
221
+ assert_eq!(AnsiUtils::strip_ansi(text), "Red text");
222
+ }
223
+
224
+ #[test]
225
+ fn test_reexported_ansi_config() {
226
+ let config = AnsiConfig::default();
227
+ assert!(config.preserve_ansi);
228
+ assert!(config.preserve_control_chars);
334
229
  }
335
230
  }
@@ -0,0 +1,207 @@
1
+ //! Integration tests for the events module
2
+
3
+ use command_stream::{EventData, EventType, StreamEmitter};
4
+ use std::sync::atomic::{AtomicUsize, Ordering};
5
+ use std::sync::Arc;
6
+
7
+ #[tokio::test]
8
+ async fn test_stream_emitter_creation() {
9
+ let emitter = StreamEmitter::new();
10
+ assert_eq!(emitter.listener_count(&EventType::Stdout).await, 0);
11
+ }
12
+
13
+ #[tokio::test]
14
+ async fn test_stream_emitter_on_emit() {
15
+ let emitter = StreamEmitter::new();
16
+ let counter = Arc::new(AtomicUsize::new(0));
17
+ let counter_clone = counter.clone();
18
+
19
+ emitter
20
+ .on(EventType::Stdout, move |data| {
21
+ if let EventData::String(s) = data {
22
+ assert_eq!(s, "hello");
23
+ counter_clone.fetch_add(1, Ordering::SeqCst);
24
+ }
25
+ })
26
+ .await;
27
+
28
+ emitter
29
+ .emit(EventType::Stdout, EventData::String("hello".to_string()))
30
+ .await;
31
+
32
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
33
+ }
34
+
35
+ #[tokio::test]
36
+ async fn test_stream_emitter_multiple_listeners() {
37
+ let emitter = StreamEmitter::new();
38
+ let counter1 = Arc::new(AtomicUsize::new(0));
39
+ let counter2 = Arc::new(AtomicUsize::new(0));
40
+ let c1 = counter1.clone();
41
+ let c2 = counter2.clone();
42
+
43
+ emitter
44
+ .on(EventType::Stdout, move |_| {
45
+ c1.fetch_add(1, Ordering::SeqCst);
46
+ })
47
+ .await;
48
+
49
+ emitter
50
+ .on(EventType::Stdout, move |_| {
51
+ c2.fetch_add(1, Ordering::SeqCst);
52
+ })
53
+ .await;
54
+
55
+ emitter
56
+ .emit(EventType::Stdout, EventData::String("test".to_string()))
57
+ .await;
58
+
59
+ assert_eq!(counter1.load(Ordering::SeqCst), 1);
60
+ assert_eq!(counter2.load(Ordering::SeqCst), 1);
61
+ }
62
+
63
+ #[tokio::test]
64
+ async fn test_stream_emitter_once() {
65
+ let emitter = StreamEmitter::new();
66
+ let counter = Arc::new(AtomicUsize::new(0));
67
+ let counter_clone = counter.clone();
68
+
69
+ emitter
70
+ .once(EventType::Exit, move |_| {
71
+ counter_clone.fetch_add(1, Ordering::SeqCst);
72
+ })
73
+ .await;
74
+
75
+ // Emit multiple times
76
+ emitter.emit(EventType::Exit, EventData::ExitCode(0)).await;
77
+ emitter.emit(EventType::Exit, EventData::ExitCode(0)).await;
78
+ emitter.emit(EventType::Exit, EventData::ExitCode(0)).await;
79
+
80
+ // Should only count once
81
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
82
+ }
83
+
84
+ #[tokio::test]
85
+ async fn test_stream_emitter_off() {
86
+ let emitter = StreamEmitter::new();
87
+ let counter = Arc::new(AtomicUsize::new(0));
88
+ let counter_clone = counter.clone();
89
+
90
+ emitter
91
+ .on(EventType::Stderr, move |_| {
92
+ counter_clone.fetch_add(1, Ordering::SeqCst);
93
+ })
94
+ .await;
95
+
96
+ assert_eq!(emitter.listener_count(&EventType::Stderr).await, 1);
97
+
98
+ emitter.off(EventType::Stderr).await;
99
+
100
+ assert_eq!(emitter.listener_count(&EventType::Stderr).await, 0);
101
+
102
+ // Emit after off - should not trigger listener
103
+ emitter
104
+ .emit(EventType::Stderr, EventData::String("error".to_string()))
105
+ .await;
106
+
107
+ assert_eq!(counter.load(Ordering::SeqCst), 0);
108
+ }
109
+
110
+ #[tokio::test]
111
+ async fn test_stream_emitter_different_events() {
112
+ let emitter = StreamEmitter::new();
113
+ let stdout_counter = Arc::new(AtomicUsize::new(0));
114
+ let stderr_counter = Arc::new(AtomicUsize::new(0));
115
+ let exit_counter = Arc::new(AtomicUsize::new(0));
116
+
117
+ let out = stdout_counter.clone();
118
+ let err = stderr_counter.clone();
119
+ let exit = exit_counter.clone();
120
+
121
+ emitter
122
+ .on(EventType::Stdout, move |_| {
123
+ out.fetch_add(1, Ordering::SeqCst);
124
+ })
125
+ .await;
126
+
127
+ emitter
128
+ .on(EventType::Stderr, move |_| {
129
+ err.fetch_add(1, Ordering::SeqCst);
130
+ })
131
+ .await;
132
+
133
+ emitter
134
+ .on(EventType::Exit, move |_| {
135
+ exit.fetch_add(1, Ordering::SeqCst);
136
+ })
137
+ .await;
138
+
139
+ emitter
140
+ .emit(EventType::Stdout, EventData::String("out1".to_string()))
141
+ .await;
142
+ emitter
143
+ .emit(EventType::Stdout, EventData::String("out2".to_string()))
144
+ .await;
145
+ emitter
146
+ .emit(EventType::Stderr, EventData::String("err".to_string()))
147
+ .await;
148
+ emitter.emit(EventType::Exit, EventData::ExitCode(0)).await;
149
+
150
+ assert_eq!(stdout_counter.load(Ordering::SeqCst), 2);
151
+ assert_eq!(stderr_counter.load(Ordering::SeqCst), 1);
152
+ assert_eq!(exit_counter.load(Ordering::SeqCst), 1);
153
+ }
154
+
155
+ #[tokio::test]
156
+ async fn test_stream_emitter_remove_all_listeners() {
157
+ let emitter = StreamEmitter::new();
158
+
159
+ emitter.on(EventType::Stdout, |_| {}).await;
160
+ emitter.on(EventType::Stderr, |_| {}).await;
161
+ emitter.on(EventType::Exit, |_| {}).await;
162
+
163
+ assert!(emitter.listener_count(&EventType::Stdout).await > 0);
164
+ assert!(emitter.listener_count(&EventType::Stderr).await > 0);
165
+ assert!(emitter.listener_count(&EventType::Exit).await > 0);
166
+
167
+ emitter.remove_all_listeners().await;
168
+
169
+ assert_eq!(emitter.listener_count(&EventType::Stdout).await, 0);
170
+ assert_eq!(emitter.listener_count(&EventType::Stderr).await, 0);
171
+ assert_eq!(emitter.listener_count(&EventType::Exit).await, 0);
172
+ }
173
+
174
+ #[tokio::test]
175
+ async fn test_event_data_variants() {
176
+ let emitter = StreamEmitter::new();
177
+
178
+ let string_received = Arc::new(std::sync::Mutex::new(None));
179
+ let code_received = Arc::new(std::sync::Mutex::new(None));
180
+
181
+ let str_clone = string_received.clone();
182
+ let code_clone = code_received.clone();
183
+
184
+ emitter
185
+ .on(EventType::Stdout, move |data| {
186
+ if let EventData::String(s) = data {
187
+ *str_clone.lock().unwrap() = Some(s);
188
+ }
189
+ })
190
+ .await;
191
+
192
+ emitter
193
+ .on(EventType::Exit, move |data| {
194
+ if let EventData::ExitCode(code) = data {
195
+ *code_clone.lock().unwrap() = Some(code);
196
+ }
197
+ })
198
+ .await;
199
+
200
+ emitter
201
+ .emit(EventType::Stdout, EventData::String("test data".to_string()))
202
+ .await;
203
+ emitter.emit(EventType::Exit, EventData::ExitCode(42)).await;
204
+
205
+ assert_eq!(*string_received.lock().unwrap(), Some("test data".to_string()));
206
+ assert_eq!(*code_received.lock().unwrap(), Some(42));
207
+ }
@@ -0,0 +1,77 @@
1
+ //! Tests for the cmd! macro and its aliases (s!, sh!, cs!)
2
+
3
+ use command_stream::{cmd, sh, s, cs};
4
+
5
+ #[tokio::test]
6
+ async fn test_cmd_macro_simple() {
7
+ let result = cmd!("echo hello").await.unwrap();
8
+ assert!(result.is_success());
9
+ assert!(result.stdout.contains("hello"));
10
+ }
11
+
12
+ #[tokio::test]
13
+ async fn test_cmd_macro_with_interpolation() {
14
+ let name = "world";
15
+ let result = cmd!("echo hello {}", name).await.unwrap();
16
+ assert!(result.is_success());
17
+ assert!(result.stdout.contains("hello"));
18
+ assert!(result.stdout.contains("world"));
19
+ }
20
+
21
+ #[tokio::test]
22
+ async fn test_cmd_macro_with_multiple_interpolations() {
23
+ let greeting = "Hello";
24
+ let name = "World";
25
+ let result = cmd!("echo {} {}", greeting, name).await.unwrap();
26
+ assert!(result.is_success());
27
+ assert!(result.stdout.contains("Hello"));
28
+ assert!(result.stdout.contains("World"));
29
+ }
30
+
31
+ #[tokio::test]
32
+ async fn test_cmd_macro_with_special_chars() {
33
+ // Test that special characters are properly quoted
34
+ let filename = "test file with spaces.txt";
35
+ let result = cmd!("echo {}", filename).await.unwrap();
36
+ assert!(result.is_success());
37
+ // The output should contain the filename (quoted in the command)
38
+ assert!(result.stdout.contains("test file with spaces.txt"));
39
+ }
40
+
41
+ #[tokio::test]
42
+ async fn test_sh_macro_alias() {
43
+ let result = sh!("echo hello from sh").await.unwrap();
44
+ assert!(result.is_success());
45
+ assert!(result.stdout.contains("hello from sh"));
46
+ }
47
+
48
+ #[tokio::test]
49
+ async fn test_s_macro_alias() {
50
+ let result = s!("echo hello from s").await.unwrap();
51
+ assert!(result.is_success());
52
+ assert!(result.stdout.contains("hello from s"));
53
+ }
54
+
55
+ #[tokio::test]
56
+ async fn test_cs_macro_alias() {
57
+ let result = cs!("echo hello from cs").await.unwrap();
58
+ assert!(result.is_success());
59
+ assert!(result.stdout.contains("hello from cs"));
60
+ }
61
+
62
+ #[tokio::test]
63
+ async fn test_s_macro_with_interpolation() {
64
+ let name = "world";
65
+ let result = s!("echo hello {}", name).await.unwrap();
66
+ assert!(result.is_success());
67
+ assert!(result.stdout.contains("hello"));
68
+ assert!(result.stdout.contains("world"));
69
+ }
70
+
71
+ #[tokio::test]
72
+ async fn test_cmd_macro_with_numbers() {
73
+ let count = 42;
74
+ let result = cmd!("echo The answer is {}", count).await.unwrap();
75
+ assert!(result.is_success());
76
+ assert!(result.stdout.contains("42"));
77
+ }
@@ -0,0 +1,93 @@
1
+ //! Tests for the Pipeline module
2
+
3
+ use command_stream::{Pipeline, PipelineExt, ProcessRunner, RunOptions};
4
+
5
+ #[tokio::test]
6
+ async fn test_pipeline_simple() {
7
+ let result = Pipeline::new()
8
+ .add("echo hello world")
9
+ .run()
10
+ .await
11
+ .unwrap();
12
+
13
+ assert!(result.is_success());
14
+ assert!(result.stdout.contains("hello world"));
15
+ }
16
+
17
+ #[tokio::test]
18
+ async fn test_pipeline_two_commands() {
19
+ let result = Pipeline::new()
20
+ .add("echo 'hello\nworld\nhello again'")
21
+ .add("grep hello")
22
+ .run()
23
+ .await
24
+ .unwrap();
25
+
26
+ assert!(result.is_success());
27
+ // The grep should filter to only lines containing "hello"
28
+ assert!(result.stdout.contains("hello"));
29
+ }
30
+
31
+ #[tokio::test]
32
+ async fn test_pipeline_with_stdin() {
33
+ let result = Pipeline::new()
34
+ .stdin("line1\nline2\nline3")
35
+ .add("cat")
36
+ .run()
37
+ .await
38
+ .unwrap();
39
+
40
+ assert!(result.is_success());
41
+ assert!(result.stdout.contains("line1"));
42
+ assert!(result.stdout.contains("line2"));
43
+ assert!(result.stdout.contains("line3"));
44
+ }
45
+
46
+ #[tokio::test]
47
+ async fn test_pipeline_three_commands() {
48
+ let result = Pipeline::new()
49
+ .add("echo 'apple\nbanana\napricot\nblueberry'")
50
+ .add("grep a")
51
+ .add("wc -l")
52
+ .run()
53
+ .await
54
+ .unwrap();
55
+
56
+ assert!(result.is_success());
57
+ // Should count lines containing 'a': apple, banana, apricot = 3 lines
58
+ }
59
+
60
+ #[tokio::test]
61
+ async fn test_pipeline_empty() {
62
+ let result = Pipeline::new().run().await.unwrap();
63
+
64
+ // Empty pipeline should return error
65
+ assert!(!result.is_success());
66
+ assert!(result.stderr.contains("No commands"));
67
+ }
68
+
69
+ #[tokio::test]
70
+ async fn test_pipeline_failure_propagation() {
71
+ let result = Pipeline::new()
72
+ .add("echo hello")
73
+ .add("false") // This command always fails
74
+ .add("echo should not reach here")
75
+ .run()
76
+ .await
77
+ .unwrap();
78
+
79
+ // Pipeline should fail because 'false' returns non-zero
80
+ assert!(!result.is_success());
81
+ }
82
+
83
+ #[tokio::test]
84
+ async fn test_pipeline_builder_pattern() {
85
+ // Test the fluent API
86
+ let pipeline = Pipeline::new()
87
+ .add("echo test")
88
+ .mirror_output(false)
89
+ .capture_output(true);
90
+
91
+ let result = pipeline.run().await.unwrap();
92
+ assert!(result.is_success());
93
+ }