1use std::ffi::OsStr;
11use std::ffi::OsString;
12use std::ops::Deref;
13use std::process::ExitStatus;
14use std::process::Output;
15
16use serde::Deserialize;
17use serde::Serialize;
18
19pub struct FloweyShell {
25 inner: xshell::Shell,
26 wrapper: Option<CommandWrapperKind>,
27}
28
29impl FloweyShell {
30 #[expect(clippy::disallowed_methods)]
32 pub fn new() -> anyhow::Result<Self> {
33 Ok(Self {
34 inner: xshell::Shell::new()?,
35 wrapper: None,
36 })
37 }
38
39 pub fn set_wrapper(&mut self, wrapper: Option<CommandWrapperKind>) {
42 self.wrapper = wrapper;
43 }
44
45 pub fn xshell(&self) -> &xshell::Shell {
50 &self.inner
51 }
52
53 pub fn wrap<'a>(&'a self, cmd: xshell::Cmd<'a>) -> FloweyCmd<'a> {
56 FloweyCmd {
57 inner: cmd,
58 env_changes: Vec::new(),
59 stdin_contents: None,
60 ignore_status: false,
61 quiet: false,
62 secret: false,
63 ignore_stdout: false,
64 ignore_stderr: false,
65 wrapper: self.wrapper.clone(),
66 sh: &self.inner,
67 }
68 }
69}
70
71impl Deref for FloweyShell {
72 type Target = xshell::Shell;
73
74 fn deref(&self) -> &xshell::Shell {
75 &self.inner
76 }
77}
78
79enum EnvChange {
81 Set(OsString, OsString),
82 Remove(OsString),
83 Clear,
84}
85
86pub struct FloweyCmd<'a> {
95 inner: xshell::Cmd<'a>,
97 env_changes: Vec<EnvChange>,
99 stdin_contents: Option<Vec<u8>>,
100 ignore_status: bool,
101 quiet: bool,
102 secret: bool,
103 ignore_stdout: bool,
104 ignore_stderr: bool,
105 wrapper: Option<CommandWrapperKind>,
106 sh: &'a xshell::Shell,
107}
108
109impl<'a> FloweyCmd<'a> {
111 pub fn arg<P: AsRef<OsStr>>(mut self, arg: P) -> Self {
113 self.inner = self.inner.arg(arg);
114 self
115 }
116
117 pub fn args<I>(mut self, args: I) -> Self
119 where
120 I: IntoIterator,
121 I::Item: AsRef<OsStr>,
122 {
123 self.inner = self.inner.args(args);
124 self
125 }
126
127 pub fn env<K: AsRef<OsStr>, V: AsRef<OsStr>>(mut self, key: K, val: V) -> Self {
129 self.env_changes.push(EnvChange::Set(
130 key.as_ref().to_owned(),
131 val.as_ref().to_owned(),
132 ));
133 self
134 }
135
136 pub fn envs<I, K, V>(mut self, vars: I) -> Self
139 where
140 I: IntoIterator<Item = (K, V)>,
141 K: AsRef<OsStr>,
142 V: AsRef<OsStr>,
143 {
144 for (k, v) in vars {
145 self.env_changes
146 .push(EnvChange::Set(k.as_ref().to_owned(), v.as_ref().to_owned()));
147 }
148 self
149 }
150
151 pub fn env_remove<K: AsRef<OsStr>>(mut self, key: K) -> Self {
153 self.env_changes
154 .push(EnvChange::Remove(key.as_ref().to_owned()));
155 self
156 }
157
158 pub fn env_clear(mut self) -> Self {
160 self.env_changes.push(EnvChange::Clear);
161 self
162 }
163
164 pub fn ignore_status(mut self) -> Self {
167 self.ignore_status = true;
168 self
169 }
170
171 pub fn set_ignore_status(&mut self, yes: bool) {
173 self.ignore_status = yes;
174 }
175
176 pub fn quiet(mut self) -> Self {
178 self.quiet = true;
179 self
180 }
181
182 pub fn set_quiet(&mut self, yes: bool) {
184 self.quiet = yes;
185 }
186
187 pub fn secret(mut self) -> Self {
190 self.secret = true;
191 self
192 }
193
194 pub fn set_secret(&mut self, yes: bool) {
196 self.secret = yes;
197 }
198
199 pub fn stdin(mut self, stdin: impl AsRef<[u8]>) -> Self {
201 self.stdin_contents = Some(stdin.as_ref().to_vec());
202 self
203 }
204
205 pub fn ignore_stdout(mut self) -> Self {
207 self.ignore_stdout = true;
208 self
209 }
210
211 pub fn set_ignore_stdout(&mut self, yes: bool) {
213 self.ignore_stdout = yes;
214 }
215
216 pub fn ignore_stderr(mut self) -> Self {
218 self.ignore_stderr = true;
219 self
220 }
221
222 pub fn set_ignore_stderr(&mut self, yes: bool) {
224 self.ignore_stderr = yes;
225 }
226
227 fn into_resolved(self) -> xshell::Cmd<'a> {
231 let mut cmd = match self.wrapper {
232 Some(wrapper) => wrapper.wrap_cmd(self.sh, self.inner),
233 None => self.inner,
234 };
235
236 for change in self.env_changes {
238 match change {
239 EnvChange::Set(k, v) => cmd = cmd.env(k, v),
240 EnvChange::Remove(k) => cmd = cmd.env_remove(k),
241 EnvChange::Clear => cmd = cmd.env_clear(),
242 }
243 }
244 if let Some(stdin) = self.stdin_contents {
245 cmd = cmd.stdin(stdin);
246 }
247 cmd.set_ignore_status(self.ignore_status);
248 cmd.set_quiet(self.quiet);
249 cmd.set_secret(self.secret);
250 cmd.set_ignore_stdout(self.ignore_stdout);
251 cmd.set_ignore_stderr(self.ignore_stderr);
252
253 cmd
254 }
255
256 pub fn run(self) -> xshell::Result<()> {
258 self.into_resolved().run()
259 }
260
261 pub fn run_interactive(self) -> anyhow::Result<ExitStatus> {
263 anyhow::ensure!(
264 self.stdin_contents.is_none(),
265 "interactive commands cannot also supply stdin contents"
266 );
267 if !self.quiet {
268 eprintln!("$ {self}");
269 }
270 let ignore_status = self.ignore_status;
271 let description = self.to_string();
272 let mut command: std::process::Command = self.into_resolved().into();
273 let status = command.status()?;
274 anyhow::ensure!(
275 status.success() || ignore_status,
276 "command {description} exited with {status}"
277 );
278 Ok(status)
279 }
280
281 pub fn read(self) -> xshell::Result<String> {
284 self.into_resolved().read()
285 }
286
287 pub fn read_stderr(self) -> xshell::Result<String> {
290 self.into_resolved().read_stderr()
291 }
292
293 pub fn output(self) -> xshell::Result<Output> {
295 self.into_resolved().output()
296 }
297}
298
299impl std::fmt::Display for FloweyCmd<'_> {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 if self.secret {
302 return f.write_str("<secret>");
303 }
304 std::fmt::Display::fmt(&self.inner, f)
306 }
307}
308
309#[derive(Clone, Debug, Serialize, Deserialize)]
315pub enum CommandWrapperKind {
316 NixShell {
318 path: Option<std::path::PathBuf>,
322 },
323 #[cfg(test)]
325 ShCmd,
326 #[cfg(test)]
328 CmdExe,
329 #[cfg(test)]
331 Prefix,
332}
333
334impl CommandWrapperKind {
335 fn wrap_cmd<'a>(self, sh: &'a xshell::Shell, cmd: xshell::Cmd<'a>) -> xshell::Cmd<'a> {
341 let cmd_str = format!("{cmd}");
342 match self {
343 CommandWrapperKind::NixShell { path } => {
344 let mut wrapped = sh.cmd("nix-shell");
345 if let Some(path) = path {
346 wrapped = wrapped.arg(path);
347 }
348 wrapped.arg("--pure").arg("--run").arg(cmd_str)
349 }
350 #[cfg(test)]
351 CommandWrapperKind::ShCmd => sh.cmd("sh").arg("-c").arg(cmd_str),
352 #[cfg(test)]
353 CommandWrapperKind::CmdExe => {
354 let cmd_body = cmd_str
357 .strip_prefix("cmd /C ")
358 .unwrap_or(&cmd_str)
359 .trim_matches('"');
360 sh.cmd("cmd").arg("/C").arg(cmd_body)
361 }
362 #[cfg(test)]
363 CommandWrapperKind::Prefix => sh.cmd("echo").arg(format!("WRAPPED: {cmd_str}")),
364 }
365 }
366}
367
368#[cfg(test)]
369#[expect(clippy::disallowed_macros, reason = "test module")]
370mod tests {
371 use super::*;
372
373 fn env_test_wrapper() -> CommandWrapperKind {
374 if cfg!(windows) {
375 CommandWrapperKind::CmdExe
376 } else {
377 CommandWrapperKind::ShCmd
378 }
379 }
380
381 fn print_env_cmd<'a>(sh: &'a FloweyShell, var: &str) -> xshell::Cmd<'a> {
382 if cfg!(windows) {
383 sh.xshell()
384 .cmd("cmd")
385 .arg("/C")
386 .arg(format!("if defined {var} (echo %{var}%) else exit /b 1"))
387 } else {
388 sh.xshell().cmd("printenv").arg(var)
389 }
390 }
391
392 fn fail_cmd<'a>(sh: &'a FloweyShell) -> xshell::Cmd<'a> {
393 if cfg!(windows) {
394 sh.xshell().cmd("cmd").arg("/C").arg("exit /b 1")
395 } else {
396 sh.xshell().cmd("false")
397 }
398 }
399
400 #[test]
401 fn no_wrapper_runs_command_directly() {
402 let sh = FloweyShell::new().unwrap();
403 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo hello"));
404 let output = cmd.read().unwrap();
405 assert_eq!(output, "hello");
406 }
407
408 #[test]
409 fn wrapper_transforms_command() {
410 let mut sh = FloweyShell::new().unwrap();
411 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
412 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag"));
413 let output = cmd.read().unwrap();
414 assert_eq!(output, "WRAPPED: my-program --flag");
415 }
416
417 #[test]
418 fn interactive_commands_preserve_wrapping_and_status_handling() {
419 let mut sh = FloweyShell::new().unwrap();
420 assert!(sh.wrap(fail_cmd(&sh)).run_interactive().is_err());
421 assert_eq!(
422 sh.wrap(fail_cmd(&sh))
423 .ignore_status()
424 .run_interactive()
425 .unwrap()
426 .code(),
427 Some(1)
428 );
429 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
430 sh.wrap(xshell::cmd!(sh.xshell(), "nonexistent-program --flag"))
431 .run_interactive()
432 .unwrap();
433 assert!(
434 sh.wrap(xshell::cmd!(sh.xshell(), "echo"))
435 .stdin("incompatible input")
436 .run_interactive()
437 .is_err()
438 );
439 }
440
441 #[test]
442 fn env_vars_survive_with_wrapper() {
443 let mut sh = FloweyShell::new().unwrap();
444 sh.set_wrapper(Some(env_test_wrapper()));
445
446 let cmd = sh
447 .wrap(print_env_cmd(&sh, "MY_FLOWEY_WRAP_TEST"))
448 .env("MY_FLOWEY_WRAP_TEST", "survived_wrapping");
449 let output = cmd.read().unwrap();
450 assert_eq!(output, "survived_wrapping");
451 }
452
453 #[test]
454 fn stdin_survives_wrapping() {
455 let sh = FloweyShell::new().unwrap();
456 let cmd = sh
457 .wrap(xshell::cmd!(sh.xshell(), "cat"))
458 .stdin("test input");
459 let output = cmd.read().unwrap();
460 assert_eq!(output, "test input");
461 }
462
463 #[test]
464 fn stdin_survives_with_wrapper() {
465 let mut sh = FloweyShell::new().unwrap();
466 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
467
468 let cmd = sh
469 .wrap(xshell::cmd!(sh.xshell(), "cat"))
470 .stdin("wrapped stdin test");
471 let output = cmd.read().unwrap();
472 assert_eq!(output, "wrapped stdin test");
473 }
474
475 #[test]
476 fn ignore_status_survives_wrapping() {
477 let mut sh = FloweyShell::new().unwrap();
478 sh.set_wrapper(Some(env_test_wrapper()));
479
480 let cmd = sh.wrap(fail_cmd(&sh)).ignore_status();
482 assert!(cmd.run().is_ok());
483 }
484
485 #[test]
486 fn display_shows_unwrapped_command() {
487 let mut sh = FloweyShell::new().unwrap();
488 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
489 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag value"));
490 assert_eq!(format!("{cmd}"), "my-program --flag value");
491 }
492
493 #[test]
494 fn nix_wrapper_display_without_path() {
495 let sh = FloweyShell::new().unwrap();
496 let cmd = CommandWrapperKind::NixShell { path: None }.wrap_cmd(
497 sh.xshell(),
498 xshell::cmd!(sh.xshell(), "cargo build --release"),
499 );
500 assert_eq!(
501 format!("{cmd}"),
502 "nix-shell --pure --run \"cargo build --release\""
503 );
504 }
505
506 #[test]
507 fn nix_wrapper_display_with_path() {
508 let sh = FloweyShell::new().unwrap();
509 let cmd = CommandWrapperKind::NixShell {
510 path: Some("/my/shell.nix".into()),
511 }
512 .wrap_cmd(sh.xshell(), xshell::cmd!(sh.xshell(), "cargo build"));
513 assert_eq!(
514 format!("{cmd}"),
515 "nix-shell /my/shell.nix --pure --run \"cargo build\""
516 );
517 }
518
519 #[test]
520 fn deref_exposes_shell_methods() {
521 let sh = FloweyShell::new().unwrap();
522 let _ = sh.current_dir();
523 }
524
525 #[test]
526 fn set_wrapper_clears_wrapper() {
527 let mut sh = FloweyShell::new().unwrap();
528 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
529 sh.set_wrapper(None);
530 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo direct"));
532 let output = cmd.read().unwrap();
533 assert_eq!(output, "direct");
534 }
535
536 #[test]
537 fn quiet_flag_survives_wrapping() {
538 let mut sh = FloweyShell::new().unwrap();
539 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
540 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo test")).quiet();
542 let output = cmd.read().unwrap();
543 assert_eq!(output, "WRAPPED: echo test");
544 }
545
546 #[test]
547 fn args_accumulate_before_wrapping() {
548 let mut sh = FloweyShell::new().unwrap();
549 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
550 let cmd = sh
551 .wrap(xshell::cmd!(sh.xshell(), "echo"))
552 .arg("one")
553 .arg("two");
554 let output = cmd.read().unwrap();
555 assert_eq!(output, "WRAPPED: echo one two");
556 }
557
558 #[test]
559 fn secret_display_is_redacted() {
560 let sh = FloweyShell::new().unwrap();
561 let cmd = sh
562 .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
563 .secret();
564 assert_eq!(format!("{cmd}"), "<secret>");
565 }
566
567 #[test]
568 fn secret_display_redacted_with_wrapper() {
569 let mut sh = FloweyShell::new().unwrap();
570 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
571 let cmd = sh
572 .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
573 .secret();
574 assert_eq!(format!("{cmd}"), "<secret>");
575 }
576
577 #[test]
578 fn env_remove_survives_wrapping() {
579 let mut sh = FloweyShell::new().unwrap();
580 sh.set_wrapper(Some(env_test_wrapper()));
581
582 sh.set_var("FLOWEY_REMOVE_TEST", "present");
585 let cmd = sh
586 .wrap(print_env_cmd(&sh, "FLOWEY_REMOVE_TEST"))
587 .env_remove("FLOWEY_REMOVE_TEST")
588 .ignore_status();
589 let output = cmd.output().unwrap();
590 assert!(!output.status.success());
591 }
592
593 #[test]
594 fn env_clear_survives_wrapping() {
595 let mut sh = FloweyShell::new().unwrap();
596 sh.set_wrapper(Some(env_test_wrapper()));
597
598 sh.set_var("FLOWEY_CLEAR_TEST", "present");
602 let cmd = sh
603 .wrap(print_env_cmd(&sh, "FLOWEY_CLEAR_TEST"))
604 .env_clear()
605 .ignore_status();
606 let output = cmd.output().unwrap();
607 assert!(!output.status.success());
608 }
609
610 #[test]
611 fn env_ordering_preserved_through_wrapping() {
612 let mut sh = FloweyShell::new().unwrap();
613 sh.set_wrapper(Some(env_test_wrapper()));
614
615 let cmd = sh
617 .wrap(print_env_cmd(&sh, "FLOWEY_ORDER_TEST"))
618 .env("FLOWEY_ORDER_TEST", "first")
619 .env_clear()
620 .env("FLOWEY_ORDER_TEST", "second");
621 let output = cmd.read().unwrap();
622 assert_eq!(output, "second");
623 }
624
625 #[test]
626 fn envs_plural_survives_wrapping() {
627 let mut sh = FloweyShell::new().unwrap();
628 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
629
630 let vars = vec![("FLOWEY_MULTI_A", "alpha"), ("FLOWEY_MULTI_B", "beta")];
631 let cmd = sh
633 .wrap(xshell::cmd!(sh.xshell(), "sh"))
634 .arg("-c")
635 .arg("echo $FLOWEY_MULTI_A $FLOWEY_MULTI_B")
636 .envs(vars);
637 let output = cmd.read().unwrap();
638 assert_eq!(output, "alpha beta");
639 }
640}