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 #[allow(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 Prefix,
308}
309
310impl CommandWrapperKind {
311 fn wrap_cmd<'a>(self, sh: &'a xshell::Shell, cmd: xshell::Cmd<'a>) -> xshell::Cmd<'a> {
317 let cmd_str = format!("{cmd}");
318 match self {
319 CommandWrapperKind::NixShell { path } => {
320 let mut wrapped = sh.cmd("nix-shell");
321 if let Some(path) = path {
322 wrapped = wrapped.arg(path);
323 }
324 wrapped.arg("--pure").arg("--run").arg(cmd_str)
325 }
326 #[cfg(test)]
327 CommandWrapperKind::ShCmd => sh.cmd("sh").arg("-c").arg(cmd_str),
328 #[cfg(test)]
329 CommandWrapperKind::Prefix => sh.cmd("echo").arg(format!("WRAPPED: {cmd_str}")),
330 }
331 }
332}
333
334#[cfg(test)]
335#[allow(
336 clippy::disallowed_macros,
337 clippy::disallowed_methods,
338 reason = "test module"
339)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn no_wrapper_runs_command_directly() {
345 let sh = FloweyShell::new().unwrap();
346 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo hello"));
347 let output = cmd.read().unwrap();
348 assert_eq!(output, "hello");
349 }
350
351 #[test]
352 fn wrapper_transforms_command() {
353 let mut sh = FloweyShell::new().unwrap();
354 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
355 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag"));
356 let output = cmd.read().unwrap();
357 assert_eq!(output, "WRAPPED: my-program --flag");
358 }
359
360 #[test]
361 fn env_vars_survive_with_wrapper() {
362 let mut sh = FloweyShell::new().unwrap();
363 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
364
365 let cmd = sh
366 .wrap(xshell::cmd!(sh.xshell(), "printenv MY_FLOWEY_WRAP_TEST"))
367 .env("MY_FLOWEY_WRAP_TEST", "survived_wrapping");
368 let output = cmd.read().unwrap();
369 assert_eq!(output, "survived_wrapping");
370 }
371
372 #[test]
373 fn stdin_survives_wrapping() {
374 let sh = FloweyShell::new().unwrap();
375 let cmd = sh
376 .wrap(xshell::cmd!(sh.xshell(), "cat"))
377 .stdin("test input");
378 let output = cmd.read().unwrap();
379 assert_eq!(output, "test input");
380 }
381
382 #[test]
383 fn stdin_survives_with_wrapper() {
384 let mut sh = FloweyShell::new().unwrap();
385 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
386
387 let cmd = sh
388 .wrap(xshell::cmd!(sh.xshell(), "cat"))
389 .stdin("wrapped stdin test");
390 let output = cmd.read().unwrap();
391 assert_eq!(output, "wrapped stdin test");
392 }
393
394 #[test]
395 fn ignore_status_survives_wrapping() {
396 let mut sh = FloweyShell::new().unwrap();
397 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
398
399 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "false")).ignore_status();
401 assert!(cmd.run().is_ok());
402 }
403
404 #[test]
405 fn display_shows_unwrapped_command() {
406 let mut sh = FloweyShell::new().unwrap();
407 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
408 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag value"));
409 assert_eq!(format!("{cmd}"), "my-program --flag value");
410 }
411
412 #[test]
413 fn nix_wrapper_display_without_path() {
414 let sh = FloweyShell::new().unwrap();
415 let cmd = CommandWrapperKind::NixShell { path: None }.wrap_cmd(
416 sh.xshell(),
417 xshell::cmd!(sh.xshell(), "cargo build --release"),
418 );
419 assert_eq!(
420 format!("{cmd}"),
421 "nix-shell --pure --run \"cargo build --release\""
422 );
423 }
424
425 #[test]
426 fn nix_wrapper_display_with_path() {
427 let sh = FloweyShell::new().unwrap();
428 let cmd = CommandWrapperKind::NixShell {
429 path: Some("/my/shell.nix".into()),
430 }
431 .wrap_cmd(sh.xshell(), xshell::cmd!(sh.xshell(), "cargo build"));
432 assert_eq!(
433 format!("{cmd}"),
434 "nix-shell /my/shell.nix --pure --run \"cargo build\""
435 );
436 }
437
438 #[test]
439 fn deref_exposes_shell_methods() {
440 let sh = FloweyShell::new().unwrap();
441 let _ = sh.current_dir();
442 }
443
444 #[test]
445 fn set_wrapper_clears_wrapper() {
446 let mut sh = FloweyShell::new().unwrap();
447 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
448 sh.set_wrapper(None);
449 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo direct"));
451 let output = cmd.read().unwrap();
452 assert_eq!(output, "direct");
453 }
454
455 #[test]
456 fn quiet_flag_survives_wrapping() {
457 let mut sh = FloweyShell::new().unwrap();
458 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
459 let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo test")).quiet();
461 let output = cmd.read().unwrap();
462 assert_eq!(output, "WRAPPED: echo test");
463 }
464
465 #[test]
466 fn args_accumulate_before_wrapping() {
467 let mut sh = FloweyShell::new().unwrap();
468 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
469 let cmd = sh
470 .wrap(xshell::cmd!(sh.xshell(), "echo"))
471 .arg("one")
472 .arg("two");
473 let output = cmd.read().unwrap();
474 assert_eq!(output, "WRAPPED: echo one two");
475 }
476
477 #[test]
478 fn secret_display_is_redacted() {
479 let sh = FloweyShell::new().unwrap();
480 let cmd = sh
481 .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
482 .secret();
483 assert_eq!(format!("{cmd}"), "<secret>");
484 }
485
486 #[test]
487 fn secret_display_redacted_with_wrapper() {
488 let mut sh = FloweyShell::new().unwrap();
489 sh.set_wrapper(Some(CommandWrapperKind::Prefix));
490 let cmd = sh
491 .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
492 .secret();
493 assert_eq!(format!("{cmd}"), "<secret>");
494 }
495
496 #[test]
497 fn env_remove_survives_wrapping() {
498 let mut sh = FloweyShell::new().unwrap();
499 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
500
501 sh.set_var("FLOWEY_REMOVE_TEST", "present");
504 let cmd = sh
505 .wrap(xshell::cmd!(sh.xshell(), "printenv FLOWEY_REMOVE_TEST"))
506 .env_remove("FLOWEY_REMOVE_TEST")
507 .ignore_status();
508 let output = cmd.output().unwrap();
509 assert!(!output.status.success());
510 }
511
512 #[test]
513 fn env_clear_survives_wrapping() {
514 let mut sh = FloweyShell::new().unwrap();
515 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
516
517 sh.set_var("FLOWEY_CLEAR_TEST", "present");
521 let cmd = sh
522 .wrap(xshell::cmd!(sh.xshell(), "printenv FLOWEY_CLEAR_TEST"))
523 .env_clear()
524 .ignore_status();
525 let output = cmd.output().unwrap();
526 assert!(!output.status.success());
527 }
528
529 #[test]
530 fn env_ordering_preserved_through_wrapping() {
531 let mut sh = FloweyShell::new().unwrap();
532 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
533
534 let cmd = sh
536 .wrap(xshell::cmd!(sh.xshell(), "printenv FLOWEY_ORDER_TEST"))
537 .env("FLOWEY_ORDER_TEST", "first")
538 .env_clear()
539 .env("FLOWEY_ORDER_TEST", "second");
540 let output = cmd.read().unwrap();
541 assert_eq!(output, "second");
542 }
543
544 #[test]
545 fn envs_plural_survives_wrapping() {
546 let mut sh = FloweyShell::new().unwrap();
547 sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
548
549 let vars = vec![("FLOWEY_MULTI_A", "alpha"), ("FLOWEY_MULTI_B", "beta")];
550 let cmd = sh
552 .wrap(xshell::cmd!(sh.xshell(), "sh"))
553 .arg("-c")
554 .arg("echo $FLOWEY_MULTI_A $FLOWEY_MULTI_B")
555 .envs(vars);
556 let output = cmd.read().unwrap();
557 assert_eq!(output, "alpha beta");
558 }
559}