1use 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
25const TEST_BYPASS_TTY_ENV: &str = "RUSTY_VIPE_TEST_BYPASS_TTY";
32
33pub 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 std::io::copy(&mut reader, tempfile.as_file_mut())?;
42 tempfile.as_file_mut().sync_data().ok();
43 Ok(tempfile)
44}
45
46pub 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 match tty {
71 Some(handles) => {
72 command.stdin(Stdio::from(handles.tty_in));
73 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
97pub 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
117pub 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
132pub fn clamp_exit_code(status: ExitStatus) -> i32 {
136 if status.success() {
137 return 0;
138 }
139 let raw = status.code().unwrap_or(1); #[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 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 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}