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.
@@ -0,0 +1,369 @@
1
+ //! Streaming and async iteration support
2
+ //!
3
+ //! This module provides async streaming capabilities similar to JavaScript's
4
+ //! async iterators and stream handling in `$.stream-utils.mjs`.
5
+ //!
6
+ //! ## Usage
7
+ //!
8
+ //! ```rust,no_run
9
+ //! use command_stream::{StreamingRunner, OutputChunk};
10
+ //!
11
+ //! #[tokio::main]
12
+ //! async fn main() -> Result<(), Box<dyn std::error::Error>> {
13
+ //! let runner = StreamingRunner::new("yes hello");
14
+ //!
15
+ //! // Stream output as it arrives
16
+ //! let mut stream = runner.stream();
17
+ //! let mut count = 0;
18
+ //! while let Some(chunk) = stream.next().await {
19
+ //! match chunk {
20
+ //! OutputChunk::Stdout(data) => {
21
+ //! print!("{}", String::from_utf8_lossy(&data));
22
+ //! count += 1;
23
+ //! if count >= 5 {
24
+ //! break;
25
+ //! }
26
+ //! }
27
+ //! OutputChunk::Stderr(data) => {
28
+ //! eprint!("{}", String::from_utf8_lossy(&data));
29
+ //! }
30
+ //! OutputChunk::Exit(code) => {
31
+ //! println!("Process exited with code: {}", code);
32
+ //! break;
33
+ //! }
34
+ //! }
35
+ //! }
36
+ //!
37
+ //! Ok(())
38
+ //! }
39
+ //! ```
40
+
41
+ use std::collections::HashMap;
42
+ use std::path::PathBuf;
43
+ use std::process::Stdio;
44
+ use tokio::io::BufReader;
45
+ use tokio::process::Command;
46
+ use tokio::sync::mpsc;
47
+
48
+ use crate::trace::trace_lazy;
49
+ use crate::{CommandResult, Result};
50
+
51
+ /// A chunk of output from a streaming process
52
+ #[derive(Debug, Clone)]
53
+ pub enum OutputChunk {
54
+ /// Stdout data
55
+ Stdout(Vec<u8>),
56
+ /// Stderr data
57
+ Stderr(Vec<u8>),
58
+ /// Process exit code
59
+ Exit(i32),
60
+ }
61
+
62
+ /// A streaming process runner that allows async iteration over output
63
+ pub struct StreamingRunner {
64
+ command: String,
65
+ cwd: Option<PathBuf>,
66
+ env: Option<HashMap<String, String>>,
67
+ stdin_content: Option<String>,
68
+ }
69
+
70
+ impl StreamingRunner {
71
+ /// Create a new streaming runner
72
+ pub fn new(command: impl Into<String>) -> Self {
73
+ StreamingRunner {
74
+ command: command.into(),
75
+ cwd: None,
76
+ env: None,
77
+ stdin_content: None,
78
+ }
79
+ }
80
+
81
+ /// Set the working directory
82
+ pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
83
+ self.cwd = Some(path.into());
84
+ self
85
+ }
86
+
87
+ /// Set environment variables
88
+ pub fn env(mut self, env: HashMap<String, String>) -> Self {
89
+ self.env = Some(env);
90
+ self
91
+ }
92
+
93
+ /// Set stdin content
94
+ pub fn stdin(mut self, content: impl Into<String>) -> Self {
95
+ self.stdin_content = Some(content.into());
96
+ self
97
+ }
98
+
99
+ /// Start the process and return a stream of output chunks
100
+ pub fn stream(mut self) -> OutputStream {
101
+ let (tx, rx) = mpsc::channel(1024);
102
+
103
+ // Spawn the process handling task
104
+ let command = self.command.clone();
105
+ let cwd = self.cwd.take();
106
+ let env = self.env.take();
107
+ let stdin_content = self.stdin_content.take();
108
+
109
+ tokio::spawn(async move {
110
+ if let Err(e) = run_streaming_process(command, cwd, env, stdin_content, tx.clone()).await {
111
+ trace_lazy("StreamingRunner", || format!("Error: {}", e));
112
+ }
113
+ });
114
+
115
+ OutputStream { rx }
116
+ }
117
+
118
+ /// Run to completion and collect all output
119
+ pub async fn collect(self) -> Result<CommandResult> {
120
+ let mut stdout = Vec::new();
121
+ let mut stderr = Vec::new();
122
+ let mut exit_code = 0;
123
+
124
+ let mut stream = self.stream();
125
+ while let Some(chunk) = stream.rx.recv().await {
126
+ match chunk {
127
+ OutputChunk::Stdout(data) => stdout.extend(data),
128
+ OutputChunk::Stderr(data) => stderr.extend(data),
129
+ OutputChunk::Exit(code) => exit_code = code,
130
+ }
131
+ }
132
+
133
+ Ok(CommandResult {
134
+ stdout: String::from_utf8_lossy(&stdout).to_string(),
135
+ stderr: String::from_utf8_lossy(&stderr).to_string(),
136
+ code: exit_code,
137
+ })
138
+ }
139
+ }
140
+
141
+ /// Stream of output chunks from a process
142
+ pub struct OutputStream {
143
+ rx: mpsc::Receiver<OutputChunk>,
144
+ }
145
+
146
+ impl OutputStream {
147
+ /// Receive the next chunk
148
+ pub async fn next(&mut self) -> Option<OutputChunk> {
149
+ self.rx.recv().await
150
+ }
151
+
152
+ /// Collect all remaining output into vectors
153
+ pub async fn collect(mut self) -> (Vec<u8>, Vec<u8>, i32) {
154
+ let mut stdout = Vec::new();
155
+ let mut stderr = Vec::new();
156
+ let mut exit_code = 0;
157
+
158
+ while let Some(chunk) = self.rx.recv().await {
159
+ match chunk {
160
+ OutputChunk::Stdout(data) => stdout.extend(data),
161
+ OutputChunk::Stderr(data) => stderr.extend(data),
162
+ OutputChunk::Exit(code) => exit_code = code,
163
+ }
164
+ }
165
+
166
+ (stdout, stderr, exit_code)
167
+ }
168
+
169
+ /// Collect stdout only, discarding stderr
170
+ pub async fn collect_stdout(mut self) -> Vec<u8> {
171
+ let mut stdout = Vec::new();
172
+
173
+ while let Some(chunk) = self.rx.recv().await {
174
+ if let OutputChunk::Stdout(data) = chunk {
175
+ stdout.extend(data);
176
+ }
177
+ }
178
+
179
+ stdout
180
+ }
181
+ }
182
+
183
+ /// Run a streaming process and send output to the channel
184
+ async fn run_streaming_process(
185
+ command: String,
186
+ cwd: Option<PathBuf>,
187
+ env: Option<HashMap<String, String>>,
188
+ stdin_content: Option<String>,
189
+ tx: mpsc::Sender<OutputChunk>,
190
+ ) -> Result<()> {
191
+ trace_lazy("StreamingRunner", || format!("Starting: {}", command));
192
+
193
+ let shell = find_available_shell();
194
+ let mut cmd = Command::new(&shell.cmd);
195
+ for arg in &shell.args {
196
+ cmd.arg(arg);
197
+ }
198
+ cmd.arg(&command);
199
+
200
+ // Configure stdio
201
+ if stdin_content.is_some() {
202
+ cmd.stdin(Stdio::piped());
203
+ } else {
204
+ cmd.stdin(Stdio::null());
205
+ }
206
+ cmd.stdout(Stdio::piped());
207
+ cmd.stderr(Stdio::piped());
208
+
209
+ // Set working directory
210
+ if let Some(ref cwd) = cwd {
211
+ cmd.current_dir(cwd);
212
+ }
213
+
214
+ // Set environment
215
+ if let Some(ref env_vars) = env {
216
+ for (key, value) in env_vars {
217
+ cmd.env(key, value);
218
+ }
219
+ }
220
+
221
+ // Spawn the process
222
+ let mut child = cmd.spawn()?;
223
+
224
+ // Write stdin if needed
225
+ if let Some(content) = stdin_content {
226
+ if let Some(mut stdin) = child.stdin.take() {
227
+ use tokio::io::AsyncWriteExt;
228
+ let _ = stdin.write_all(content.as_bytes()).await;
229
+ let _ = stdin.shutdown().await;
230
+ }
231
+ }
232
+
233
+ // Spawn stdout reader
234
+ let stdout = child.stdout.take();
235
+ let tx_stdout = tx.clone();
236
+ let stdout_handle = if let Some(stdout) = stdout {
237
+ Some(tokio::spawn(async move {
238
+ let mut reader = BufReader::new(stdout);
239
+ let mut buf = vec![0u8; 8192];
240
+ loop {
241
+ use tokio::io::AsyncReadExt;
242
+ match reader.read(&mut buf).await {
243
+ Ok(0) => break,
244
+ Ok(n) => {
245
+ if tx_stdout.send(OutputChunk::Stdout(buf[..n].to_vec())).await.is_err() {
246
+ break;
247
+ }
248
+ }
249
+ Err(_) => break,
250
+ }
251
+ }
252
+ }))
253
+ } else {
254
+ None
255
+ };
256
+
257
+ // Spawn stderr reader
258
+ let stderr = child.stderr.take();
259
+ let tx_stderr = tx.clone();
260
+ let stderr_handle = if let Some(stderr) = stderr {
261
+ Some(tokio::spawn(async move {
262
+ let mut reader = BufReader::new(stderr);
263
+ let mut buf = vec![0u8; 8192];
264
+ loop {
265
+ use tokio::io::AsyncReadExt;
266
+ match reader.read(&mut buf).await {
267
+ Ok(0) => break,
268
+ Ok(n) => {
269
+ if tx_stderr.send(OutputChunk::Stderr(buf[..n].to_vec())).await.is_err() {
270
+ break;
271
+ }
272
+ }
273
+ Err(_) => break,
274
+ }
275
+ }
276
+ }))
277
+ } else {
278
+ None
279
+ };
280
+
281
+ // Wait for readers to complete
282
+ if let Some(handle) = stdout_handle {
283
+ let _ = handle.await;
284
+ }
285
+ if let Some(handle) = stderr_handle {
286
+ let _ = handle.await;
287
+ }
288
+
289
+ // Wait for process to exit
290
+ let status = child.wait().await?;
291
+ let code = status.code().unwrap_or(-1);
292
+
293
+ // Send exit code
294
+ let _ = tx.send(OutputChunk::Exit(code)).await;
295
+
296
+ trace_lazy("StreamingRunner", || format!("Exited with code: {}", code));
297
+
298
+ Ok(())
299
+ }
300
+
301
+ /// Shell configuration
302
+ #[derive(Debug, Clone)]
303
+ struct ShellConfig {
304
+ cmd: String,
305
+ args: Vec<String>,
306
+ }
307
+
308
+ /// Find an available shell
309
+ fn find_available_shell() -> ShellConfig {
310
+ let is_windows = cfg!(windows);
311
+
312
+ if is_windows {
313
+ ShellConfig {
314
+ cmd: "cmd.exe".to_string(),
315
+ args: vec!["/c".to_string()],
316
+ }
317
+ } else {
318
+ let shells = [
319
+ ("/bin/sh", "-c"),
320
+ ("/usr/bin/sh", "-c"),
321
+ ("/bin/bash", "-c"),
322
+ ];
323
+
324
+ for (cmd, arg) in shells {
325
+ if std::path::Path::new(cmd).exists() {
326
+ return ShellConfig {
327
+ cmd: cmd.to_string(),
328
+ args: vec![arg.to_string()],
329
+ };
330
+ }
331
+ }
332
+
333
+ ShellConfig {
334
+ cmd: "/bin/sh".to_string(),
335
+ args: vec!["-c".to_string()],
336
+ }
337
+ }
338
+ }
339
+
340
+ /// Async iterator trait for output streams
341
+ #[async_trait::async_trait]
342
+ pub trait AsyncIterator {
343
+ type Item;
344
+
345
+ /// Get the next item from the iterator
346
+ async fn next(&mut self) -> Option<Self::Item>;
347
+ }
348
+
349
+ #[async_trait::async_trait]
350
+ impl AsyncIterator for OutputStream {
351
+ type Item = OutputChunk;
352
+
353
+ async fn next(&mut self) -> Option<Self::Item> {
354
+ self.rx.recv().await
355
+ }
356
+ }
357
+
358
+ /// Extension trait to convert ProcessRunner into a stream
359
+ pub trait IntoStream {
360
+ /// Convert into an output stream
361
+ fn into_stream(self) -> OutputStream;
362
+ }
363
+
364
+ impl IntoStream for crate::ProcessRunner {
365
+ fn into_stream(self) -> OutputStream {
366
+ let streaming = StreamingRunner::new(self.command().to_string());
367
+ streaming.stream()
368
+ }
369
+ }
@@ -0,0 +1,152 @@
1
+ //! Trace/logging utilities for command-stream
2
+ //!
3
+ //! This module provides verbose logging functionality that can be controlled
4
+ //! via environment variables for debugging and development purposes.
5
+
6
+ use std::env;
7
+
8
+ /// Check if tracing is enabled via environment variables
9
+ ///
10
+ /// Tracing can be controlled via:
11
+ /// - COMMAND_STREAM_TRACE=true/false (explicit control)
12
+ /// - COMMAND_STREAM_VERBOSE=true (enables tracing unless TRACE=false)
13
+ pub fn is_trace_enabled() -> bool {
14
+ let trace_env = env::var("COMMAND_STREAM_TRACE").ok();
15
+ let verbose_env = env::var("COMMAND_STREAM_VERBOSE")
16
+ .map(|v| v == "true")
17
+ .unwrap_or(false);
18
+
19
+ match trace_env.as_deref() {
20
+ Some("false") => false,
21
+ Some("true") => true,
22
+ _ => verbose_env,
23
+ }
24
+ }
25
+
26
+ /// Trace function for verbose logging
27
+ ///
28
+ /// Outputs trace messages to stderr when tracing is enabled.
29
+ /// Messages are prefixed with timestamp and category.
30
+ ///
31
+ /// # Examples
32
+ ///
33
+ /// ```
34
+ /// use command_stream::trace::trace;
35
+ ///
36
+ /// trace("ProcessRunner", "Starting command execution");
37
+ /// ```
38
+ pub fn trace(category: &str, message: &str) {
39
+ if !is_trace_enabled() {
40
+ return;
41
+ }
42
+
43
+ let timestamp = chrono::Utc::now().to_rfc3339();
44
+ eprintln!("[TRACE {}] [{}] {}", timestamp, category, message);
45
+ }
46
+
47
+ /// Trace function with lazy message evaluation
48
+ ///
49
+ /// Only evaluates the message function if tracing is enabled.
50
+ /// This is useful for expensive message formatting that should
51
+ /// be avoided when tracing is disabled.
52
+ ///
53
+ /// # Examples
54
+ ///
55
+ /// ```
56
+ /// use command_stream::trace::trace_lazy;
57
+ ///
58
+ /// trace_lazy("ProcessRunner", || {
59
+ /// format!("Expensive computation result: {}", 42)
60
+ /// });
61
+ /// ```
62
+ pub fn trace_lazy<F>(category: &str, message_fn: F)
63
+ where
64
+ F: FnOnce() -> String,
65
+ {
66
+ if !is_trace_enabled() {
67
+ return;
68
+ }
69
+
70
+ trace(category, &message_fn());
71
+ }
72
+
73
+ #[cfg(test)]
74
+ mod tests {
75
+ use super::*;
76
+ use std::env;
77
+ use std::sync::Mutex;
78
+
79
+ // Use a mutex to serialize tests that modify environment variables
80
+ // This prevents race conditions when tests run in parallel
81
+ static ENV_MUTEX: Mutex<()> = Mutex::new(());
82
+
83
+ /// Helper to save and restore environment variables during tests
84
+ struct EnvGuard {
85
+ trace_value: Option<String>,
86
+ verbose_value: Option<String>,
87
+ }
88
+
89
+ impl EnvGuard {
90
+ fn new() -> Self {
91
+ EnvGuard {
92
+ trace_value: env::var("COMMAND_STREAM_TRACE").ok(),
93
+ verbose_value: env::var("COMMAND_STREAM_VERBOSE").ok(),
94
+ }
95
+ }
96
+ }
97
+
98
+ impl Drop for EnvGuard {
99
+ fn drop(&mut self) {
100
+ // Restore original values
101
+ match &self.trace_value {
102
+ Some(v) => env::set_var("COMMAND_STREAM_TRACE", v),
103
+ None => env::remove_var("COMMAND_STREAM_TRACE"),
104
+ }
105
+ match &self.verbose_value {
106
+ Some(v) => env::set_var("COMMAND_STREAM_VERBOSE", v),
107
+ None => env::remove_var("COMMAND_STREAM_VERBOSE"),
108
+ }
109
+ }
110
+ }
111
+
112
+ #[test]
113
+ fn test_trace_disabled_by_default() {
114
+ let _lock = ENV_MUTEX.lock().unwrap();
115
+ let _guard = EnvGuard::new();
116
+
117
+ // Clear env vars to test default behavior
118
+ env::remove_var("COMMAND_STREAM_TRACE");
119
+ env::remove_var("COMMAND_STREAM_VERBOSE");
120
+ assert!(!is_trace_enabled());
121
+ }
122
+
123
+ #[test]
124
+ fn test_trace_enabled_by_verbose() {
125
+ let _lock = ENV_MUTEX.lock().unwrap();
126
+ let _guard = EnvGuard::new();
127
+
128
+ env::remove_var("COMMAND_STREAM_TRACE");
129
+ env::set_var("COMMAND_STREAM_VERBOSE", "true");
130
+ assert!(is_trace_enabled());
131
+ }
132
+
133
+ #[test]
134
+ fn test_trace_explicit_true() {
135
+ let _lock = ENV_MUTEX.lock().unwrap();
136
+ let _guard = EnvGuard::new();
137
+
138
+ env::remove_var("COMMAND_STREAM_VERBOSE");
139
+ env::set_var("COMMAND_STREAM_TRACE", "true");
140
+ assert!(is_trace_enabled());
141
+ }
142
+
143
+ #[test]
144
+ fn test_trace_explicit_false_overrides_verbose() {
145
+ let _lock = ENV_MUTEX.lock().unwrap();
146
+ let _guard = EnvGuard::new();
147
+
148
+ env::set_var("COMMAND_STREAM_TRACE", "false");
149
+ env::set_var("COMMAND_STREAM_VERBOSE", "true");
150
+ assert!(!is_trace_enabled());
151
+ }
152
+ }