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