Skip to main content

reef/
passthrough.rs

1//! Bash command passthrough execution with environment diffing.
2//!
3//! Runs commands in a bash subprocess, captures environment changes, and
4//! prints fish `set` commands to synchronize the fish shell's state.
5
6use std::io::{self, Write};
7use std::path::Path;
8use std::process::{Command, Stdio};
9
10use crate::env_diff::{self, EnvSnapshot};
11use crate::state;
12
13/// Null-delimited sentinel markers for separating env data from command output.
14/// Null bytes prevent collisions with any possible command output.
15const ENV_MARKER: &str = "\0__REEF_ENV__\0";
16const CWD_MARKER: &str = "\0__REEF_CWD__\0";
17
18/// Execute a command through bash with streaming output, then print
19/// environment changes as fish commands to stdout.
20///
21/// Returns the bash command's exit code. The caller (fish) is expected to
22/// pipe stdout through `| source` to apply environment changes.
23///
24/// How it works:
25/// 1. Capture a "before" snapshot of the current environment
26/// 2. Run the command in bash with stderr inherited (streams directly)
27/// 3. Stdout is captured — the command output appears before our markers,
28///    and we print it back to the real stdout immediately
29/// 4. After the markers, we parse the env dump
30/// 5. Diff before/after and print fish `set` commands
31///
32/// # Examples
33///
34/// ```no_run
35/// use reef::passthrough::bash_exec;
36///
37/// // Run a bash command and get its exit code
38/// let exit_code = bash_exec("export MY_VAR=hello && echo done");
39/// assert_eq!(exit_code, 0);
40/// // Fish `set -gx MY_VAR hello` commands are printed to stdout
41/// ```
42#[must_use]
43pub fn bash_exec(command: &str) -> i32 {
44    let before = EnvSnapshot::capture_current();
45
46    // Run the user's command in bash with output to stderr (so user sees it),
47    // then dump env to stdout (for fish to eval).
48    let script = build_script(&shell_escape_for_bash(command), " >&2", true);
49
50    let output = match Command::new("bash")
51        .args(["-c", &script])
52        .stdin(Stdio::inherit())
53        .stdout(Stdio::piped())
54        .stderr(Stdio::inherit())
55        .output()
56    {
57        Ok(o) => o,
58        Err(e) => {
59            eprintln!("reef: failed to run bash: {e}");
60            return 1;
61        }
62    };
63
64    // Preserve signal information: on Unix, a process killed by signal has
65    // no exit code. Fall back to 128 + signal number (shell convention).
66    #[cfg(unix)]
67    let exit_code = {
68        use std::os::unix::process::ExitStatusExt;
69        output.status.code().unwrap_or_else(|| {
70            output.status.signal().map_or(1, |sig| 128 + sig)
71        })
72    };
73    #[cfg(not(unix))]
74    let exit_code = output.status.code().unwrap_or(1);
75    diff_and_print_env(&before, &output.stdout);
76    exit_code
77}
78
79/// Execute a command through bash and only print environment diff as
80/// fish commands. No command output is shown — both stdout and stderr
81/// are suppressed. Used to source bash scripts and capture their
82/// environment side effects.
83///
84/// Returns the bash command's exit code.
85///
86/// # Examples
87///
88/// ```no_run
89/// use reef::passthrough::bash_exec_env_diff;
90///
91/// // Source a bash script, capturing only env changes
92/// let exit_code = bash_exec_env_diff("source ~/.bashrc");
93/// // Fish `set -gx` commands for any new/changed vars are on stdout
94/// ```
95#[must_use]
96pub fn bash_exec_env_diff(command: &str) -> i32 {
97    let before = EnvSnapshot::capture_current();
98
99    // Run the command and capture env afterward — all in one bash invocation.
100    // Suppress command stdout/stderr since we only want the env diff.
101    let script = build_script(&shell_escape_for_bash(command), " >/dev/null 2>&1", false);
102
103    let output = match Command::new("bash").args(["-c", &script]).output() {
104        Ok(o) => o,
105        Err(e) => {
106            eprintln!("reef: failed to run bash: {e}");
107            return 1;
108        }
109    };
110
111    diff_and_print_env(&before, &output.stdout);
112
113    if output.status.success() {
114        0
115    } else {
116        #[cfg(unix)]
117        {
118            use std::os::unix::process::ExitStatusExt;
119            output.status.code().unwrap_or_else(|| {
120                output.status.signal().map_or(1, |sig| 128 + sig)
121            })
122        }
123        #[cfg(not(unix))]
124        {
125            output.status.code().unwrap_or(1)
126        }
127    }
128}
129
130/// Execute a command through bash with state file persistence.
131///
132/// Before running the command, sources the state file to restore previous
133/// exported variables. After running, saves the new environment to the state
134/// file and prints the diff as fish commands.
135///
136/// Returns the bash command's exit code.
137///
138/// # Examples
139///
140/// ```no_run
141/// use std::path::Path;
142/// use reef::passthrough::bash_exec_with_state;
143///
144/// let state = Path::new("/tmp/reef-state-12345");
145/// let exit_code = bash_exec_with_state("export FOO=bar", state);
146/// // FOO=bar is persisted to the state file for next invocation
147/// ```
148#[must_use]
149pub fn bash_exec_with_state(command: &str, state_path: &Path) -> i32 {
150    let before = EnvSnapshot::capture_current();
151
152    let prefix = state::state_prefix(state_path);
153    let escaped = shell_escape_for_bash(command);
154    let body = build_script(&escaped, " >&2", true);
155
156    let mut script = String::with_capacity(prefix.len() + body.len());
157    script.push_str(&prefix);
158    script.push_str(&body);
159
160    let output = match Command::new("bash")
161        .args(["-c", &script])
162        .stdin(Stdio::inherit())
163        .stdout(Stdio::piped())
164        .stderr(Stdio::inherit())
165        .output()
166    {
167        Ok(o) => o,
168        Err(e) => {
169            eprintln!("reef: failed to run bash: {e}");
170            return 1;
171        }
172    };
173
174    #[cfg(unix)]
175    let exit_code = {
176        use std::os::unix::process::ExitStatusExt;
177        output.status.code().unwrap_or_else(|| {
178            output.status.signal().map_or(1, |sig| 128 + sig)
179        })
180    };
181    #[cfg(not(unix))]
182    let exit_code = output.status.code().unwrap_or(1);
183    diff_and_print_env_save_state(&before, &output.stdout, state_path);
184    exit_code
185}
186
187/// Extract env and cwd sections from bash stdout (after sentinel markers).
188fn extract_env_sections(raw_stdout: &[u8]) -> Option<(String, String)> {
189    let stdout = String::from_utf8_lossy(raw_stdout);
190    let env_pos = stdout.find(ENV_MARKER)?;
191    let cwd_pos = stdout.find(CWD_MARKER)?;
192    let env_section = stdout[env_pos + ENV_MARKER.len()..cwd_pos].to_string();
193    let cwd_section = stdout[cwd_pos + CWD_MARKER.len()..].trim().to_string();
194    Some((env_section, cwd_section))
195}
196
197/// Parse env data from bash stdout, diff against the before snapshot,
198/// and print fish `set` commands to stdout.
199fn diff_and_print_env(before: &EnvSnapshot, raw_stdout: &[u8]) {
200    if let Some((env_section, cwd_section)) = extract_env_sections(raw_stdout) {
201        let after = EnvSnapshot::new(
202            env_diff::parse_null_separated_env(&env_section),
203            cwd_section,
204        );
205        let mut buf = String::new();
206        before.diff_into(&after, &mut buf);
207        if !buf.is_empty() {
208            let _ = io::stdout().lock().write_all(buf.as_bytes());
209        }
210    }
211}
212
213/// Like `diff_and_print_env`, but also saves the env snapshot to a state file
214/// so subsequent invocations can restore it.
215fn diff_and_print_env_save_state(before: &EnvSnapshot, raw_stdout: &[u8], state_path: &Path) {
216    if let Some((env_section, cwd_section)) = extract_env_sections(raw_stdout) {
217        let _ = state::save_state(state_path, &env_section);
218        let after = EnvSnapshot::new(
219            env_diff::parse_null_separated_env(&env_section),
220            cwd_section,
221        );
222        let mut buf = String::new();
223        before.diff_into(&after, &mut buf);
224        if !buf.is_empty() {
225            let _ = io::stdout().lock().write_all(buf.as_bytes());
226        }
227    }
228}
229
230/// Build a bash script that evals the command with the given redirect suffix,
231/// then dumps env markers + env -0 + cwd for the diff.
232fn build_script(escaped_cmd: &str, redirect: &str, track_exit: bool) -> String {
233    let mut s = String::with_capacity(escaped_cmd.len() + 100);
234    s.push_str("eval ");
235    s.push_str(escaped_cmd);
236    s.push_str(redirect);
237    s.push('\n');
238    if track_exit {
239        s.push_str("__reef_exit=$?\n");
240    }
241    // Use printf with null bytes for sentinels — prevents collisions with any output
242    s.push_str("printf '\\0__REEF_ENV__\\0'\nenv -0\nprintf '\\0__REEF_CWD__\\0'\npwd");
243    if track_exit {
244        s.push_str("\nexit $__reef_exit");
245    }
246    s
247}
248
249/// Escape a command string for embedding in a bash `eval` statement.
250/// We single-quote the entire thing to prevent any interpretation.
251fn shell_escape_for_bash(s: &str) -> String {
252    let mut result = String::with_capacity(s.len() + 2);
253    result.push('\'');
254    for &b in s.as_bytes() {
255        if b == b'\'' {
256            result.push_str("'\\''");
257        } else {
258            result.push(b as char);
259        }
260    }
261    result.push('\'');
262    result
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn shell_escape_simple() {
271        assert_eq!(shell_escape_for_bash("echo hello"), "'echo hello'");
272    }
273
274    #[test]
275    fn shell_escape_with_quotes() {
276        assert_eq!(
277            shell_escape_for_bash("echo 'it'\"s\""),
278            "'echo '\\''it'\\''\"s\"'"
279        );
280    }
281
282    #[test]
283    fn bash_exec_sets_var() {
284        // Run a command that exports a unique variable
285        let code = bash_exec("export __REEF_TEST_VAR_xyzzy=hello_reef");
286        // The command should succeed
287        assert_eq!(code, 0);
288    }
289
290    #[test]
291    fn bash_exec_env_diff_captures_var() {
292        // This test verifies that bash_exec_env_diff runs without error
293        let code = bash_exec_env_diff("export __REEF_TEST_ED_VAR=test_val");
294        assert_eq!(code, 0);
295    }
296
297    #[test]
298    fn bash_exec_preserves_exit_code() {
299        let code = bash_exec("exit 42");
300        assert_eq!(code, 42);
301    }
302
303    #[test]
304    fn bash_exec_exit_code_zero() {
305        let code = bash_exec("true");
306        assert_eq!(code, 0);
307    }
308
309    #[test]
310    fn sentinel_uses_null_bytes() {
311        // Verify sentinels contain null bytes to prevent collision
312        assert!(ENV_MARKER.contains('\0'));
313        assert!(CWD_MARKER.contains('\0'));
314    }
315}