command-stream 0.9.0 → 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/js/src/$.ansi.mjs +147 -0
- package/js/src/$.mjs +49 -6382
- package/js/src/$.process-runner-base.mjs +563 -0
- package/js/src/$.process-runner-execution.mjs +1497 -0
- package/js/src/$.process-runner-orchestration.mjs +250 -0
- package/js/src/$.process-runner-pipeline.mjs +1162 -0
- package/js/src/$.process-runner-stream-kill.mjs +312 -0
- package/js/src/$.process-runner-virtual.mjs +297 -0
- package/js/src/$.quote.mjs +161 -0
- package/js/src/$.result.mjs +23 -0
- package/js/src/$.shell-settings.mjs +84 -0
- package/js/src/$.shell.mjs +157 -0
- package/js/src/$.state.mjs +401 -0
- package/js/src/$.stream-emitter.mjs +111 -0
- package/js/src/$.stream-utils.mjs +390 -0
- package/js/src/$.trace.mjs +36 -0
- package/js/src/$.utils.mjs +2 -23
- package/js/src/$.virtual-commands.mjs +113 -0
- package/js/src/commands/$.which.mjs +3 -1
- package/js/src/commands/index.mjs +24 -0
- package/js/src/shell-parser.mjs +125 -83
- package/js/tests/resource-cleanup-internals.test.mjs +22 -24
- package/js/tests/sigint-cleanup.test.mjs +3 -0
- package/package.json +1 -1
- package/rust/src/ansi.rs +194 -0
- package/rust/src/events.rs +305 -0
- package/rust/src/lib.rs +71 -60
- package/rust/src/macros.rs +165 -0
- package/rust/src/pipeline.rs +411 -0
- package/rust/src/quote.rs +161 -0
- package/rust/src/state.rs +333 -0
- package/rust/src/stream.rs +369 -0
- package/rust/src/trace.rs +152 -0
- package/rust/src/utils.rs +53 -158
- package/rust/tests/events.rs +207 -0
- package/rust/tests/macros.rs +77 -0
- package/rust/tests/pipeline.rs +93 -0
- package/rust/tests/state.rs +207 -0
- package/rust/tests/stream.rs +102 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
//! Pipeline execution support
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides pipeline functionality similar to the JavaScript
|
|
4
|
+
//! `$.process-runner-pipeline.mjs` module. It allows chaining commands
|
|
5
|
+
//! together with the output of one command becoming the input of the next.
|
|
6
|
+
//!
|
|
7
|
+
//! ## Usage
|
|
8
|
+
//!
|
|
9
|
+
//! ```rust,no_run
|
|
10
|
+
//! use command_stream::{Pipeline, run};
|
|
11
|
+
//!
|
|
12
|
+
//! #[tokio::main]
|
|
13
|
+
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
14
|
+
//! // Create a pipeline
|
|
15
|
+
//! let result = Pipeline::new()
|
|
16
|
+
//! .add("echo hello world")
|
|
17
|
+
//! .add("grep world")
|
|
18
|
+
//! .add("wc -l")
|
|
19
|
+
//! .run()
|
|
20
|
+
//! .await?;
|
|
21
|
+
//!
|
|
22
|
+
//! println!("Output: {}", result.stdout);
|
|
23
|
+
//! Ok(())
|
|
24
|
+
//! }
|
|
25
|
+
//! ```
|
|
26
|
+
|
|
27
|
+
use std::collections::HashMap;
|
|
28
|
+
use std::path::PathBuf;
|
|
29
|
+
use std::process::Stdio;
|
|
30
|
+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
31
|
+
use tokio::process::Command;
|
|
32
|
+
|
|
33
|
+
use crate::trace::trace_lazy;
|
|
34
|
+
use crate::{CommandResult, Result, RunOptions, StdinOption};
|
|
35
|
+
|
|
36
|
+
/// A pipeline of commands to be executed sequentially
|
|
37
|
+
///
|
|
38
|
+
/// Each command's stdout is piped to the next command's stdin.
|
|
39
|
+
#[derive(Debug, Clone)]
|
|
40
|
+
pub struct Pipeline {
|
|
41
|
+
/// Commands in the pipeline
|
|
42
|
+
commands: Vec<String>,
|
|
43
|
+
/// Initial stdin content (optional)
|
|
44
|
+
stdin: Option<String>,
|
|
45
|
+
/// Working directory
|
|
46
|
+
cwd: Option<PathBuf>,
|
|
47
|
+
/// Environment variables
|
|
48
|
+
env: Option<HashMap<String, String>>,
|
|
49
|
+
/// Whether to mirror output to parent stdout/stderr
|
|
50
|
+
mirror: bool,
|
|
51
|
+
/// Whether to capture output
|
|
52
|
+
capture: bool,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
impl Default for Pipeline {
|
|
56
|
+
fn default() -> Self {
|
|
57
|
+
Self::new()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl Pipeline {
|
|
62
|
+
/// Create a new empty pipeline
|
|
63
|
+
pub fn new() -> Self {
|
|
64
|
+
Pipeline {
|
|
65
|
+
commands: Vec::new(),
|
|
66
|
+
stdin: None,
|
|
67
|
+
cwd: None,
|
|
68
|
+
env: None,
|
|
69
|
+
mirror: true,
|
|
70
|
+
capture: true,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Add a command to the pipeline
|
|
75
|
+
pub fn add(mut self, command: impl Into<String>) -> Self {
|
|
76
|
+
self.commands.push(command.into());
|
|
77
|
+
self
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Set the initial stdin content for the first command
|
|
81
|
+
pub fn stdin(mut self, content: impl Into<String>) -> Self {
|
|
82
|
+
self.stdin = Some(content.into());
|
|
83
|
+
self
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Set the working directory for all commands
|
|
87
|
+
pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
|
|
88
|
+
self.cwd = Some(path.into());
|
|
89
|
+
self
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Set environment variables for all commands
|
|
93
|
+
pub fn env(mut self, env: HashMap<String, String>) -> Self {
|
|
94
|
+
self.env = Some(env);
|
|
95
|
+
self
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Set whether to mirror output to stdout/stderr
|
|
99
|
+
pub fn mirror_output(mut self, mirror: bool) -> Self {
|
|
100
|
+
self.mirror = mirror;
|
|
101
|
+
self
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Set whether to capture output
|
|
105
|
+
pub fn capture_output(mut self, capture: bool) -> Self {
|
|
106
|
+
self.capture = capture;
|
|
107
|
+
self
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Execute the pipeline and return the result
|
|
111
|
+
pub async fn run(self) -> Result<CommandResult> {
|
|
112
|
+
if self.commands.is_empty() {
|
|
113
|
+
return Ok(CommandResult {
|
|
114
|
+
stdout: String::new(),
|
|
115
|
+
stderr: "No commands in pipeline".to_string(),
|
|
116
|
+
code: 1,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
trace_lazy("Pipeline", || {
|
|
121
|
+
format!("Running pipeline with {} commands", self.commands.len())
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
let mut current_stdin = self.stdin.clone();
|
|
125
|
+
let mut last_result = CommandResult {
|
|
126
|
+
stdout: String::new(),
|
|
127
|
+
stderr: String::new(),
|
|
128
|
+
code: 0,
|
|
129
|
+
};
|
|
130
|
+
let mut accumulated_stderr = String::new();
|
|
131
|
+
|
|
132
|
+
for (i, cmd_str) in self.commands.iter().enumerate() {
|
|
133
|
+
let is_last = i == self.commands.len() - 1;
|
|
134
|
+
|
|
135
|
+
trace_lazy("Pipeline", || {
|
|
136
|
+
format!("Executing command {}/{}: {}", i + 1, self.commands.len(), cmd_str)
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Check if this is a virtual command
|
|
140
|
+
let first_word = cmd_str.split_whitespace().next().unwrap_or("");
|
|
141
|
+
if crate::commands::are_virtual_commands_enabled() {
|
|
142
|
+
if let Some(result) = self.try_virtual_command(first_word, cmd_str, ¤t_stdin).await {
|
|
143
|
+
if result.code != 0 {
|
|
144
|
+
return Ok(CommandResult {
|
|
145
|
+
stdout: result.stdout,
|
|
146
|
+
stderr: accumulated_stderr + &result.stderr,
|
|
147
|
+
code: result.code,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
current_stdin = Some(result.stdout.clone());
|
|
151
|
+
accumulated_stderr.push_str(&result.stderr);
|
|
152
|
+
last_result = result;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Execute via shell
|
|
158
|
+
let shell = find_available_shell();
|
|
159
|
+
let mut cmd = Command::new(&shell.cmd);
|
|
160
|
+
for arg in &shell.args {
|
|
161
|
+
cmd.arg(arg);
|
|
162
|
+
}
|
|
163
|
+
cmd.arg(cmd_str);
|
|
164
|
+
|
|
165
|
+
// Configure stdio
|
|
166
|
+
cmd.stdin(Stdio::piped());
|
|
167
|
+
cmd.stdout(Stdio::piped());
|
|
168
|
+
cmd.stderr(Stdio::piped());
|
|
169
|
+
|
|
170
|
+
// Set working directory
|
|
171
|
+
if let Some(ref cwd) = self.cwd {
|
|
172
|
+
cmd.current_dir(cwd);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Set environment
|
|
176
|
+
if let Some(ref env_vars) = self.env {
|
|
177
|
+
for (key, value) in env_vars {
|
|
178
|
+
cmd.env(key, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Spawn the process
|
|
183
|
+
let mut child = cmd.spawn()?;
|
|
184
|
+
|
|
185
|
+
// Write stdin if available
|
|
186
|
+
if let Some(ref stdin_content) = current_stdin {
|
|
187
|
+
if let Some(mut stdin) = child.stdin.take() {
|
|
188
|
+
let content = stdin_content.clone();
|
|
189
|
+
tokio::spawn(async move {
|
|
190
|
+
let _ = stdin.write_all(content.as_bytes()).await;
|
|
191
|
+
let _ = stdin.shutdown().await;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Read stdout
|
|
197
|
+
let mut stdout_content = String::new();
|
|
198
|
+
if let Some(mut stdout) = child.stdout.take() {
|
|
199
|
+
stdout.read_to_string(&mut stdout_content).await?;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Read stderr
|
|
203
|
+
let mut stderr_content = String::new();
|
|
204
|
+
if let Some(mut stderr) = child.stderr.take() {
|
|
205
|
+
stderr.read_to_string(&mut stderr_content).await?;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Mirror output if enabled and this is the last command
|
|
209
|
+
if is_last && self.mirror {
|
|
210
|
+
if !stdout_content.is_empty() {
|
|
211
|
+
print!("{}", stdout_content);
|
|
212
|
+
}
|
|
213
|
+
if !stderr_content.is_empty() {
|
|
214
|
+
eprint!("{}", stderr_content);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Wait for the process
|
|
219
|
+
let status = child.wait().await?;
|
|
220
|
+
let code = status.code().unwrap_or(-1);
|
|
221
|
+
|
|
222
|
+
accumulated_stderr.push_str(&stderr_content);
|
|
223
|
+
|
|
224
|
+
if code != 0 {
|
|
225
|
+
return Ok(CommandResult {
|
|
226
|
+
stdout: stdout_content,
|
|
227
|
+
stderr: accumulated_stderr,
|
|
228
|
+
code,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Set up stdin for next command
|
|
233
|
+
current_stdin = Some(stdout_content.clone());
|
|
234
|
+
last_result = CommandResult {
|
|
235
|
+
stdout: stdout_content,
|
|
236
|
+
stderr: String::new(),
|
|
237
|
+
code,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
Ok(CommandResult {
|
|
242
|
+
stdout: last_result.stdout,
|
|
243
|
+
stderr: accumulated_stderr,
|
|
244
|
+
code: last_result.code,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/// Try to execute a virtual command
|
|
249
|
+
async fn try_virtual_command(
|
|
250
|
+
&self,
|
|
251
|
+
cmd_name: &str,
|
|
252
|
+
full_cmd: &str,
|
|
253
|
+
stdin: &Option<String>,
|
|
254
|
+
) -> Option<CommandResult> {
|
|
255
|
+
let parts: Vec<&str> = full_cmd.split_whitespace().collect();
|
|
256
|
+
let args: Vec<String> = parts.iter().skip(1).map(|s| s.to_string()).collect();
|
|
257
|
+
|
|
258
|
+
let ctx = crate::commands::CommandContext {
|
|
259
|
+
args,
|
|
260
|
+
stdin: stdin.clone(),
|
|
261
|
+
cwd: self.cwd.clone(),
|
|
262
|
+
env: self.env.clone(),
|
|
263
|
+
output_tx: None,
|
|
264
|
+
is_cancelled: None,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
match cmd_name {
|
|
268
|
+
"echo" => Some(crate::commands::echo(ctx).await),
|
|
269
|
+
"pwd" => Some(crate::commands::pwd(ctx).await),
|
|
270
|
+
"cd" => Some(crate::commands::cd(ctx).await),
|
|
271
|
+
"true" => Some(crate::commands::r#true(ctx).await),
|
|
272
|
+
"false" => Some(crate::commands::r#false(ctx).await),
|
|
273
|
+
"sleep" => Some(crate::commands::sleep(ctx).await),
|
|
274
|
+
"cat" => Some(crate::commands::cat(ctx).await),
|
|
275
|
+
"ls" => Some(crate::commands::ls(ctx).await),
|
|
276
|
+
"mkdir" => Some(crate::commands::mkdir(ctx).await),
|
|
277
|
+
"rm" => Some(crate::commands::rm(ctx).await),
|
|
278
|
+
"touch" => Some(crate::commands::touch(ctx).await),
|
|
279
|
+
"cp" => Some(crate::commands::cp(ctx).await),
|
|
280
|
+
"mv" => Some(crate::commands::mv(ctx).await),
|
|
281
|
+
"basename" => Some(crate::commands::basename(ctx).await),
|
|
282
|
+
"dirname" => Some(crate::commands::dirname(ctx).await),
|
|
283
|
+
"env" => Some(crate::commands::env(ctx).await),
|
|
284
|
+
"exit" => Some(crate::commands::exit(ctx).await),
|
|
285
|
+
"which" => Some(crate::commands::which(ctx).await),
|
|
286
|
+
"yes" => Some(crate::commands::yes(ctx).await),
|
|
287
|
+
"seq" => Some(crate::commands::seq(ctx).await),
|
|
288
|
+
"test" => Some(crate::commands::test(ctx).await),
|
|
289
|
+
_ => None,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Shell configuration
|
|
295
|
+
#[derive(Debug, Clone)]
|
|
296
|
+
struct ShellConfig {
|
|
297
|
+
cmd: String,
|
|
298
|
+
args: Vec<String>,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Find an available shell
|
|
302
|
+
fn find_available_shell() -> ShellConfig {
|
|
303
|
+
let is_windows = cfg!(windows);
|
|
304
|
+
|
|
305
|
+
if is_windows {
|
|
306
|
+
ShellConfig {
|
|
307
|
+
cmd: "cmd.exe".to_string(),
|
|
308
|
+
args: vec!["/c".to_string()],
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
let shells = [
|
|
312
|
+
("/bin/sh", "-c"),
|
|
313
|
+
("/usr/bin/sh", "-c"),
|
|
314
|
+
("/bin/bash", "-c"),
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
for (cmd, arg) in shells {
|
|
318
|
+
if std::path::Path::new(cmd).exists() {
|
|
319
|
+
return ShellConfig {
|
|
320
|
+
cmd: cmd.to_string(),
|
|
321
|
+
args: vec![arg.to_string()],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
ShellConfig {
|
|
327
|
+
cmd: "/bin/sh".to_string(),
|
|
328
|
+
args: vec!["-c".to_string()],
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Extension trait to add `.pipe()` method to ProcessRunner
|
|
334
|
+
pub trait PipelineExt {
|
|
335
|
+
/// Pipe the output of this command to another command
|
|
336
|
+
fn pipe(self, command: impl Into<String>) -> PipelineBuilder;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
impl PipelineExt for crate::ProcessRunner {
|
|
340
|
+
fn pipe(self, command: impl Into<String>) -> PipelineBuilder {
|
|
341
|
+
PipelineBuilder {
|
|
342
|
+
first: self,
|
|
343
|
+
additional: vec![command.into()],
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// Builder for piping commands together
|
|
349
|
+
pub struct PipelineBuilder {
|
|
350
|
+
first: crate::ProcessRunner,
|
|
351
|
+
additional: Vec<String>,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
impl PipelineBuilder {
|
|
355
|
+
/// Add another command to the pipeline
|
|
356
|
+
pub fn pipe(mut self, command: impl Into<String>) -> Self {
|
|
357
|
+
self.additional.push(command.into());
|
|
358
|
+
self
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Execute the pipeline
|
|
362
|
+
pub async fn run(mut self) -> Result<CommandResult> {
|
|
363
|
+
// First, run the initial command
|
|
364
|
+
let first_result = self.first.run().await?;
|
|
365
|
+
|
|
366
|
+
if first_result.code != 0 {
|
|
367
|
+
return Ok(first_result);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Then run the rest as a pipeline
|
|
371
|
+
let mut current_stdin = Some(first_result.stdout);
|
|
372
|
+
let mut accumulated_stderr = first_result.stderr;
|
|
373
|
+
let mut last_result = CommandResult {
|
|
374
|
+
stdout: String::new(),
|
|
375
|
+
stderr: String::new(),
|
|
376
|
+
code: 0,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
for cmd_str in &self.additional {
|
|
380
|
+
let mut runner = crate::ProcessRunner::new(
|
|
381
|
+
cmd_str.clone(),
|
|
382
|
+
RunOptions {
|
|
383
|
+
stdin: StdinOption::Content(current_stdin.take().unwrap_or_default()),
|
|
384
|
+
mirror: false,
|
|
385
|
+
capture: true,
|
|
386
|
+
..Default::default()
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
let result = runner.run().await?;
|
|
391
|
+
accumulated_stderr.push_str(&result.stderr);
|
|
392
|
+
|
|
393
|
+
if result.code != 0 {
|
|
394
|
+
return Ok(CommandResult {
|
|
395
|
+
stdout: result.stdout,
|
|
396
|
+
stderr: accumulated_stderr,
|
|
397
|
+
code: result.code,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
current_stdin = Some(result.stdout.clone());
|
|
402
|
+
last_result = result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
Ok(CommandResult {
|
|
406
|
+
stdout: last_result.stdout,
|
|
407
|
+
stderr: accumulated_stderr,
|
|
408
|
+
code: last_result.code,
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
//! Shell quoting utilities for command-stream
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides functions for safely quoting values for shell usage,
|
|
4
|
+
//! preventing command injection and ensuring proper argument handling.
|
|
5
|
+
|
|
6
|
+
/// Quote a value for safe shell usage
|
|
7
|
+
///
|
|
8
|
+
/// This function quotes strings appropriately for use in shell commands,
|
|
9
|
+
/// handling special characters and edge cases.
|
|
10
|
+
///
|
|
11
|
+
/// # Examples
|
|
12
|
+
///
|
|
13
|
+
/// ```
|
|
14
|
+
/// use command_stream::quote::quote;
|
|
15
|
+
///
|
|
16
|
+
/// // Safe characters are passed through unchanged
|
|
17
|
+
/// assert_eq!(quote("hello"), "hello");
|
|
18
|
+
/// assert_eq!(quote("/path/to/file"), "/path/to/file");
|
|
19
|
+
///
|
|
20
|
+
/// // Special characters are quoted
|
|
21
|
+
/// assert_eq!(quote("hello world"), "'hello world'");
|
|
22
|
+
///
|
|
23
|
+
/// // Single quotes in strings are escaped
|
|
24
|
+
/// assert_eq!(quote("it's"), "'it'\\''s'");
|
|
25
|
+
///
|
|
26
|
+
/// // Empty strings are quoted
|
|
27
|
+
/// assert_eq!(quote(""), "''");
|
|
28
|
+
/// ```
|
|
29
|
+
pub fn quote(value: &str) -> String {
|
|
30
|
+
if value.is_empty() {
|
|
31
|
+
return "''".to_string();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// If already properly quoted with single quotes, check if we can use as-is
|
|
35
|
+
if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
|
|
36
|
+
let inner = &value[1..value.len() - 1];
|
|
37
|
+
if !inner.contains('\'') {
|
|
38
|
+
return value.to_string();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If already double-quoted, wrap in single quotes
|
|
43
|
+
if value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
|
|
44
|
+
return format!("'{}'", value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check if the string needs quoting at all
|
|
48
|
+
// Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus, at
|
|
49
|
+
let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
|
|
50
|
+
|
|
51
|
+
if safe_pattern.is_match(value) {
|
|
52
|
+
return value.to_string();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Default behavior: wrap in single quotes and escape any internal single quotes
|
|
56
|
+
// The shell escape sequence for a single quote inside single quotes is: '\''
|
|
57
|
+
// This ends the single quote, adds an escaped single quote, and starts single quotes again
|
|
58
|
+
format!("'{}'", value.replace('\'', "'\\''"))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Quote multiple values and join them with spaces
|
|
62
|
+
///
|
|
63
|
+
/// Convenience function for quoting a list of arguments.
|
|
64
|
+
///
|
|
65
|
+
/// # Examples
|
|
66
|
+
///
|
|
67
|
+
/// ```
|
|
68
|
+
/// use command_stream::quote::quote_all;
|
|
69
|
+
///
|
|
70
|
+
/// let args = vec!["echo", "hello world", "test"];
|
|
71
|
+
/// assert_eq!(quote_all(&args), "echo 'hello world' test");
|
|
72
|
+
/// ```
|
|
73
|
+
pub fn quote_all(values: &[&str]) -> String {
|
|
74
|
+
values
|
|
75
|
+
.iter()
|
|
76
|
+
.map(|v| quote(v))
|
|
77
|
+
.collect::<Vec<_>>()
|
|
78
|
+
.join(" ")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Check if a string needs quoting for shell usage
|
|
82
|
+
///
|
|
83
|
+
/// Returns true if the string contains characters that would be interpreted
|
|
84
|
+
/// specially by the shell.
|
|
85
|
+
///
|
|
86
|
+
/// # Examples
|
|
87
|
+
///
|
|
88
|
+
/// ```
|
|
89
|
+
/// use command_stream::quote::needs_quoting;
|
|
90
|
+
///
|
|
91
|
+
/// assert!(!needs_quoting("hello"));
|
|
92
|
+
/// assert!(needs_quoting("hello world"));
|
|
93
|
+
/// assert!(needs_quoting("$PATH"));
|
|
94
|
+
/// ```
|
|
95
|
+
pub fn needs_quoting(value: &str) -> bool {
|
|
96
|
+
if value.is_empty() {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
|
|
101
|
+
!safe_pattern.is_match(value)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[cfg(test)]
|
|
105
|
+
mod tests {
|
|
106
|
+
use super::*;
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn test_quote_empty() {
|
|
110
|
+
assert_eq!(quote(""), "''");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn test_quote_safe_chars() {
|
|
115
|
+
assert_eq!(quote("hello"), "hello");
|
|
116
|
+
assert_eq!(quote("/path/to/file"), "/path/to/file");
|
|
117
|
+
assert_eq!(quote("file.txt"), "file.txt");
|
|
118
|
+
assert_eq!(quote("key=value"), "key=value");
|
|
119
|
+
assert_eq!(quote("user@host"), "user@host");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[test]
|
|
123
|
+
fn test_quote_special_chars() {
|
|
124
|
+
assert_eq!(quote("hello world"), "'hello world'");
|
|
125
|
+
assert_eq!(quote("it's"), "'it'\\''s'");
|
|
126
|
+
assert_eq!(quote("$var"), "'$var'");
|
|
127
|
+
assert_eq!(quote("test*"), "'test*'");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn test_quote_already_quoted() {
|
|
132
|
+
assert_eq!(quote("'already quoted'"), "'already quoted'");
|
|
133
|
+
assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[test]
|
|
137
|
+
fn test_quote_all() {
|
|
138
|
+
let args = vec!["echo", "hello world", "test"];
|
|
139
|
+
assert_eq!(quote_all(&args), "echo 'hello world' test");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn test_needs_quoting() {
|
|
144
|
+
assert!(!needs_quoting("hello"));
|
|
145
|
+
assert!(!needs_quoting("/path/to/file"));
|
|
146
|
+
assert!(needs_quoting("hello world"));
|
|
147
|
+
assert!(needs_quoting("$PATH"));
|
|
148
|
+
assert!(needs_quoting(""));
|
|
149
|
+
assert!(needs_quoting("test*"));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn test_quote_with_newlines() {
|
|
154
|
+
assert_eq!(quote("line1\nline2"), "'line1\nline2'");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn test_quote_with_tabs() {
|
|
159
|
+
assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
|
|
160
|
+
}
|
|
161
|
+
}
|