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<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    /// Adopt any args that were added to the underlying `Command` (from a
104    /// `Deref`). Calling this function caches args instead of requiring
105    /// throwaway work when subsequently generating printable strings (which
106    /// would be inefficient when done multiple times).
107    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    /// If args are added to the underlying command, they will be added as individual arg groups by `PrintableShellCommand`.
138    fn deref_mut(&mut self) -> &mut Command {
139        &mut self.command
140    }
141}
142
143impl From<Command> for PrintableShellCommand {
144    /// Adopts a `Command`, treating each arg as its own group (i.e. each arg will be printed on a separate line).
145    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        // Not printed by successful tests, but we can at least check this doesn't panic.
216        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        // Not printed by successful tests, but we can at least check this doesn't panic.
239        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        // Not printed by tests, but we can at least check this doesn't panic.
261        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        // Second adoption
303        {
304            let command: &mut Command = printable_shell_command.deref_mut();
305            command.arg("to").arg("the").arg("internet");
306        }
307        // Test adoption idempotency.
308        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    // TODO: test invalid UTF-8
328
329    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}