use os_pipe::{PipeReader, PipeWriter};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::io::IntoRawFd;
#[cfg(windows)]
use std::os::windows::io::IntoRawHandle;
use std::sync::{Arc, Mutex};
use std::thread;
use tokio::sync::mpsc::UnboundedSender;
use crate::repl::tui::event::{OutputKind, UiEvent};
const STDOUT_FD: libc::c_int = 1;
const STDERR_FD: libc::c_int = 2;
const RAW_LOG_ENV_VAR: &str = "SOFOS_RAW_LOG";
type RawLogSink = Option<Arc<Mutex<std::fs::File>>>;
fn open_raw_log() -> RawLogSink {
let path = std::env::var(RAW_LOG_ENV_VAR).ok()?;
let path = path.trim();
if path.is_empty() {
return None;
}
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
{
Ok(file) => {
let mut file = file;
let _ = writeln!(file, "[sofos raw pipe log — path={path}]");
let _ = file.flush();
Some(Arc::new(Mutex::new(file)))
}
Err(err) => {
eprintln!(
"SOFOS_RAW_LOG: failed to open {path:?} for writing: {err}. \
Raw-log capture disabled for this session."
);
None
}
}
}
fn write_raw_log(sink: &RawLogSink, kind: OutputKind, bytes: &[u8]) {
let Some(handle) = sink else { return };
let Ok(mut file) = handle.lock() else { return };
let mut rendered = format!("[{:?}] ", kind);
for &b in bytes {
if (0x20..=0x7e).contains(&b) && b != b'\\' {
rendered.push(b as char);
} else {
rendered.push_str(&format!("\\x{:02X}", b));
}
}
rendered.push('\n');
let _ = file.write_all(rendered.as_bytes());
let _ = file.flush();
}
pub struct OutputCapture {
saved_stdout: libc::c_int,
saved_stderr: libc::c_int,
pipe_stdout: libc::c_int,
pipe_stderr: libc::c_int,
}
impl OutputCapture {
pub fn install(tx: UnboundedSender<UiEvent>) -> std::io::Result<Self> {
let saved_stdout = dup_fd(STDOUT_FD)?;
let saved_stderr = match dup_fd(STDERR_FD) {
Ok(fd) => fd,
Err(e) => {
unsafe { libc::close(saved_stdout) };
return Err(e);
}
};
let (stdout_reader, stdout_writer) = match os_pipe::pipe() {
Ok(p) => p,
Err(e) => {
unsafe {
libc::close(saved_stdout);
libc::close(saved_stderr);
}
return Err(e);
}
};
let (stderr_reader, stderr_writer) = match os_pipe::pipe() {
Ok(p) => p,
Err(e) => {
unsafe {
libc::close(saved_stdout);
libc::close(saved_stderr);
}
return Err(e);
}
};
let pipe_stdout = match redirect(stdout_writer, STDOUT_FD) {
Ok(fd) => fd,
Err(e) => {
unsafe {
libc::close(saved_stdout);
libc::close(saved_stderr);
}
return Err(e);
}
};
let pipe_stderr = match redirect(stderr_writer, STDERR_FD) {
Ok(fd) => fd,
Err(e) => {
unsafe {
libc::dup2(saved_stdout, STDOUT_FD);
libc::close(saved_stdout);
libc::close(saved_stderr);
libc::close(pipe_stdout);
}
return Err(e);
}
};
let this = OutputCapture {
saved_stdout,
saved_stderr,
pipe_stdout,
pipe_stderr,
};
let raw_log = open_raw_log();
spawn_reader(
stdout_reader,
OutputKind::Stdout,
tx.clone(),
raw_log.clone(),
)?;
spawn_reader(stderr_reader, OutputKind::Stderr, tx, raw_log)?;
Ok(this)
}
}
impl Drop for OutputCapture {
fn drop(&mut self) {
unsafe {
libc::dup2(self.saved_stdout, STDOUT_FD);
libc::dup2(self.saved_stderr, STDERR_FD);
libc::close(self.saved_stdout);
libc::close(self.saved_stderr);
libc::close(self.pipe_stdout);
libc::close(self.pipe_stderr);
}
}
}
fn dup_fd(fd: libc::c_int) -> std::io::Result<libc::c_int> {
let dup = unsafe { libc::dup(fd) };
if dup < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(dup)
}
}
fn redirect(writer: PipeWriter, target_fd: libc::c_int) -> std::io::Result<libc::c_int> {
let write_fd = pipe_writer_into_fd(writer)?;
let rc = unsafe { libc::dup2(write_fd, target_fd) };
if rc < 0 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(write_fd) };
return Err(err);
}
Ok(write_fd)
}
#[cfg(unix)]
fn pipe_writer_into_fd(writer: PipeWriter) -> std::io::Result<libc::c_int> {
Ok(writer.into_raw_fd())
}
#[cfg(windows)]
fn pipe_writer_into_fd(writer: PipeWriter) -> std::io::Result<libc::c_int> {
let handle = writer.into_raw_handle();
let fd =
unsafe { libc::open_osfhandle(handle as libc::intptr_t, libc::O_BINARY | libc::O_WRONLY) };
if fd < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(fd)
}
}
fn spawn_reader(
reader: PipeReader,
kind: OutputKind,
tx: UnboundedSender<UiEvent>,
raw_log: RawLogSink,
) -> std::io::Result<()> {
thread::Builder::new()
.name(format!("sofos-{:?}-reader", kind))
.spawn(move || {
let mut buf = BufReader::new(reader);
let mut line = Vec::<u8>::new();
let mut state = SgrState::default();
loop {
line.clear();
match buf.read_until(b'\n', &mut line) {
Ok(0) => break,
Ok(_) => {
write_raw_log(&raw_log, kind, &line);
while matches!(line.last(), Some(b'\n') | Some(b'\r')) {
line.pop();
}
let mut text = String::from_utf8_lossy(&line).into_owned();
strip_trailing_incomplete_csi(&mut text);
let prefix = state.to_ansi_prefix();
state.apply(&text);
let payload = if prefix.is_empty() {
text
} else {
let mut out = String::with_capacity(prefix.len() + text.len());
out.push_str(&prefix);
out.push_str(&text);
out
};
if tx
.send(UiEvent::Output {
kind,
text: payload,
})
.is_err()
{
break;
}
}
Err(_) => break,
}
}
})?;
Ok(())
}
fn strip_trailing_incomplete_csi(text: &mut String) {
let bytes = text.as_bytes();
let esc_positions: Vec<usize> = bytes
.iter()
.enumerate()
.filter_map(|(idx, &b)| (b == 0x1b).then_some(idx))
.collect();
for &start in esc_positions.iter().rev() {
if bytes.get(start + 1) != Some(&b'[') {
continue;
}
let mut j = start + 2;
while let Some(&b) = bytes.get(j) {
if matches!(b, b'0'..=b'9' | b';' | b':' | b'?' | b'<' | b'=' | b'>') {
j += 1;
} else {
break;
}
}
while let Some(&b) = bytes.get(j) {
if (0x20..=0x2f).contains(&b) {
j += 1;
} else {
break;
}
}
match bytes.get(j) {
Some(&terminator) if (0x40..=0x7e).contains(&terminator) => {
return;
}
Some(_) => {
return;
}
None => {
text.truncate(start);
return;
}
}
}
}
#[derive(Default)]
struct SgrState {
attrs: SgrAttrs,
fg: SgrColor,
bg: SgrColor,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
struct SgrAttrs {
bold: bool,
dim: bool,
italic: bool,
underline: bool,
slow_blink: bool,
rapid_blink: bool,
reverse: bool,
conceal: bool,
strike: bool,
}
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
enum SgrColor {
#[default]
Default,
Basic(u16),
Indexed(u8),
Rgb(u8, u8, u8),
}
impl SgrState {
fn reset(&mut self) {
self.attrs = SgrAttrs::default();
self.fg = SgrColor::Default;
self.bg = SgrColor::Default;
}
fn to_ansi_prefix(&self) -> String {
let mut params: Vec<u16> = Vec::new();
if self.attrs.bold {
params.push(1);
}
if self.attrs.dim {
params.push(2);
}
if self.attrs.italic {
params.push(3);
}
if self.attrs.underline {
params.push(4);
}
if self.attrs.slow_blink {
params.push(5);
}
if self.attrs.rapid_blink {
params.push(6);
}
if self.attrs.reverse {
params.push(7);
}
if self.attrs.conceal {
params.push(8);
}
if self.attrs.strike {
params.push(9);
}
let mut out = String::new();
if !params.is_empty() {
out.push_str("\x1b[");
for (i, p) in params.iter().enumerate() {
if i > 0 {
out.push(';');
}
out.push_str(&p.to_string());
}
out.push('m');
}
push_color_ansi(&mut out, self.fg, ColorSlot::Fg);
push_color_ansi(&mut out, self.bg, ColorSlot::Bg);
out
}
fn apply(&mut self, text: &str) {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != 0x1b || i + 1 >= bytes.len() || bytes[i + 1] != b'[' {
i += 1;
continue;
}
let mut j = i + 2;
let mut private = false;
if let Some(&b) = bytes.get(j) {
if matches!(b, b'?' | b'<' | b'=' | b'>') {
private = true;
j += 1;
}
}
let param_start = j;
while let Some(&b) = bytes.get(j) {
if matches!(b, b'0'..=b'9' | b';' | b':') {
j += 1;
} else {
break;
}
}
let param_end = j;
while let Some(&b) = bytes.get(j) {
if (0x20..=0x2f).contains(&b) {
j += 1;
} else {
break;
}
}
let Some(&terminator) = bytes.get(j) else {
break;
};
let consumed_end = j + 1;
if private || terminator != b'm' {
i = consumed_end;
continue;
}
let params_str = std::str::from_utf8(&bytes[param_start..param_end]).unwrap_or("");
self.apply_sgr_params(params_str);
i = consumed_end;
}
}
fn apply_sgr_params(&mut self, params_str: &str) {
if params_str.is_empty() {
self.reset();
return;
}
let nums: Vec<u16> = params_str
.split(';')
.filter_map(|p| p.parse::<u16>().ok())
.collect();
let mut k = 0;
while k < nums.len() {
let n = nums[k];
match n {
0 => self.reset(),
1 => self.attrs.bold = true,
2 => self.attrs.dim = true,
3 => self.attrs.italic = true,
4 => self.attrs.underline = true,
5 => self.attrs.slow_blink = true,
6 => self.attrs.rapid_blink = true,
7 => self.attrs.reverse = true,
8 => self.attrs.conceal = true,
9 => self.attrs.strike = true,
22 => {
self.attrs.bold = false;
self.attrs.dim = false;
}
23 => self.attrs.italic = false,
24 => self.attrs.underline = false,
25 => {
self.attrs.slow_blink = false;
self.attrs.rapid_blink = false;
}
27 => self.attrs.reverse = false,
28 => self.attrs.conceal = false,
29 => self.attrs.strike = false,
30..=37 | 90..=97 => self.fg = SgrColor::Basic(n),
39 => self.fg = SgrColor::Default,
40..=47 | 100..=107 => self.bg = SgrColor::Basic(n),
49 => self.bg = SgrColor::Default,
38 => {
if let Some(color) = parse_extended_color(&nums, &mut k) {
self.fg = color;
continue;
}
}
48 => {
if let Some(color) = parse_extended_color(&nums, &mut k) {
self.bg = color;
continue;
}
}
_ => {}
}
k += 1;
}
}
}
fn parse_extended_color(nums: &[u16], cursor: &mut usize) -> Option<SgrColor> {
let mode = *nums.get(*cursor + 1)?;
match mode {
5 => {
let idx = *nums.get(*cursor + 2)?;
let color = SgrColor::Indexed(u8::try_from(idx).ok()?);
*cursor += 3;
Some(color)
}
2 => {
let r = u8::try_from(*nums.get(*cursor + 2)?).ok()?;
let g = u8::try_from(*nums.get(*cursor + 3)?).ok()?;
let b = u8::try_from(*nums.get(*cursor + 4)?).ok()?;
*cursor += 5;
Some(SgrColor::Rgb(r, g, b))
}
_ => None,
}
}
#[derive(Clone, Copy)]
enum ColorSlot {
Fg,
Bg,
}
fn push_color_ansi(out: &mut String, color: SgrColor, slot: ColorSlot) {
let prefix = match slot {
ColorSlot::Fg => 38u16,
ColorSlot::Bg => 48u16,
};
match color {
SgrColor::Default => {}
SgrColor::Basic(n) => {
out.push_str("\x1b[");
out.push_str(&n.to_string());
out.push('m');
}
SgrColor::Indexed(idx) => {
out.push_str("\x1b[");
out.push_str(&prefix.to_string());
out.push_str(";5;");
out.push_str(&idx.to_string());
out.push('m');
}
SgrColor::Rgb(r, g, b) => {
out.push_str("\x1b[");
out.push_str(&prefix.to_string());
out.push_str(";2;");
out.push_str(&r.to_string());
out.push(';');
out.push_str(&g.to_string());
out.push(';');
out.push_str(&b.to_string());
out.push('m');
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_state_emits_nothing() {
let s = SgrState::default();
assert_eq!(s.to_ansi_prefix(), "");
}
#[test]
fn bold_is_tracked_and_reset_clears_it() {
let mut s = SgrState::default();
s.apply("\x1b[1mhello");
assert!(s.attrs.bold);
assert_eq!(s.to_ansi_prefix(), "\x1b[1m");
s.apply(" world\x1b[0m");
assert!(!s.attrs.bold);
assert_eq!(s.to_ansi_prefix(), "");
}
#[test]
fn combined_bold_italic_fg_round_trip() {
let mut s = SgrState::default();
s.apply("\x1b[1;3;31mfoo");
assert!(s.attrs.bold);
assert!(s.attrs.italic);
assert_eq!(s.fg, SgrColor::Basic(31));
assert_eq!(s.to_ansi_prefix(), "\x1b[1;3m\x1b[31m");
}
#[test]
fn reset_via_empty_m_clears_state() {
let mut s = SgrState::default();
s.apply("\x1b[1mfoo\x1b[mbar");
assert_eq!(s.to_ansi_prefix(), "");
}
#[test]
fn ignores_non_sgr_csi_sequences() {
let mut s = SgrState::default();
s.apply("\x1b[2J\x1b[1;5H\x1b[1mstill bold");
assert!(s.attrs.bold);
assert_eq!(s.to_ansi_prefix(), "\x1b[1m");
}
#[test]
fn skips_dec_private_mode_sequences() {
let mut s = SgrState::default();
s.apply("\x1b[1m\x1b[?25lstill bold\x1b[?25h");
assert!(s.attrs.bold);
assert_eq!(s.to_ansi_prefix(), "\x1b[1m");
}
#[test]
fn skips_cursor_movement_sequences() {
let mut s = SgrState::default();
s.apply("\x1b[31m\x1b[1;5Hred\x1b[0mreset");
assert_eq!(s.fg, SgrColor::Default);
assert!(!s.attrs.bold);
assert_eq!(s.to_ansi_prefix(), "");
}
#[test]
fn handles_256_color_fg() {
let mut s = SgrState::default();
s.apply("\x1b[38;5;202mtext");
assert_eq!(s.fg, SgrColor::Indexed(202));
assert_eq!(s.to_ansi_prefix(), "\x1b[38;5;202m");
}
#[test]
fn handles_truecolor_fg() {
let mut s = SgrState::default();
s.apply("\x1b[38;2;255;128;0mhello");
assert_eq!(s.fg, SgrColor::Rgb(255, 128, 0));
assert_eq!(s.to_ansi_prefix(), "\x1b[38;2;255;128;0m");
}
#[test]
fn handles_truecolor_bg() {
let mut s = SgrState::default();
s.apply("\x1b[48;2;10;20;30mhello");
assert_eq!(s.bg, SgrColor::Rgb(10, 20, 30));
assert_eq!(s.to_ansi_prefix(), "\x1b[48;2;10;20;30m");
}
#[test]
fn independent_attrs_are_not_lost() {
let mut s = SgrState::default();
s.apply("\x1b[1m"); for code in 31..=37 {
s.apply(&format!("\x1b[{}m", code));
}
for code in 90..=97 {
s.apply(&format!("\x1b[{}m", code));
}
assert!(s.attrs.bold);
assert_eq!(s.fg, SgrColor::Basic(97));
}
#[test]
fn partial_off_leaves_other_attrs_alone() {
let mut s = SgrState::default();
s.apply("\x1b[1;3munderline"); s.apply("\x1b[23mno italic"); assert!(s.attrs.bold);
assert!(!s.attrs.italic);
}
#[test]
fn bold_dim_share_22_off() {
let mut s = SgrState::default();
s.apply("\x1b[1;2mboth");
s.apply("\x1b[22mneither");
assert!(!s.attrs.bold);
assert!(!s.attrs.dim);
}
#[test]
fn default_fg_restores_via_39() {
let mut s = SgrState::default();
s.apply("\x1b[31mred");
s.apply("\x1b[39mdefault");
assert_eq!(s.fg, SgrColor::Default);
assert_eq!(s.to_ansi_prefix(), "");
}
#[test]
fn malformed_extended_color_is_ignored() {
let mut s = SgrState::default();
s.apply("\x1b[31m\x1b[38;5m");
assert_eq!(s.fg, SgrColor::Basic(31));
}
#[test]
fn strip_trailing_incomplete_csi_drops_unterminated_tail() {
let mut text = "text\x1b[38;2;".to_string();
strip_trailing_incomplete_csi(&mut text);
assert_eq!(text, "text");
}
#[test]
fn strip_trailing_incomplete_csi_preserves_terminated_tail() {
let mut text = "text\x1b[31mred".to_string();
let original = text.clone();
strip_trailing_incomplete_csi(&mut text);
assert_eq!(text, original);
}
#[test]
fn strip_trailing_incomplete_csi_keeps_earlier_sequences() {
let mut text = "\x1b[31mred\x1b[38;2;".to_string();
strip_trailing_incomplete_csi(&mut text);
assert_eq!(text, "\x1b[31mred");
}
#[test]
fn strip_trailing_incomplete_csi_is_noop_on_plain_text() {
let mut text = "plain text with no escapes".to_string();
strip_trailing_incomplete_csi(&mut text);
assert_eq!(text, "plain text with no escapes");
}
#[test]
fn strip_trailing_incomplete_csi_tolerates_bare_esc() {
let mut text = "text\x1ball".to_string();
let original = text.clone();
strip_trailing_incomplete_csi(&mut text);
assert_eq!(text, original);
}
}