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    FormattingOptions,
12    command::{add_arg_from_command, add_arg_from_command_lossy},
13    print_builder::PrintBuilder,
14    shell_printable::{ShellPrintable, ShellPrintableWithOptions},
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    pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
35        self.adopt_args();
36        let arg = self.arg_without_adoption(arg);
37        self.command.arg(arg);
38        self
39    }
40
41    fn arg_without_adoption<S: AsRef<OsStr>>(&mut self, arg: S) -> S {
42        self.arg_groups.push(vec![(&arg).into()]);
43        arg
44    }
45
46    pub fn args<I, S>(&mut self, args: I) -> &mut Self
47    where
48        I: IntoIterator<Item = S>,
49        S: AsRef<OsStr>,
50    {
51        self.adopt_args();
52        let args = self.args_without_adoption(args);
53        self.command.args(args);
54        self
55    }
56
57    fn args_without_adoption<I, S>(&mut self, args: I) -> Vec<OsString>
58    where
59        I: IntoIterator<Item = S>,
60        S: AsRef<OsStr>,
61    {
62        let args: Vec<OsString> = args
63            .into_iter()
64            .map(|arg| std::convert::Into::<OsString>::into(&arg))
65            .collect();
66        self.arg_groups.push(args.clone());
67        args
68    }
69
70    fn args_to_adopt(&self) -> Vec<OsString> {
71        let mut to_adopt: Vec<OsString> = vec![];
72        for either_or_both in self
73            .arg_groups
74            .iter()
75            .flatten()
76            .zip_longest(self.command.get_args())
77        {
78            match either_or_both {
79                itertools::EitherOrBoth::Both(a, b) => {
80                    if a != b {
81                        panic!("Command args do not match. This should not be possible.")
82                    }
83                }
84                itertools::EitherOrBoth::Left(_) => {
85                    panic!("Command is missing a previously seen arg. This should not be possible.")
86                }
87                itertools::EitherOrBoth::Right(arg) => {
88                    to_adopt.push(arg.to_owned());
89                }
90            }
91        }
92        to_adopt
93    }
94
95    /// Adopt any args that were added to the underlying `Command` (from a
96    /// `Deref`). Calling this function caches args instead of requiring
97    /// throwaway work when subsequently generating printable strings (which
98    /// would be inefficient when done multiple times).
99    pub fn adopt_args(&mut self) -> &mut Self {
100        for arg in self.args_to_adopt() {
101            self.arg_without_adoption(arg);
102        }
103        self
104    }
105
106    fn add_unadopted_args_lossy(&self, print_builder: &mut PrintBuilder) {
107        for arg in self.args_to_adopt() {
108            add_arg_from_command_lossy(print_builder, arg.as_os_str());
109        }
110    }
111
112    fn add_unadopted_args(&self, print_builder: &mut PrintBuilder) -> Result<(), Utf8Error> {
113        for arg in self.args_to_adopt() {
114            add_arg_from_command(print_builder, arg.as_os_str())?;
115        }
116        Ok(())
117    }
118}
119
120impl Deref for PrintableShellCommand {
121    type Target = Command;
122
123    fn deref(&self) -> &Command {
124        &self.command
125    }
126}
127
128impl DerefMut for PrintableShellCommand {
129    /// If args are added to the underlying command, they will be added as individual arg groups by `PrintableShellCommand`.
130    fn deref_mut(&mut self) -> &mut Command {
131        &mut self.command
132    }
133}
134
135impl From<Command> for PrintableShellCommand {
136    /// Adopts a `Command`, treating each arg as its own group (i.e. each arg will be printed on a separate line).
137    fn from(command: Command) -> Self {
138        let mut printable_shell_command = Self {
139            arg_groups: vec![],
140            command,
141        };
142        printable_shell_command.adopt_args();
143        printable_shell_command
144    }
145}
146
147impl ShellPrintableWithOptions for PrintableShellCommand {
148    fn printable_invocation_string_lossy_with_options(
149        &self,
150        formatting_options: FormattingOptions,
151    ) -> String {
152        let mut print_builder = PrintBuilder::new(formatting_options);
153        print_builder.add_program_name(&self.get_program().to_string_lossy());
154        for arg_group in &self.arg_groups {
155            let mut strings: Vec<String> = vec![];
156            for arg in arg_group {
157                strings.push(arg.to_string_lossy().to_string())
158            }
159            print_builder.add_arg_group(strings.iter());
160        }
161        self.add_unadopted_args_lossy(&mut print_builder);
162        print_builder.get()
163    }
164
165    fn printable_invocation_string_with_options(
166        &self,
167        formatting_options: FormattingOptions,
168    ) -> Result<String, Utf8Error> {
169        let mut print_builder = PrintBuilder::new(formatting_options);
170        print_builder.add_program_name(TryInto::<&str>::try_into(self.get_program())?);
171        for arg_group in &self.arg_groups {
172            let mut strings: Vec<&str> = vec![];
173            for arg in arg_group {
174                let s = TryInto::<&str>::try_into(arg.as_os_str())?;
175                strings.push(s)
176            }
177            print_builder.add_arg_group(strings.into_iter());
178        }
179        self.add_unadopted_args(&mut print_builder)?;
180        Ok(print_builder.get())
181    }
182}
183
184impl ShellPrintable for PrintableShellCommand {
185    fn printable_invocation_string(&self) -> Result<String, Utf8Error> {
186        self.printable_invocation_string_with_options(Default::default())
187    }
188
189    fn printable_invocation_string_lossy(&self) -> String {
190        self.printable_invocation_string_lossy_with_options(Default::default())
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::{ops::DerefMut, process::Command, str::Utf8Error};
197
198    use crate::{
199        FormattingOptions, PrintableShellCommand, Quoting, ShellPrintable,
200        ShellPrintableWithOptions,
201    };
202
203    #[test]
204    fn echo() -> Result<(), Utf8Error> {
205        let mut printable_shell_command = PrintableShellCommand::new("echo");
206        printable_shell_command.args(["#hi"]);
207        // Not printed by successful tests, but we can at least check this doesn't panic.
208        let _ = printable_shell_command.print_invocation();
209
210        assert_eq!(
211            printable_shell_command.printable_invocation_string()?,
212            "echo \\
213  '#hi'"
214        );
215        assert_eq!(
216            printable_shell_command.printable_invocation_string()?,
217            printable_shell_command.printable_invocation_string_lossy(),
218        );
219        Ok(())
220    }
221
222    #[test]
223    fn ffmpeg() -> Result<(), Utf8Error> {
224        let mut printable_shell_command = PrintableShellCommand::new("ffmpeg");
225        printable_shell_command
226            .args(["-i", "./test/My video.mp4"])
227            .args(["-filter:v", "setpts=2.0*PTS"])
228            .args(["-filter:a", "atempo=0.5"])
229            .arg("./test/My video (slow-mo).mov");
230        // Not printed by successful tests, but we can at least check this doesn't panic.
231        let _ = printable_shell_command.print_invocation();
232
233        assert_eq!(
234            printable_shell_command.printable_invocation_string()?,
235            "ffmpeg \\
236  -i './test/My video.mp4' \\
237  -filter:v 'setpts=2.0*PTS' \\
238  -filter:a atempo=0.5 \\
239  './test/My video (slow-mo).mov'"
240        );
241        assert_eq!(
242            printable_shell_command.printable_invocation_string()?,
243            printable_shell_command.printable_invocation_string_lossy(),
244        );
245        Ok(())
246    }
247
248    #[test]
249    fn from_command() -> Result<(), Utf8Error> {
250        let mut command = Command::new("echo");
251        command.args(["hello", "#world"]);
252        // Not printed by tests, but we can at least check this doesn't panic.
253        let mut printable_shell_command = PrintableShellCommand::from(command);
254        let _ = printable_shell_command.print_invocation();
255
256        assert_eq!(
257            printable_shell_command.printable_invocation_string()?,
258            "echo \\
259  hello \\
260  '#world'"
261        );
262        Ok(())
263    }
264
265    #[test]
266    fn adoption() -> Result<(), Utf8Error> {
267        let mut printable_shell_command = PrintableShellCommand::new("echo");
268
269        {
270            let command: &mut Command = printable_shell_command.deref_mut();
271            command.arg("hello");
272            command.arg("#world");
273        }
274
275        printable_shell_command.printable_invocation_string()?;
276        assert_eq!(
277            printable_shell_command.printable_invocation_string()?,
278            "echo \\
279  hello \\
280  '#world'"
281        );
282
283        printable_shell_command.args(["wide", "web"]);
284
285        printable_shell_command.printable_invocation_string()?;
286        assert_eq!(
287            printable_shell_command.printable_invocation_string()?,
288            "echo \\
289  hello \\
290  '#world' \\
291  wide web"
292        );
293
294        // Second adoption
295        {
296            let command: &mut Command = printable_shell_command.deref_mut();
297            command.arg("to").arg("the").arg("internet");
298        }
299        // Test adoption idempotency.
300        printable_shell_command.adopt_args();
301        printable_shell_command.adopt_args();
302        printable_shell_command.adopt_args();
303        assert_eq!(
304            printable_shell_command
305                .printable_invocation_string()
306                .unwrap(),
307            "echo \\
308  hello \\
309  '#world' \\
310  wide web \\
311  to \\
312  the \\
313  internet"
314        );
315
316        Ok(())
317    }
318
319    // TODO: test invalid UTF-8
320
321    fn rsync_command_for_testing() -> PrintableShellCommand {
322        let mut printable_shell_command = PrintableShellCommand::new("rsync");
323        printable_shell_command
324            .arg("-avz")
325            .args(["--exclude", ".DS_Store"])
326            .args(["--exclude", ".git"])
327            .arg("./dist/web/experiments.cubing.net/test/deploy/")
328            .arg("experiments.cubing.net:~/experiments.cubing.net/test/deploy/");
329        printable_shell_command
330    }
331
332    #[test]
333    fn extra_safe_quoting() -> Result<(), Utf8Error> {
334        let printable_shell_command = rsync_command_for_testing();
335        assert_eq!(
336            printable_shell_command.printable_invocation_string_with_options(
337                FormattingOptions {
338                    quoting: Some(Quoting::ExtraSafe),
339                    ..Default::default()
340                }
341            )?,
342            "'rsync' \\
343  '-avz' \\
344  '--exclude' '.DS_Store' \\
345  '--exclude' '.git' \\
346  './dist/web/experiments.cubing.net/test/deploy/' \\
347  'experiments.cubing.net:~/experiments.cubing.net/test/deploy/'"
348        );
349        Ok(())
350    }
351
352    #[test]
353    fn indentation() -> Result<(), Utf8Error> {
354        let printable_shell_command = rsync_command_for_testing();
355        assert_eq!(
356            printable_shell_command.printable_invocation_string_with_options(
357                FormattingOptions {
358                    arg_indentation: Some("\t   \t".to_owned()),
359                    ..Default::default()
360                }
361            )?,
362            "rsync \\
363	   	-avz \\
364	   	--exclude .DS_Store \\
365	   	--exclude .git \\
366	   	./dist/web/experiments.cubing.net/test/deploy/ \\
367	   	experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
368        );
369        assert_eq!(
370            printable_shell_command.printable_invocation_string_with_options(
371                FormattingOptions {
372                    arg_indentation: Some("↪ ".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                    main_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                    arg_indentation: Some("🙉".to_owned()),
402                    ..Default::default()
403                }
404            )?,
405            "🙈rsync \\
406🙈🙉-avz \\
407🙈🙉--exclude .DS_Store \\
408🙈🙉--exclude .git \\
409🙈🙉./dist/web/experiments.cubing.net/test/deploy/ \\
410🙈🙉experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
411        );
412        Ok(())
413    }
414
415    #[test]
416    fn line_wrapping() -> Result<(), Utf8Error> {
417        let printable_shell_command = rsync_command_for_testing();
418        assert_eq!(
419            printable_shell_command.printable_invocation_string_with_options(
420                FormattingOptions {
421                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByEntry),
422                    ..Default::default()
423                }
424            )?,
425            printable_shell_command.printable_invocation_string()?
426        );
427        assert_eq!(
428            printable_shell_command.printable_invocation_string_with_options(
429                FormattingOptions {
430                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::NestedByEntry),
431                    ..Default::default()
432                }
433            )?,
434            "rsync \\
435  -avz \\
436  --exclude \\
437    .DS_Store \\
438  --exclude \\
439    .git \\
440  ./dist/web/experiments.cubing.net/test/deploy/ \\
441  experiments.cubing.net:~/experiments.cubing.net/test/deploy/"
442        );
443        assert_eq!(
444            printable_shell_command.printable_invocation_string_with_options(
445                FormattingOptions {
446                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
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        Ok(())
460    }
461
462    #[test]
463    fn command_with_space_is_escaped_by_default() -> Result<(), Utf8Error> {
464        let printable_shell_command =
465            PrintableShellCommand::new("/Applications/My App.app/Contents/Resources/my-app");
466        assert_eq!(
467            printable_shell_command.printable_invocation_string_with_options(
468                FormattingOptions {
469                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
470                    ..Default::default()
471                }
472            )?,
473            "'/Applications/My App.app/Contents/Resources/my-app'"
474        );
475        Ok(())
476    }
477
478    #[test]
479    fn command_with_equal_sign_is_escaped_by_default() -> Result<(), Utf8Error> {
480        let printable_shell_command = PrintableShellCommand::new("THIS_LOOKS_LIKE_AN=env-var");
481        assert_eq!(
482            printable_shell_command.printable_invocation_string_with_options(
483                FormattingOptions {
484                    argument_line_wrapping: Some(crate::ArgumentLineWrapping::ByArgument),
485                    ..Default::default()
486                }
487            )?,
488            "'THIS_LOOKS_LIKE_AN=env-var'"
489        );
490        Ok(())
491    }
492}