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 = PrintBuilder::new(formatting_options);
167        print_builder.add_program_name(&self.get_program().to_string_lossy());
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(formatting_options);
184        print_builder.add_program_name(TryInto::<&str>::try_into(self.get_program())?);
185        for arg_group in &self.arg_groups {
186            let mut strings: Vec<&str> = vec![];
187            for arg in arg_group {
188                let s = TryInto::<&str>::try_into(arg.as_os_str())?;
189                strings.push(s)
190            }
191            print_builder.add_arg_group(strings.into_iter());
192        }
193        self.add_unadopted_args(&mut print_builder)?;
194        Ok(print_builder.get())
195    }
196}
197
198impl ShellPrintable for PrintableShellCommand {
199    fn printable_invocation_string(&self) -> Result<String, Utf8Error> {
200        self.printable_invocation_string_with_options(Default::default())
201    }
202
203    fn printable_invocation_string_lossy(&self) -> String {
204        self.printable_invocation_string_lossy_with_options(Default::default())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::{ops::DerefMut, process::Command, str::Utf8Error};
211
212    use crate::{
213        FormattingOptions, PrintableShellCommand, Quoting, ShellPrintable,
214        ShellPrintableWithOptions,
215    };
216
217    #[test]
218    fn echo() -> Result<(), Utf8Error> {
219        let mut printable_shell_command = PrintableShellCommand::new("echo");
220        printable_shell_command.args(["#hi"]);
221        // Not printed by successful tests, but we can at least check this doesn't panic.
222        let _ = printable_shell_command.print_invocation();
223
224        assert_eq!(
225            printable_shell_command.printable_invocation_string()?,
226            "echo \\
227  '#hi'"
228        );
229        assert_eq!(
230            printable_shell_command.printable_invocation_string()?,
231            printable_shell_command.printable_invocation_string_lossy(),
232        );
233        Ok(())
234    }
235
236    #[test]
237    fn ffmpeg() -> Result<(), Utf8Error> {
238        let mut printable_shell_command = PrintableShellCommand::new("ffmpeg");
239        printable_shell_command
240            .args(["-i", "./test/My video.mp4"])
241            .args(["-filter:v", "setpts=2.0*PTS"])
242            .args(["-filter:a", "atempo=0.5"])
243            .arg("./test/My video (slow-mo).mov");
244        // Not printed by successful tests, but we can at least check this doesn't panic.
245        let _ = printable_shell_command.print_invocation();
246
247        assert_eq!(
248            printable_shell_command.printable_invocation_string()?,
249            "ffmpeg \\
250  -i './test/My video.mp4' \\
251  -filter:v 'setpts=2.0*PTS' \\
252  -filter:a atempo=0.5 \\
253  './test/My video (slow-mo).mov'"
254        );
255        assert_eq!(
256            printable_shell_command.printable_invocation_string()?,
257            printable_shell_command.printable_invocation_string_lossy(),
258        );
259        Ok(())
260    }
261
262    #[test]
263    fn from_command() -> Result<(), Utf8Error> {
264        let mut command = Command::new("echo");
265        command.args(["hello", "#world"]);
266        // Not printed by tests, but we can at least check this doesn't panic.
267        let mut printable_shell_command = PrintableShellCommand::from(command);
268        let _ = printable_shell_command.print_invocation();
269
270        assert_eq!(
271            printable_shell_command.printable_invocation_string()?,
272            "echo \\
273  hello \\
274  '#world'"
275        );
276        Ok(())
277    }
278
279    #[test]
280    fn adoption() -> Result<(), Utf8Error> {
281        let mut printable_shell_command = PrintableShellCommand::new("echo");
282
283        {
284            let command: &mut Command = printable_shell_command.deref_mut();
285            command.arg("hello");
286            command.arg("#world");
287        }
288
289        printable_shell_command.printable_invocation_string()?;
290        assert_eq!(
291            printable_shell_command.printable_invocation_string()?,
292            "echo \\
293  hello \\
294  '#world'"
295        );
296
297        printable_shell_command.args(["wide", "web"]);
298
299        printable_shell_command.printable_invocation_string()?;
300        assert_eq!(
301            printable_shell_command.printable_invocation_string()?,
302            "echo \\
303  hello \\
304  '#world' \\
305  wide web"
306        );
307
308        // Second adoption
309        {
310            let command: &mut Command = printable_shell_command.deref_mut();
311            command.arg("to").arg("the").arg("internet");
312        }
313        // Test adoption idempotency.
314        printable_shell_command.adopt_args();
315        printable_shell_command.adopt_args();
316        printable_shell_command.adopt_args();
317        assert_eq!(
318            printable_shell_command
319                .printable_invocation_string()
320                .unwrap(),
321            "echo \\
322  hello \\
323  '#world' \\
324  wide web \\
325  to \\
326  the \\
327  internet"
328        );
329
330        Ok(())
331    }
332
333    // TODO: test invalid UTF-8
334
335    fn rsync_command_for_testing() -> PrintableShellCommand {
336        let mut printable_shell_command = PrintableShellCommand::new("rsync");
337        printable_shell_command
338            .arg("-avz")
339            .args(["--exclude", ".DS_Store"])
340            .args(["--exclude", ".git"])
341            .arg("./dist/web/experiments.cubing.net/test/deploy/")
342            .arg("experiments.cubing.net:~/experiments.cubing.net/test/deploy/");
343        printable_shell_command
344    }
345
346    #[test]
347    fn extra_safe_quoting() -> Result<(), Utf8Error> {
348        let printable_shell_command = rsync_command_for_testing();
349        assert_eq!(
350            printable_shell_command.printable_invocation_string_with_options(
351                FormattingOptions {
352                    quoting: Some(Quoting::ExtraSafe),
353                    ..Default::default()
354                }
355            )?,
356            "'rsync' \\
357  '-avz' \\
358  '--exclude' '.DS_Store' \\
359  '--exclude' '.git' \\
360  './dist/web/experiments.cubing.net/test/deploy/' \\
361  'experiments.cubing.net:~/experiments.cubing.net/test/deploy/'"
362        );
363        Ok(())
364    }
365
366    #[test]
367    fn indentation() -> Result<(), Utf8Error> {
368        let printable_shell_command = rsync_command_for_testing();
369        assert_eq!(
370            printable_shell_command.printable_invocation_string_with_options(
371                FormattingOptions {
372                    arg_indentation: Some("\t   \t".to_owned()),
373                    ..Default::default()
374                }
375            )?,
376            "rsync \\
377	   	-avz \\
378	   	--exclude .DS_Store \\
379	   	--exclude .git \\
380	   	./dist/web/experiments.cubing.net/test/deploy/ \\
381	   	experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
382        );
383        assert_eq!(
384            printable_shell_command.printable_invocation_string_with_options(
385                FormattingOptions {
386                    arg_indentation: Some("↪ ".to_owned()),
387                    ..Default::default()
388                }
389            )?,
390            "rsync \\
391↪ -avz \\
392↪ --exclude .DS_Store \\
393↪ --exclude .git \\
394↪ ./dist/web/experiments.cubing.net/test/deploy/ \\
395↪ experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
396        );
397        assert_eq!(
398            printable_shell_command.printable_invocation_string_with_options(
399                FormattingOptions {
400                    main_indentation: Some("  ".to_owned()),
401                    ..Default::default()
402                }
403            )?,
404            "  rsync \\
405    -avz \\
406    --exclude .DS_Store \\
407    --exclude .git \\
408    ./dist/web/experiments.cubing.net/test/deploy/ \\
409    experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
410        );
411        assert_eq!(
412            printable_shell_command.printable_invocation_string_with_options(
413                FormattingOptions {
414                    main_indentation: Some("🙈".to_owned()),
415                    arg_indentation: Some("🙉".to_owned()),
416                    ..Default::default()
417                }
418            )?,
419            "🙈rsync \\
420🙈🙉-avz \\
421🙈🙉--exclude .DS_Store \\
422🙈🙉--exclude .git \\
423🙈🙉./dist/web/experiments.cubing.net/test/deploy/ \\
424🙈🙉experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
425        );
426        Ok(())
427    }
428
429    #[test]
430    fn line_wrapping() -> Result<(), Utf8Error> {
431        let printable_shell_command = rsync_command_for_testing();
432        assert_eq!(
433            printable_shell_command.printable_invocation_string_with_options(
434                FormattingOptions {
435                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByEntry),
436                    ..Default::default()
437                }
438            )?,
439            printable_shell_command.printable_invocation_string()?
440        );
441        assert_eq!(
442            printable_shell_command.printable_invocation_string_with_options(
443                FormattingOptions {
444                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::NestedByEntry),
445                    ..Default::default()
446                }
447            )?,
448            "rsync \\
449  -avz \\
450  --exclude \\
451    .DS_Store \\
452  --exclude \\
453    .git \\
454  ./dist/web/experiments.cubing.net/test/deploy/ \\
455  experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
456        );
457        assert_eq!(
458            printable_shell_command.printable_invocation_string_with_options(
459                FormattingOptions {
460                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
461                    ..Default::default()
462                }
463            )?,
464            "rsync \\
465  -avz \\
466  --exclude \\
467  .DS_Store \\
468  --exclude \\
469  .git \\
470  ./dist/web/experiments.cubing.net/test/deploy/ \\
471  experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
472        );
473        Ok(())
474    }
475
476    #[test]
477    fn command_with_space_is_escaped_by_default() -> Result<(), Utf8Error> {
478        let printable_shell_command =
479            PrintableShellCommand::new("/Applications/My App.app/Contents/Resources/my-app");
480        assert_eq!(
481            printable_shell_command.printable_invocation_string_with_options(
482                FormattingOptions {
483                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
484                    ..Default::default()
485                }
486            )?,
487            "'/Applications/My App.app/Contents/Resources/my-app'"
488        );
489        Ok(())
490    }
491
492    #[test]
493    fn command_with_equal_sign_is_escaped_by_default() -> Result<(), Utf8Error> {
494        let printable_shell_command = PrintableShellCommand::new("THIS_LOOKS_LIKE_AN=env-var");
495        assert_eq!(
496            printable_shell_command.printable_invocation_string_with_options(
497                FormattingOptions {
498                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
499                    ..Default::default()
500                }
501            )?,
502            "'THIS_LOOKS_LIKE_AN=env-var'"
503        );
504        Ok(())
505    }
506
507    #[test]
508    fn arg_each() -> Result<(), Utf8Error> {
509        let mut printable_shell_command = PrintableShellCommand::new("echo");
510        printable_shell_command.arg_each(["hello", "world"]);
511        assert_eq!(
512            printable_shell_command.printable_invocation_string()?,
513            "echo \\
514  hello \\
515  world"
516        );
517        Ok(())
518    }
519}