1use std::{
2 ffi::{OsStr, OsString},
3 ops::{Deref, DerefMut},
4 process::Command,
5 str::Utf8Error,
6};
7
8use itertools::Itertools;
9
10use crate::{
11 command::{add_arg_from_command, add_arg_from_command_lossy},
12 print_builder::PrintBuilder,
13 shell_printable::{ShellPrintable, ShellPrintableWithOptions},
14 FormattingOptions,
15};
16
17pub struct PrintableShellCommand {
18 arg_groups: Vec<Vec<OsString>>,
19 command: Command,
20}
21
22impl PrintableShellCommand {
27 pub fn new<S: AsRef<OsStr>>(program: S) -> Self {
28 Self {
29 arg_groups: vec![],
30 command: Command::new(program),
31 }
32 }
33
34 pub fn arg_each<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
36 self.adopt_args();
37 let arg = self.arg_without_adoption(arg);
38 self.command.arg(arg);
39 self
40 }
41
42 pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
43 self.adopt_args();
44 let arg = self.arg_without_adoption(arg);
45 self.command.arg(arg);
46 self
47 }
48
49 fn arg_without_adoption<S: AsRef<OsStr>>(&mut self, arg: S) -> S {
50 self.arg_groups.push(vec![(&arg).into()]);
51 arg
52 }
53
54 pub fn args<I, S>(&mut self, args: I) -> &mut Self
55 where
56 I: IntoIterator<Item = S>,
57 S: AsRef<OsStr>,
58 {
59 self.adopt_args();
60 let args = self.args_without_adoption(args);
61 self.command.args(args);
62 self
63 }
64
65 fn args_without_adoption<I, S>(&mut self, args: I) -> Vec<OsString>
66 where
67 I: IntoIterator<Item = S>,
68 S: AsRef<OsStr>,
69 {
70 let args: Vec<OsString> = args
71 .into_iter()
72 .map(|arg| std::convert::Into::<OsString>::into(&arg))
73 .collect();
74 self.arg_groups.push(args.clone());
75 args
76 }
77
78 fn args_to_adopt(&self) -> Vec<OsString> {
79 let mut to_adopt: Vec<OsString> = vec![];
80 for either_or_both in self
81 .arg_groups
82 .iter()
83 .flatten()
84 .zip_longest(self.command.get_args())
85 {
86 match either_or_both {
87 itertools::EitherOrBoth::Both(a, b) => {
88 if a != b {
89 panic!("Command args do not match. This should not be possible.")
90 }
91 }
92 itertools::EitherOrBoth::Left(_) => {
93 panic!("Command is missing a previously seen arg. This should not be possible.")
94 }
95 itertools::EitherOrBoth::Right(arg) => {
96 to_adopt.push(arg.to_owned());
97 }
98 }
99 }
100 to_adopt
101 }
102
103 pub fn adopt_args(&mut self) -> &mut Self {
108 for arg in self.args_to_adopt() {
109 self.arg_without_adoption(arg);
110 }
111 self
112 }
113
114 fn add_unadopted_args_lossy(&self, print_builder: &mut PrintBuilder) {
115 for arg in self.args_to_adopt() {
116 add_arg_from_command_lossy(print_builder, arg.as_os_str());
117 }
118 }
119
120 fn add_unadopted_args(&self, print_builder: &mut PrintBuilder) -> Result<(), Utf8Error> {
121 for arg in self.args_to_adopt() {
122 add_arg_from_command(print_builder, arg.as_os_str())?;
123 }
124 Ok(())
125 }
126}
127
128impl Deref for PrintableShellCommand {
129 type Target = Command;
130
131 fn deref(&self) -> &Command {
132 &self.command
133 }
134}
135
136impl DerefMut for PrintableShellCommand {
137 fn deref_mut(&mut self) -> &mut Command {
139 &mut self.command
140 }
141}
142
143impl From<Command> for PrintableShellCommand {
144 fn from(command: Command) -> Self {
146 let mut printable_shell_command = Self {
147 arg_groups: vec![],
148 command,
149 };
150 printable_shell_command.adopt_args();
151 printable_shell_command
152 }
153}
154
155impl ShellPrintableWithOptions for PrintableShellCommand {
156 fn printable_invocation_string_lossy_with_options(
157 &self,
158 formatting_options: FormattingOptions,
159 ) -> String {
160 let mut print_builder = PrintBuilder::new(formatting_options);
161 print_builder.add_program_name(&self.get_program().to_string_lossy());
162 for arg_group in &self.arg_groups {
163 let mut strings: Vec<String> = vec![];
164 for arg in arg_group {
165 strings.push(arg.to_string_lossy().to_string())
166 }
167 print_builder.add_arg_group(strings.iter());
168 }
169 self.add_unadopted_args_lossy(&mut print_builder);
170 print_builder.get()
171 }
172
173 fn printable_invocation_string_with_options(
174 &self,
175 formatting_options: FormattingOptions,
176 ) -> Result<String, Utf8Error> {
177 let mut print_builder = PrintBuilder::new(formatting_options);
178 print_builder.add_program_name(TryInto::<&str>::try_into(self.get_program())?);
179 for arg_group in &self.arg_groups {
180 let mut strings: Vec<&str> = vec![];
181 for arg in arg_group {
182 let s = TryInto::<&str>::try_into(arg.as_os_str())?;
183 strings.push(s)
184 }
185 print_builder.add_arg_group(strings.into_iter());
186 }
187 self.add_unadopted_args(&mut print_builder)?;
188 Ok(print_builder.get())
189 }
190}
191
192impl ShellPrintable for PrintableShellCommand {
193 fn printable_invocation_string(&self) -> Result<String, Utf8Error> {
194 self.printable_invocation_string_with_options(Default::default())
195 }
196
197 fn printable_invocation_string_lossy(&self) -> String {
198 self.printable_invocation_string_lossy_with_options(Default::default())
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use std::{ops::DerefMut, process::Command, str::Utf8Error};
205
206 use crate::{
207 FormattingOptions, PrintableShellCommand, Quoting, ShellPrintable,
208 ShellPrintableWithOptions,
209 };
210
211 #[test]
212 fn echo() -> Result<(), Utf8Error> {
213 let mut printable_shell_command = PrintableShellCommand::new("echo");
214 printable_shell_command.args(["#hi"]);
215 let _ = printable_shell_command.print_invocation();
217
218 assert_eq!(
219 printable_shell_command.printable_invocation_string()?,
220 "echo \\
221 '#hi'"
222 );
223 assert_eq!(
224 printable_shell_command.printable_invocation_string()?,
225 printable_shell_command.printable_invocation_string_lossy(),
226 );
227 Ok(())
228 }
229
230 #[test]
231 fn ffmpeg() -> Result<(), Utf8Error> {
232 let mut printable_shell_command = PrintableShellCommand::new("ffmpeg");
233 printable_shell_command
234 .args(["-i", "./test/My video.mp4"])
235 .args(["-filter:v", "setpts=2.0*PTS"])
236 .args(["-filter:a", "atempo=0.5"])
237 .arg("./test/My video (slow-mo).mov");
238 let _ = printable_shell_command.print_invocation();
240
241 assert_eq!(
242 printable_shell_command.printable_invocation_string()?,
243 "ffmpeg \\
244 -i './test/My video.mp4' \\
245 -filter:v 'setpts=2.0*PTS' \\
246 -filter:a atempo=0.5 \\
247 './test/My video (slow-mo).mov'"
248 );
249 assert_eq!(
250 printable_shell_command.printable_invocation_string()?,
251 printable_shell_command.printable_invocation_string_lossy(),
252 );
253 Ok(())
254 }
255
256 #[test]
257 fn from_command() -> Result<(), Utf8Error> {
258 let mut command = Command::new("echo");
259 command.args(["hello", "#world"]);
260 let mut printable_shell_command = PrintableShellCommand::from(command);
262 let _ = printable_shell_command.print_invocation();
263
264 assert_eq!(
265 printable_shell_command.printable_invocation_string()?,
266 "echo \\
267 hello \\
268 '#world'"
269 );
270 Ok(())
271 }
272
273 #[test]
274 fn adoption() -> Result<(), Utf8Error> {
275 let mut printable_shell_command = PrintableShellCommand::new("echo");
276
277 {
278 let command: &mut Command = printable_shell_command.deref_mut();
279 command.arg("hello");
280 command.arg("#world");
281 }
282
283 printable_shell_command.printable_invocation_string()?;
284 assert_eq!(
285 printable_shell_command.printable_invocation_string()?,
286 "echo \\
287 hello \\
288 '#world'"
289 );
290
291 printable_shell_command.args(["wide", "web"]);
292
293 printable_shell_command.printable_invocation_string()?;
294 assert_eq!(
295 printable_shell_command.printable_invocation_string()?,
296 "echo \\
297 hello \\
298 '#world' \\
299 wide web"
300 );
301
302 {
304 let command: &mut Command = printable_shell_command.deref_mut();
305 command.arg("to").arg("the").arg("internet");
306 }
307 printable_shell_command.adopt_args();
309 printable_shell_command.adopt_args();
310 printable_shell_command.adopt_args();
311 assert_eq!(
312 printable_shell_command
313 .printable_invocation_string()
314 .unwrap(),
315 "echo \\
316 hello \\
317 '#world' \\
318 wide web \\
319 to \\
320 the \\
321 internet"
322 );
323
324 Ok(())
325 }
326
327 fn rsync_command_for_testing() -> PrintableShellCommand {
330 let mut printable_shell_command = PrintableShellCommand::new("rsync");
331 printable_shell_command
332 .arg("-avz")
333 .args(["--exclude", ".DS_Store"])
334 .args(["--exclude", ".git"])
335 .arg("./dist/web/experiments.cubing.net/test/deploy/")
336 .arg("experiments.cubing.net:~/experiments.cubing.net/test/deploy/");
337 printable_shell_command
338 }
339
340 #[test]
341 fn extra_safe_quoting() -> Result<(), Utf8Error> {
342 let printable_shell_command = rsync_command_for_testing();
343 assert_eq!(
344 printable_shell_command.printable_invocation_string_with_options(
345 FormattingOptions {
346 quoting: Some(Quoting::ExtraSafe),
347 ..Default::default()
348 }
349 )?,
350 "'rsync' \\
351 '-avz' \\
352 '--exclude' '.DS_Store' \\
353 '--exclude' '.git' \\
354 './dist/web/experiments.cubing.net/test/deploy/' \\
355 'experiments.cubing.net:~/experiments.cubing.net/test/deploy/'"
356 );
357 Ok(())
358 }
359
360 #[test]
361 fn indentation() -> Result<(), Utf8Error> {
362 let printable_shell_command = rsync_command_for_testing();
363 assert_eq!(
364 printable_shell_command.printable_invocation_string_with_options(
365 FormattingOptions {
366 arg_indentation: Some("\t \t".to_owned()),
367 ..Default::default()
368 }
369 )?,
370 "rsync \\
371 -avz \\
372 --exclude .DS_Store \\
373 --exclude .git \\
374 ./dist/web/experiments.cubing.net/test/deploy/ \\
375 experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
376 );
377 assert_eq!(
378 printable_shell_command.printable_invocation_string_with_options(
379 FormattingOptions {
380 arg_indentation: Some("↪ ".to_owned()),
381 ..Default::default()
382 }
383 )?,
384 "rsync \\
385↪ -avz \\
386↪ --exclude .DS_Store \\
387↪ --exclude .git \\
388↪ ./dist/web/experiments.cubing.net/test/deploy/ \\
389↪ experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
390 );
391 assert_eq!(
392 printable_shell_command.printable_invocation_string_with_options(
393 FormattingOptions {
394 main_indentation: Some(" ".to_owned()),
395 ..Default::default()
396 }
397 )?,
398 " rsync \\
399 -avz \\
400 --exclude .DS_Store \\
401 --exclude .git \\
402 ./dist/web/experiments.cubing.net/test/deploy/ \\
403 experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
404 );
405 assert_eq!(
406 printable_shell_command.printable_invocation_string_with_options(
407 FormattingOptions {
408 main_indentation: Some("🙈".to_owned()),
409 arg_indentation: Some("🙉".to_owned()),
410 ..Default::default()
411 }
412 )?,
413 "🙈rsync \\
414🙈🙉-avz \\
415🙈🙉--exclude .DS_Store \\
416🙈🙉--exclude .git \\
417🙈🙉./dist/web/experiments.cubing.net/test/deploy/ \\
418🙈🙉experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
419 );
420 Ok(())
421 }
422
423 #[test]
424 fn line_wrapping() -> Result<(), Utf8Error> {
425 let printable_shell_command = rsync_command_for_testing();
426 assert_eq!(
427 printable_shell_command.printable_invocation_string_with_options(
428 FormattingOptions {
429 argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByEntry),
430 ..Default::default()
431 }
432 )?,
433 printable_shell_command.printable_invocation_string()?
434 );
435 assert_eq!(
436 printable_shell_command.printable_invocation_string_with_options(
437 FormattingOptions {
438 argument_line_wrapping: Some(crate::ArgumentLineWrapping::NestedByEntry),
439 ..Default::default()
440 }
441 )?,
442 "rsync \\
443 -avz \\
444 --exclude \\
445 .DS_Store \\
446 --exclude \\
447 .git \\
448 ./dist/web/experiments.cubing.net/test/deploy/ \\
449 experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
450 );
451 assert_eq!(
452 printable_shell_command.printable_invocation_string_with_options(
453 FormattingOptions {
454 argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
455 ..Default::default()
456 }
457 )?,
458 "rsync \\
459 -avz \\
460 --exclude \\
461 .DS_Store \\
462 --exclude \\
463 .git \\
464 ./dist/web/experiments.cubing.net/test/deploy/ \\
465 experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
466 );
467 Ok(())
468 }
469
470 #[test]
471 fn command_with_space_is_escaped_by_default() -> Result<(), Utf8Error> {
472 let printable_shell_command =
473 PrintableShellCommand::new("/Applications/My App.app/Contents/Resources/my-app");
474 assert_eq!(
475 printable_shell_command.printable_invocation_string_with_options(
476 FormattingOptions {
477 argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
478 ..Default::default()
479 }
480 )?,
481 "'/Applications/My App.app/Contents/Resources/my-app'"
482 );
483 Ok(())
484 }
485
486 #[test]
487 fn command_with_equal_sign_is_escaped_by_default() -> Result<(), Utf8Error> {
488 let printable_shell_command = PrintableShellCommand::new("THIS_LOOKS_LIKE_AN=env-var");
489 assert_eq!(
490 printable_shell_command.printable_invocation_string_with_options(
491 FormattingOptions {
492 argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
493 ..Default::default()
494 }
495 )?,
496 "'THIS_LOOKS_LIKE_AN=env-var'"
497 );
498 Ok(())
499 }
500}