printable_shell_command/
printable_shell_command.rs

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
22// TODO: this depends on the interface to `Command` supporting the *appending*
23// of algs but not the removal/reordering/editing of any args added so far. Is
24// it even remotely possible to fail compilation if the args in `Command` become
25// mutable like this?
26impl 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    /// Add args using `.arg(…)` each, in bulk.
35    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    /// Adopt any args that were added to the underlying `Command` (from a
110    /// `Deref`). Calling this function caches args instead of requiring
111    /// throwaway work when subsequently generating printable strings (which
112    /// would be inefficient when done multiple times).
113    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    /// If args are added to the underlying command, they will be added as individual arg groups by `PrintableShellCommand`.
144    fn deref_mut(&mut self) -> &mut Command {
145        &mut self.command
146    }
147}
148
149impl From<Command> for PrintableShellCommand {
150    /// Adopts a `Command`, treating each arg as its own group (i.e. each arg will be printed on a separate line).
151    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        // Not printed by successful tests, but we can at least check this doesn't panic.
224        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        // Not printed by successful tests, but we can at least check this doesn't panic.
247        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        // Not printed by tests, but we can at least check this doesn't panic.
269        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        // Second adoption
311        {
312            let command: &mut Command = printable_shell_command.deref_mut();
313            command.arg("to").arg("the").arg("internet");
314        }
315        // Test adoption idempotency.
316        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    // TODO: test invalid UTF-8
336
337    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}