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 = 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 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 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 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 {
310 let command: &mut Command = printable_shell_command.deref_mut();
311 command.arg("to").arg("the").arg("internet");
312 }
313 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 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}