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
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<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 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 fn deref_mut(&mut self) -> &mut Command {
131 &mut self.command
132 }
133}
134
135impl From<Command> for PrintableShellCommand {
136 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 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 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 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 {
296 let command: &mut Command = printable_shell_command.deref_mut();
297 command.arg("to").arg("the").arg("internet");
298 }
299 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 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}