use std::ffi::OsString;
use std::io::Read;
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use tempfile::NamedTempFile;
use crate::Error;
use crate::tty::{PreservedStdout, TtyHandles};
const TEST_BYPASS_TTY_ENV: &str = "RUSTY_VIPE_TEST_BYPASS_TTY";
pub fn drain_to_tempfile<R: Read>(mut reader: R, suffix: &str) -> Result<NamedTempFile, Error> {
let mut tempfile = tempfile::Builder::new()
.prefix(".rusty-vipe-")
.suffix(suffix)
.tempfile()?;
std::io::copy(&mut reader, tempfile.as_file_mut())?;
tempfile.as_file_mut().sync_data().ok();
Ok(tempfile)
}
pub fn spawn_editor(
argv: &[OsString],
extras: &[OsString],
tempfile_path: &Path,
tty: Option<TtyHandles>,
) -> Result<ExitStatus, Error> {
if argv.is_empty() {
return Err(Error::EditorNotFound(String::from("(empty argv)")));
}
let program = &argv[0];
let mut command = Command::new(program);
command.args(&argv[1..]);
command.args(extras);
command.arg(tempfile_path);
match tty {
Some(handles) => {
command.stdin(Stdio::from(handles.tty_in));
let stdout_handle = handles.tty_out.try_clone()?;
command.stdout(Stdio::from(stdout_handle));
command.stderr(Stdio::from(handles.tty_out));
}
None => {
command.stdin(Stdio::null());
command.stdout(Stdio::null());
command.stderr(Stdio::inherit());
}
}
let status = match command.status() {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let program_str = program.to_string_lossy().to_string();
return Err(Error::EditorNotFound(program_str));
}
Err(e) => return Err(Error::Io(e)),
};
Ok(status)
}
pub fn write_back_to_saved_stdout(
tempfile_path: &Path,
mut saved_stdout: PreservedStdout,
) -> Result<(), Error> {
let bytes = match std::fs::read(tempfile_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::TempFileDeleted(tempfile_path.to_path_buf()));
}
Err(e) => return Err(Error::Io(e)),
};
use std::io::Write;
saved_stdout.as_writer().write_all(&bytes)?;
saved_stdout.as_writer().flush().ok();
Ok(())
}
pub fn test_bypass_tty_enabled() -> bool {
match std::env::var_os(TEST_BYPASS_TTY_ENV) {
Some(v) => {
let Some(s) = v.to_str() else { return false };
matches!(
s.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
None => false,
}
}
pub fn clamp_exit_code(status: ExitStatus) -> i32 {
if status.success() {
return 0;
}
let raw = status.code().unwrap_or(1); #[cfg(unix)]
{
if (1..=255).contains(&raw) { raw } else { 1 }
}
#[cfg(windows)]
{
if (1..=254).contains(&raw) { raw } else { 1 }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn drain_empty_input_produces_zero_byte_tempfile() {
let tempfile = drain_to_tempfile(Cursor::new(&[][..]), ".txt").unwrap();
let meta = std::fs::metadata(tempfile.path()).unwrap();
assert_eq!(meta.len(), 0, "FR-008: empty stdin → zero-byte tempfile");
}
#[test]
fn drain_small_input_roundtrips() {
let tempfile = drain_to_tempfile(Cursor::new(b"hello\nworld\n"), ".txt").unwrap();
let bytes = std::fs::read(tempfile.path()).unwrap();
assert_eq!(bytes, b"hello\nworld\n");
}
#[test]
fn drain_uses_configured_suffix() {
let tempfile = drain_to_tempfile(Cursor::new(b"x"), ".json").unwrap();
let name = tempfile.path().file_name().unwrap().to_string_lossy();
assert!(
name.ends_with(".json"),
"FR-012: --suffix=.json should produce *.json tempfile, got {name}"
);
}
#[test]
fn drain_empty_suffix_means_no_extension() {
let tempfile = drain_to_tempfile(Cursor::new(b"x"), "").unwrap();
let name = tempfile.path().file_name().unwrap().to_string_lossy();
assert!(
!name.ends_with(".txt") && !name.ends_with('.'),
"FR-012 + Clarification Q2: empty --suffix= means no extension, got {name}"
);
}
#[test]
fn drain_binary_passthrough_unchanged() {
let bytes: &[u8] = &[0x00, 0xfe, 0xff, 0xc3, 0x28, 0xa0, 0xa1];
let tempfile = drain_to_tempfile(Cursor::new(bytes), ".bin").unwrap();
let read_back = std::fs::read(tempfile.path()).unwrap();
assert_eq!(read_back, bytes, "FR-017: bytes opaque, no transformation");
}
#[test]
fn spawn_editor_returns_editor_not_found_on_missing_binary() {
let argv = vec![OsString::from(
"a-binary-that-definitely-does-not-exist-12345",
)];
let extras: Vec<OsString> = Vec::new();
let result = spawn_editor(&argv, &extras, Path::new("/tmp/nope"), None);
match result {
Err(Error::EditorNotFound(name)) => {
assert!(name.contains("does-not-exist"), "should carry the argv[0]");
}
other => panic!("expected EditorNotFound, got {other:?}"),
}
}
#[test]
fn spawn_editor_empty_argv_returns_editor_not_found() {
let argv: Vec<OsString> = Vec::new();
let extras: Vec<OsString> = Vec::new();
let result = spawn_editor(&argv, &extras, Path::new("/tmp/nope"), None);
assert!(matches!(result, Err(Error::EditorNotFound(_))));
}
#[test]
fn write_back_returns_tempfile_deleted_when_file_missing() {
let tmpdir = tempfile::tempdir().unwrap();
let missing = tmpdir.path().join("does-not-exist.txt");
let preserved = crate::tty::preserve_stdout().expect("preserve_stdout should succeed");
let result = write_back_to_saved_stdout(&missing, preserved);
assert!(matches!(result, Err(Error::TempFileDeleted(_))));
}
#[test]
fn test_bypass_tty_recognizes_truthy_values() {
for v in ["1", "true", "yes", "on", "TRUE", " 1 "] {
unsafe {
std::env::set_var(TEST_BYPASS_TTY_ENV, v);
}
assert!(test_bypass_tty_enabled(), "{v:?} should be truthy");
}
unsafe {
std::env::remove_var(TEST_BYPASS_TTY_ENV);
}
assert!(!test_bypass_tty_enabled());
}
}