Skip to main content

rusty_vipe/
pipeline.rs

1//! Core vipe pipeline: drain → spawn → write-back.
2//!
3//! Three stateless functions composed by `lib.rs::run()`:
4//! 1. [`drain_to_tempfile`] — read all of `reader` into a fresh
5//!    `NamedTempFile` with the configured suffix.
6//! 2. [`spawn_editor`] — build a `Command` with the resolved editor argv +
7//!    user-supplied extras + tempfile path; reattach stdin/stdout to the
8//!    controlling TTY (or fall back per `RUSTY_VIPE_TEST_BYPASS_TTY`);
9//!    wait for the editor; return exit status.
10//! 3. [`write_back_to_saved_stdout`] — read the tempfile's current bytes
11//!    (opaquely — no encoding) and write them to the preserved original
12//!    stdout sink. Distinguishes `NotFound` from other IO errors per
13//!    FR-007 (`Error::TempFileDeleted`).
14
15use std::ffi::OsString;
16use std::io::Read;
17use std::path::Path;
18use std::process::{Command, ExitStatus, Stdio};
19
20use tempfile::NamedTempFile;
21
22use crate::Error;
23use crate::tty::{PreservedStdout, TtyHandles};
24
25/// Env var that disables TTY reattachment for the editor child. Test-only:
26/// set in CI/integration-test runs where the test process has no controlling
27/// terminal. NEVER enable in production — the editor's stdio falls back to
28/// `Stdio::null()` for stdin/stdout (the test's fake editor doesn't need real
29/// terminal access) and inherits stderr so spawn failures stay visible.
30/// Documented in `docs/DESIGN.md`.
31const TEST_BYPASS_TTY_ENV: &str = "RUSTY_VIPE_TEST_BYPASS_TTY";
32
33/// Drain `reader` into a fresh `NamedTempFile` with the given `suffix`.
34/// Empty reader still produces a zero-byte tempfile (per FR-008 / HINT-003).
35pub fn drain_to_tempfile<R: Read>(mut reader: R, suffix: &str) -> Result<NamedTempFile, Error> {
36    let mut tempfile = tempfile::Builder::new()
37        .prefix(".rusty-vipe-")
38        .suffix(suffix)
39        .tempfile()?;
40    // Use std::io::copy to stream bytes opaquely. No transformation per FR-017.
41    std::io::copy(&mut reader, tempfile.as_file_mut())?;
42    tempfile.as_file_mut().sync_data().ok();
43    Ok(tempfile)
44}
45
46/// Spawn the editor with the resolved argv + extras + tempfile path appended.
47///
48/// `tty` is the result of `tty::open_controlling_tty()` — if `None`, the
49/// caller has chosen the test bypass path (RUSTY_VIPE_TEST_BYPASS_TTY=1)
50/// and we'll feed the editor null stdin/stdout.
51///
52/// On spawn `ErrorKind::NotFound`, returns `Error::EditorNotFound(argv[0])`
53/// per FR-016.
54pub fn spawn_editor(
55    argv: &[OsString],
56    extras: &[OsString],
57    tempfile_path: &Path,
58    tty: Option<TtyHandles>,
59) -> Result<ExitStatus, Error> {
60    if argv.is_empty() {
61        return Err(Error::EditorNotFound(String::from("(empty argv)")));
62    }
63    let program = &argv[0];
64    let mut command = Command::new(program);
65    command.args(&argv[1..]);
66    command.args(extras);
67    command.arg(tempfile_path);
68
69    // Stdio wiring: TTY reattachment in production; null+inherit in test bypass.
70    match tty {
71        Some(handles) => {
72            command.stdin(Stdio::from(handles.tty_in));
73            // tty_out wraps a write-side handle; clone for stdout and stderr.
74            let stdout_handle = handles.tty_out.try_clone()?;
75            command.stdout(Stdio::from(stdout_handle));
76            command.stderr(Stdio::from(handles.tty_out));
77        }
78        None => {
79            command.stdin(Stdio::null());
80            command.stdout(Stdio::null());
81            command.stderr(Stdio::inherit());
82        }
83    }
84
85    let status = match command.status() {
86        Ok(s) => s,
87        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
88            let program_str = program.to_string_lossy().to_string();
89            return Err(Error::EditorNotFound(program_str));
90        }
91        Err(e) => return Err(Error::Io(e)),
92    };
93
94    Ok(status)
95}
96
97/// Read the tempfile bytes and write them to the preserved stdout sink.
98/// Distinguishes `io::ErrorKind::NotFound` (user deleted the tempfile from
99/// inside the editor) from other read errors per FR-007.
100pub fn write_back_to_saved_stdout(
101    tempfile_path: &Path,
102    mut saved_stdout: PreservedStdout,
103) -> Result<(), Error> {
104    let bytes = match std::fs::read(tempfile_path) {
105        Ok(b) => b,
106        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
107            return Err(Error::TempFileDeleted(tempfile_path.to_path_buf()));
108        }
109        Err(e) => return Err(Error::Io(e)),
110    };
111    use std::io::Write;
112    saved_stdout.as_writer().write_all(&bytes)?;
113    saved_stdout.as_writer().flush().ok();
114    Ok(())
115}
116
117/// Returns true iff the `RUSTY_VIPE_TEST_BYPASS_TTY` env var is set to a
118/// truthy value. Test-only escape hatch; documented in DESIGN.md.
119pub fn test_bypass_tty_enabled() -> bool {
120    match std::env::var_os(TEST_BYPASS_TTY_ENV) {
121        Some(v) => {
122            let Some(s) = v.to_str() else { return false };
123            matches!(
124                s.trim().to_ascii_lowercase().as_str(),
125                "1" | "true" | "yes" | "on"
126            )
127        }
128        None => false,
129    }
130}
131
132/// Compute the exit code to return per FR-006's clamping rule.
133/// Unix: pass through verbatim in the range 1–255; 0 only when editor exited 0.
134/// Windows: pass through 1–254 verbatim; clamp >254 or negative to 1.
135pub fn clamp_exit_code(status: ExitStatus) -> i32 {
136    if status.success() {
137        return 0;
138    }
139    let raw = status.code().unwrap_or(1); // signal-terminated → 1
140    #[cfg(unix)]
141    {
142        if (1..=255).contains(&raw) { raw } else { 1 }
143    }
144    #[cfg(windows)]
145    {
146        if (1..=254).contains(&raw) { raw } else { 1 }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::io::Cursor;
154
155    #[test]
156    fn drain_empty_input_produces_zero_byte_tempfile() {
157        let tempfile = drain_to_tempfile(Cursor::new(&[][..]), ".txt").unwrap();
158        let meta = std::fs::metadata(tempfile.path()).unwrap();
159        assert_eq!(meta.len(), 0, "FR-008: empty stdin → zero-byte tempfile");
160    }
161
162    #[test]
163    fn drain_small_input_roundtrips() {
164        let tempfile = drain_to_tempfile(Cursor::new(b"hello\nworld\n"), ".txt").unwrap();
165        let bytes = std::fs::read(tempfile.path()).unwrap();
166        assert_eq!(bytes, b"hello\nworld\n");
167    }
168
169    #[test]
170    fn drain_uses_configured_suffix() {
171        let tempfile = drain_to_tempfile(Cursor::new(b"x"), ".json").unwrap();
172        let name = tempfile.path().file_name().unwrap().to_string_lossy();
173        assert!(
174            name.ends_with(".json"),
175            "FR-012: --suffix=.json should produce *.json tempfile, got {name}"
176        );
177    }
178
179    #[test]
180    fn drain_empty_suffix_means_no_extension() {
181        let tempfile = drain_to_tempfile(Cursor::new(b"x"), "").unwrap();
182        let name = tempfile.path().file_name().unwrap().to_string_lossy();
183        // Empty suffix → name is just the prefix + random part, no '.' at end.
184        assert!(
185            !name.ends_with(".txt") && !name.ends_with('.'),
186            "FR-012 + Clarification Q2: empty --suffix= means no extension, got {name}"
187        );
188    }
189
190    #[test]
191    fn drain_binary_passthrough_unchanged() {
192        let bytes: &[u8] = &[0x00, 0xfe, 0xff, 0xc3, 0x28, 0xa0, 0xa1];
193        let tempfile = drain_to_tempfile(Cursor::new(bytes), ".bin").unwrap();
194        let read_back = std::fs::read(tempfile.path()).unwrap();
195        assert_eq!(read_back, bytes, "FR-017: bytes opaque, no transformation");
196    }
197
198    #[test]
199    fn spawn_editor_returns_editor_not_found_on_missing_binary() {
200        let argv = vec![OsString::from(
201            "a-binary-that-definitely-does-not-exist-12345",
202        )];
203        let extras: Vec<OsString> = Vec::new();
204        let result = spawn_editor(&argv, &extras, Path::new("/tmp/nope"), None);
205        match result {
206            Err(Error::EditorNotFound(name)) => {
207                assert!(name.contains("does-not-exist"), "should carry the argv[0]");
208            }
209            other => panic!("expected EditorNotFound, got {other:?}"),
210        }
211    }
212
213    #[test]
214    fn spawn_editor_empty_argv_returns_editor_not_found() {
215        let argv: Vec<OsString> = Vec::new();
216        let extras: Vec<OsString> = Vec::new();
217        let result = spawn_editor(&argv, &extras, Path::new("/tmp/nope"), None);
218        assert!(matches!(result, Err(Error::EditorNotFound(_))));
219    }
220
221    #[test]
222    fn write_back_returns_tempfile_deleted_when_file_missing() {
223        let tmpdir = tempfile::tempdir().unwrap();
224        let missing = tmpdir.path().join("does-not-exist.txt");
225
226        let preserved = crate::tty::preserve_stdout().expect("preserve_stdout should succeed");
227        let result = write_back_to_saved_stdout(&missing, preserved);
228        assert!(matches!(result, Err(Error::TempFileDeleted(_))));
229    }
230
231    #[test]
232    fn test_bypass_tty_recognizes_truthy_values() {
233        for v in ["1", "true", "yes", "on", "TRUE", " 1 "] {
234            // SAFETY: tests run sequentially via Cargo's default; setting env vars is fine.
235            unsafe {
236                std::env::set_var(TEST_BYPASS_TTY_ENV, v);
237            }
238            assert!(test_bypass_tty_enabled(), "{v:?} should be truthy");
239        }
240        unsafe {
241            std::env::remove_var(TEST_BYPASS_TTY_ENV);
242        }
243        assert!(!test_bypass_tty_enabled());
244    }
245}