1use std::ffi::OsStr;
11use std::ffi::OsString;
12use std::ops::Deref;
13use std::process::Output;
14
15use serde::Deserialize;
16use serde::Serialize;
17
18pub struct FloweyShell {
24 inner: xshell::Shell,
25 wrapper: Option<CommandWrapperKind>,
26}
27
28impl FloweyShell {
29 #[expect(clippy::disallowed_methods)]
31 pub fn new() -> anyhow::Result<Self> {
32 Ok(Self {
33 inner: xshell::Shell::new()?,
34 wrapper: None,
35 })
36 }
37
38 pub fn set_wrapper(&mut self, wrapper: Option<CommandWrapperKind>) {
41 self.wrapper = wrapper;
42 }
43
44 pub fn xshell(&self) -> &xshell::Shell {
49 &self.inner
50 }
51
52 pub fn wrap<'a>(&'a self, cmd: xshell::Cmd<'a>) -> FloweyCmd<'a> {
55 FloweyCmd {
56 inner: cmd,
57 env_changes: Vec::new(),
58 stdin_contents: None,
59 ignore_status: false,
60 quiet: false,
61 secret: false,
62 ignore_stdout: false,
63 ignore_stderr: false,
64 wrapper: self.wrapper.clone(),
65 sh: &self.inner,
66 }
67 }
68}
69
70impl Deref for FloweyShell {
71 type Target = xshell::Shell;
72
73 fn deref(&self) -> &xshell::Shell {
74 &self.inner
75 }
76}
77
78enum EnvChange {
80 Set(OsString, OsString),
81 Remove(OsString),
82 Clear,
83}
84
85pub struct FloweyCmd<'a> {
94 inner: xshell::Cmd<'a>,
96 env_changes: Vec<EnvChange>,
98 stdin_contents: Option<Vec<u8>>,
99 ignore_status: bool,
100 quiet: bool,
101 secret: bool,
102 ignore_stdout: bool,
103 ignore_stderr: bool,
104 wrapper: Option<CommandWrapperKind>,
105 sh: &'a xshell::Shell,
106}
107
108impl<'a> FloweyCmd<'a> {
110 pub fn arg<P: AsRef<OsStr>>(mut self, arg: P) -> Self {
112 self.inner = self.inner.arg(arg);
113 self
114 }
115
116 pub fn args<I>(mut self, args: I) -> Self
118 where
119 I: IntoIterator,
120 I::Item: AsRef<OsStr>,
121 {
122 self.inner = self.inner.args(args);
123 self
124 }
125
126 pub fn env<K: AsRef<OsStr>, V: AsRef<OsStr>>(mut self, key: K, val: V) -> Self {
128 self.env_changes.push(EnvChange::Set(
129 key.as_ref().to_owned(),
130 val.as_ref().to_owned(),
131 ));
132 self
133 }
134
135 pub fn envs<I, K, V>(mut self, vars: I) -> Self
138 where
139 I: IntoIterator<Item = (K, V)>,
140 K: AsRef<OsStr>,
141 V: AsRef<OsStr>,
142 {
143 for (k, v) in vars {
144 self.env_changes
145 .push(EnvChange::Set(k.as_ref().to_owned(), v.as_ref().to_owned()));
146 }
147 self
148 }
149
150 pub fn env_remove<K: AsRef<OsStr>>(mut self, key: K) -> Self {
152 self.env_changes
153 .push(EnvChange::Remove(key.as_ref().to_owned()));
154 self
155 }
156
157 pub fn env_clear(mut self) -> Self {
159 self.env_changes.push(EnvChange::Clear);
160 self
161 }
162
163 pub fn ignore_status(mut self) -> Self {
166 self.ignore_status = true;
167 self
168 }
169
170 pub fn set_ignore_status(&mut self, yes: bool) {
172 self.ignore_status = yes;
173 }
174
175 pub fn quiet(mut self) -> Self {
177 self.quiet = true;
178 self
179 }
180
181 pub fn set_quiet(&mut self, yes: bool) {
183 self.quiet = yes;
184 }
185
186 pub fn secret(mut self) -> Self {
189 self.secret = true;
190 self
191 }
192
193 pub fn set_secret(&mut self, yes: bool) {
195 self.secret = yes;
196 }
197
198 pub fn stdin(mut self, stdin: impl AsRef<[u8]>) -> Self {
200 self.stdin_contents = Some(stdin.as_ref().to_vec());
201 self
202 }
203
204 pub fn ignore_stdout(mut self) -> Self {
206 self.ignore_stdout = true;
207 self
208 }
209
210 pub fn set_ignore_stdout(&mut self, yes: bool) {
212 self.ignore_stdout = yes;
213 }
214
215 pub fn ignore_stderr(mut self) -> Self {
217 self.ignore_stderr = true;
218 self
219 }
220
221 pub fn set_ignore_stderr(&mut self, yes: bool) {
223 self.ignore_stderr = yes;
224 }
225
226 fn into_resolved(self) -> xshell::Cmd<'a> {
230 let mut cmd = match self.wrapper {
231 Some(wrapper) => wrapper.wrap_cmd(self.sh, self.inner),
232 None => self.inner,
233 };
234
235 for change in self.env_changes {
237 match change {
238 EnvChange::Set(k, v) => cmd = cmd.env(k, v),
239 EnvChange::Remove(k) => cmd = cmd.env_remove(k),
240 EnvChange::Clear => cmd = cmd.env_clear(),
241 }
242 }
243 if let Some(stdin) = self.stdin_contents {
244 cmd = cmd.stdin(stdin);
245 }
246 cmd.set_ignore_status(self.ignore_status);
247 cmd.set_quiet(self.quiet);
248 cmd.set_secret(self.secret);
249 cmd.set_ignore_stdout(self.ignore_stdout);
250 cmd.set_ignore_stderr(self.ignore_stderr);
251
252 cmd
253 }
254
255 pub fn run(self) -> xshell::Result<()> {
257 self.into_resolved().run()
258 }
259
260 pub fn read(self) -> xshell::Result<String> {
263 self.into_resolved().read()
264 }
265
266 pub fn read_stderr(self) -> xshell::Result<String> {
269 self.into_resolved().read_stderr()
270 }
271
272 pub fn output(self) -> xshell::Result<Output> {
274 self.into_resolved().output()
275 }
276}
277
278impl std::fmt::Display for FloweyCmd<'_> {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 if self.secret {
281 return f.write_str("<secret>");
282 }
283 std::fmt::Display::fmt(&self.inner, f)
285 }
286}
287
288#[derive(Clone, Debug, Serialize, Deserialize)]
294pub enum CommandWrapperKind {
295 NixShell {
297 path: Option<std::path::PathBuf>,
301 },
302 #[cfg(test)]
304 ShCmd,
305 #[cfg(test)]
307 CmdExe,
308 #[cfg(test)]
310 Prefix,
311}
312
313impl CommandWrapperKind {
314 fn wrap_cmd<'a>(self, sh: &'a xshell::Shell, cmd: xshell::Cmd<'a>) -> xshell::Cmd<'a> {
320 let cmd_str = format!("{cmd}");
321 match self {
322 CommandWrapperKind::NixShell { path } => {
323 let mut wrapped = sh.cmd("nix-shell");
324 if let Some(path) = path {
325 wrapped = wrapped.arg(path);
326 }
327 wrapped.arg("--pure").arg("--run").arg(cmd_str)
328 }
329 #[cfg(test)]
330 CommandWrapperKind::ShCmd => sh.cmd("sh").arg("-c").arg(cmd_str),
331 #[cfg(test)]
332 CommandWrapperKind::CmdExe => {
333 let cmd_body = cmd_str
336 .strip_prefix("cmd /C ")
337 .unwrap_or(&cmd_str)
338 .trim_matches('"');
339 sh.cmd("cmd").arg("/C").arg(cmd_body)
340 }
341 #[cfg(test)]
342 CommandWrapperKind::Prefix => sh.cmd("echo").arg(format!("WRAPPED: {cmd_str}")),
343 }
344 }
345}
346
347#[cfg(test)]
348#[expect(clippy::disallowed_macros, reason = "test module")]
349mod tests {
350 use super::*;
351
352 fn env_test_wrapper() -> CommandWrapperKind {
353 if cfg!(windows) {
354 CommandWrapperKind::CmdExe
355 } else {
356 CommandWrapperKind::ShCmd
357 }
358 }
359
360 fn print_env_cmd<'a>(sh: &'a FloweyShell, var: &str) -> xshell::Cmd<'a> {
361 if cfg!(windows) {
362 sh.xshell()
363 .cmd("cmd")
364 .arg("/C")
365 .arg(format!("if defined {var} (echo %{var}%) else exit /b 1"))
366 } else {
367 sh.xshell().cmd("printenv").arg(var)
368 }
369 }
370
371 fn fail_cmd<'a>(sh: &'a FloweyShell) -> xshell::Cmd<'a> {
372 if cfg!(windows) {
373 sh.xshell().cmd("cmd").arg("/C").arg("exit /b 1")
374 } else {
375 sh.xshell().cmd("false")
376 }
377 }
378
379 #[test]
380 fn no_wrapper_runs_command_directly() {
381 let sh = FloweyShell::new().unwrap();
382 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo hello"));
383 let output = cmd.read().unwrap();
384 assert_eq!(output, "hello");
385 }
386
387 #[test]
388 fn wrapper_transforms_command() {
389 let mut sh = FloweyShell::new().unwrap();
390 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
391 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag"));
392 let output = cmd.read().unwrap();
393 assert_eq!(output, "WRAPPED: my-program --flag");
394 }
395
396 #[test]
397 fn env_vars_survive_with_wrapper() {
398 let mut sh = FloweyShell::new().unwrap();
399 sh.set_wrapper(Some(env_test_wrapper()));
400
401 let cmd = sh
402 .wrap(print_env_cmd(&sh, "MY_FLOWEY_WRAP_TEST"))
403 .env("MY_FLOWEY_WRAP_TEST", "survived_wrapping");
404 let output = cmd.read().unwrap();
405 assert_eq!(output, "survived_wrapping");
406 }
407
408 #[test]
409 fn stdin_survives_wrapping() {
410 let sh = FloweyShell::new().unwrap();
411 let cmd = sh
412 .wrap(xshell::cmd!(sh.xshell(), "cat"))
413 .stdin("test input");
414 let output = cmd.read().unwrap();
415 assert_eq!(output, "test input");
416 }
417
418 #[test]
419 fn stdin_survives_with_wrapper() {
420 let mut sh = FloweyShell::new().unwrap();
421 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
422
423 let cmd = sh
424 .wrap(xshell::cmd!(sh.xshell(), "cat"))
425 .stdin("wrapped stdin test");
426 let output = cmd.read().unwrap();
427 assert_eq!(output, "wrapped stdin test");
428 }
429
430 #[test]
431 fn ignore_status_survives_wrapping() {
432 let mut sh = FloweyShell::new().unwrap();
433 sh.set_wrapper(Some(env_test_wrapper()));
434
435 let cmd = sh.wrap(fail_cmd(&sh)).ignore_status();
437 assert!(cmd.run().is_ok());
438 }
439
440 #[test]
441 fn display_shows_unwrapped_command() {
442 let mut sh = FloweyShell::new().unwrap();
443 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
444 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag value"));
445 assert_eq!(format!("{cmd}"), "my-program --flag value");
446 }
447
448 #[test]
449 fn nix_wrapper_display_without_path() {
450 let sh = FloweyShell::new().unwrap();
451 let cmd = CommandWrapperKind::NixShell { path: None }.wrap_cmd(
452 sh.xshell(),
453 xshell::cmd!(sh.xshell(), "cargo build --release"),
454 );
455 assert_eq!(
456 format!("{cmd}"),
457 "nix-shell --pure --run \"cargo build --release\""
458 );
459 }
460
461 #[test]
462 fn nix_wrapper_display_with_path() {
463 let sh = FloweyShell::new().unwrap();
464 let cmd = CommandWrapperKind::NixShell {
465 path: Some("/my/shell.nix".into()),
466 }
467 .wrap_cmd(sh.xshell(), xshell::cmd!(sh.xshell(), "cargo build"));
468 assert_eq!(
469 format!("{cmd}"),
470 "nix-shell /my/shell.nix --pure --run \"cargo build\""
471 );
472 }
473
474 #[test]
475 fn deref_exposes_shell_methods() {
476 let sh = FloweyShell::new().unwrap();
477 let _ = sh.current_dir();
478 }
479
480 #[test]
481 fn set_wrapper_clears_wrapper() {
482 let mut sh = FloweyShell::new().unwrap();
483 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
484 sh.set_wrapper(None);
485 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo direct"));
487 let output = cmd.read().unwrap();
488 assert_eq!(output, "direct");
489 }
490
491 #[test]
492 fn quiet_flag_survives_wrapping() {
493 let mut sh = FloweyShell::new().unwrap();
494 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
495 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo test")).quiet();
497 let output = cmd.read().unwrap();
498 assert_eq!(output, "WRAPPED: echo test");
499 }
500
501 #[test]
502 fn args_accumulate_before_wrapping() {
503 let mut sh = FloweyShell::new().unwrap();
504 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
505 let cmd = sh
506 .wrap(xshell::cmd!(sh.xshell(), "echo"))
507 .arg("one")
508 .arg("two");
509 let output = cmd.read().unwrap();
510 assert_eq!(output, "WRAPPED: echo one two");
511 }
512
513 #[test]
514 fn secret_display_is_redacted() {
515 let sh = FloweyShell::new().unwrap();
516 let cmd = sh
517 .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
518 .secret();
519 assert_eq!(format!("{cmd}"), "<secret>");
520 }
521
522 #[test]
523 fn secret_display_redacted_with_wrapper() {
524 let mut sh = FloweyShell::new().unwrap();
525 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
526 let cmd = sh
527 .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
528 .secret();
529 assert_eq!(format!("{cmd}"), "<secret>");
530 }
531
532 #[test]
533 fn env_remove_survives_wrapping() {
534 let mut sh = FloweyShell::new().unwrap();
535 sh.set_wrapper(Some(env_test_wrapper()));
536
537 sh.set_var("FLOWEY_REMOVE_TEST", "present");
540 let cmd = sh
541 .wrap(print_env_cmd(&sh, "FLOWEY_REMOVE_TEST"))
542 .env_remove("FLOWEY_REMOVE_TEST")
543 .ignore_status();
544 let output = cmd.output().unwrap();
545 assert!(!output.status.success());
546 }
547
548 #[test]
549 fn env_clear_survives_wrapping() {
550 let mut sh = FloweyShell::new().unwrap();
551 sh.set_wrapper(Some(env_test_wrapper()));
552
553 sh.set_var("FLOWEY_CLEAR_TEST", "present");
557 let cmd = sh
558 .wrap(print_env_cmd(&sh, "FLOWEY_CLEAR_TEST"))
559 .env_clear()
560 .ignore_status();
561 let output = cmd.output().unwrap();
562 assert!(!output.status.success());
563 }
564
565 #[test]
566 fn env_ordering_preserved_through_wrapping() {
567 let mut sh = FloweyShell::new().unwrap();
568 sh.set_wrapper(Some(env_test_wrapper()));
569
570 let cmd = sh
572 .wrap(print_env_cmd(&sh, "FLOWEY_ORDER_TEST"))
573 .env("FLOWEY_ORDER_TEST", "first")
574 .env_clear()
575 .env("FLOWEY_ORDER_TEST", "second");
576 let output = cmd.read().unwrap();
577 assert_eq!(output, "second");
578 }
579
580 #[test]
581 fn envs_plural_survives_wrapping() {
582 let mut sh = FloweyShell::new().unwrap();
583 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
584
585 let vars = vec![("FLOWEY_MULTI_A", "alpha"), ("FLOWEY_MULTI_B", "beta")];
586 let cmd = sh
588 .wrap(xshell::cmd!(sh.xshell(), "sh"))
589 .arg("-c")
590 .arg("echo $FLOWEY_MULTI_A $FLOWEY_MULTI_B")
591 .envs(vars);
592 let output = cmd.read().unwrap();
593 assert_eq!(output, "alpha beta");
594 }
595}