sivtr-core 0.1.0

Core library for sivtr terminal output and AI coding session processing
Documentation
use anyhow::Result;
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Once;

use crate::session;

static CLEANUP_ONCE: Once = Once::new();

/// Get the session log path used by the shell hook.
pub fn session_log_path() -> std::path::PathBuf {
    if let Ok(path) = env::var("SIVTR_SESSION_LOG") {
        let path = PathBuf::from(path);
        cleanup_stale_sessions_once(&path);
        return path;
    }

    if let Ok(path) = env::var("SIFT_SESSION_LOG") {
        let path = PathBuf::from(path);
        cleanup_stale_sessions_once(&path);
        return path;
    }

    let base_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
    let current = base_dir.join("sivtr").join("session.log");
    if current.exists() {
        return current;
    }

    let legacy = base_dir.join("sift").join("session.log");
    if legacy.exists() {
        return legacy;
    }

    current
}

fn cleanup_stale_sessions_once(current_session_path: &Path) {
    let current_session_path = current_session_path.to_path_buf();
    CLEANUP_ONCE.call_once(|| {
        let _ = cleanup_stale_sessions(&current_session_path);
    });
}

fn cleanup_stale_sessions(current_session_path: &Path) -> Result<()> {
    let Some(parent) = current_session_path.parent() else {
        return Ok(());
    };
    if !parent.exists() {
        return Ok(());
    }

    let current_pid = session_pid_from_path(current_session_path);

    for entry in std::fs::read_dir(parent)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_file() {
            continue;
        }

        let Some(pid) = session_pid_from_path(&path) else {
            continue;
        };

        if Some(pid) == current_pid || process_is_alive(pid) {
            continue;
        }

        let _ = std::fs::remove_file(path);
    }

    Ok(())
}

fn session_pid_from_path(path: &Path) -> Option<u32> {
    let name = path.file_name()?.to_str()?;
    let suffix = if let Some(pid) = name
        .strip_prefix("session_")
        .and_then(|rest| rest.strip_suffix(".log"))
    {
        pid
    } else if let Some(pid) = name
        .strip_prefix("session_")
        .and_then(|rest| rest.strip_suffix(".state"))
    {
        pid
    } else {
        return None;
    };

    suffix.parse::<u32>().ok()
}

#[cfg(windows)]
fn process_is_alive(pid: u32) -> bool {
    use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
    use winapi::um::processthreadsapi::OpenProcess;
    use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION;

    unsafe {
        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
        if handle.is_null() || handle == INVALID_HANDLE_VALUE {
            return false;
        }
        let _ = CloseHandle(handle);
        true
    }
}

#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
    if result == 0 {
        return true;
    }

    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

#[cfg(test)]
mod tests {
    use super::session_pid_from_path;
    use std::path::Path;

    #[test]
    fn extracts_pid_from_session_artifacts() {
        assert_eq!(session_pid_from_path(Path::new("session_42.log")), Some(42));
        assert_eq!(
            session_pid_from_path(Path::new("session_42.state")),
            Some(42)
        );
        assert_eq!(session_pid_from_path(Path::new("session.log")), None);
        assert_eq!(session_pid_from_path(Path::new("history.db")), None);
    }
}

/// Get the flush state file path (derived from session log path).
pub fn flush_state_path() -> std::path::PathBuf {
    session_log_path().with_extension("state")
}

/// Read the current session log populated by the shell hook.
pub fn read_session_log() -> Result<Option<String>> {
    let log = session_log_path();
    if log.exists() {
        let entries = session::load_entries(&log)?;
        if !entries.is_empty() {
            return Ok(Some(session::render_entries_ansi(&entries)));
        }
    }
    Ok(None)
}

/// Read the current console buffer with ANSI color codes.
/// Used by `sivtr flush` to incrementally capture output.
#[cfg(windows)]
pub struct ConsoleSnapshot {
    pub content: String,
    pub width: usize,
}

/// Read the current console buffer with ANSI color codes.
/// Used by `sivtr flush` to incrementally capture output.
#[cfg(windows)]
pub fn capture_console_buffer() -> Result<ConsoleSnapshot> {
    capture_windows_console()
}

/// Read the Windows console screen buffer via Win32 API,
/// including color attributes converted to ANSI escape codes.
#[cfg(windows)]
fn capture_windows_console() -> Result<ConsoleSnapshot> {
    use std::ptr;

    use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING};
    use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
    use winapi::um::processenv::GetStdHandle;
    use winapi::um::winbase::STD_OUTPUT_HANDLE;
    use winapi::um::wincon::{
        GetConsoleScreenBufferInfo, ReadConsoleOutputAttribute, ReadConsoleOutputCharacterW,
        CONSOLE_SCREEN_BUFFER_INFO, COORD,
    };
    use winapi::um::winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE};

    unsafe {
        // In PowerShell and ConPTY-based terminals, a child process's STDOUT is often a pipe.
        // Opening CONOUT$ gives us the active console screen buffer instead of the redirected handle.
        let conout: Vec<u16> = "CONOUT$\0".encode_utf16().collect();
        let conout_handle = CreateFileW(
            conout.as_ptr(),
            GENERIC_READ | GENERIC_WRITE,
            FILE_SHARE_READ | FILE_SHARE_WRITE,
            ptr::null_mut(),
            OPEN_EXISTING,
            0,
            ptr::null_mut(),
        );
        let (handle, should_close) =
            if !conout_handle.is_null() && conout_handle != INVALID_HANDLE_VALUE {
                (conout_handle, true)
            } else {
                let std_handle = GetStdHandle(STD_OUTPUT_HANDLE);
                (std_handle, false)
            };

        if handle.is_null() || handle == INVALID_HANDLE_VALUE {
            anyhow::bail!("Failed to get console handle");
        }

        let mut info: CONSOLE_SCREEN_BUFFER_INFO = std::mem::zeroed();
        if GetConsoleScreenBufferInfo(handle, &mut info) == 0 {
            if should_close {
                CloseHandle(handle);
            }
            anyhow::bail!("Failed to get console buffer info");
        }

        let width = info.dwSize.X as usize;
        if width == 0 {
            return Ok(ConsoleSnapshot {
                content: String::new(),
                width: 0,
            });
        }
        let cursor_row = info.dwCursorPosition.Y as usize;
        let content_rows = (cursor_row + 1).min(info.dwSize.Y as usize);
        let default_attr = info.wAttributes;

        let mut result = String::with_capacity(content_rows * (width + 20));
        let mut char_buf = vec![0u16; width];
        let mut attr_buf = vec![0u16; width];

        for row in 0..content_rows {
            let mut chars_read: u32 = 0;
            let mut attrs_read: u32 = 0;
            let coord = COORD {
                X: 0,
                Y: row as i16,
            };

            ReadConsoleOutputCharacterW(
                handle,
                char_buf.as_mut_ptr(),
                width as u32,
                coord,
                &mut chars_read,
            );
            ReadConsoleOutputAttribute(
                handle,
                attr_buf.as_mut_ptr(),
                width as u32,
                coord,
                &mut attrs_read,
            );

            let line_str = String::from_utf16_lossy(&char_buf[..chars_read as usize]);
            let trimmed = line_str.trim_end();

            if trimmed.is_empty() {
                result.push('\n');
                continue;
            }

            let trimmed_len = trimmed.len();
            let mut prev_attr = default_attr;
            let mut byte_idx: usize = 0;
            let mut cell_idx: usize = 0;

            for ch in line_str.chars() {
                if byte_idx >= trimmed_len {
                    break;
                }

                let attr = if cell_idx < attrs_read as usize {
                    attr_buf[cell_idx]
                } else {
                    default_attr
                };

                // Emit ANSI code when attribute changes
                if attr != prev_attr || cell_idx == 0 {
                    if attr != default_attr {
                        result.push_str(&win_attr_to_ansi(attr));
                    } else if prev_attr != default_attr {
                        result.push_str("\x1b[0m");
                    }
                    prev_attr = attr;
                }

                result.push(ch);
                byte_idx += ch.len_utf8();

                // Wide chars occupy 2 console cells
                if unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) > 1 {
                    cell_idx += 2;
                } else {
                    cell_idx += 1;
                }
            }

            // Reset at end of line if we had non-default color
            if prev_attr != default_attr {
                result.push_str("\x1b[0m");
            }

            result.push('\n');
        }

        if should_close {
            CloseHandle(handle);
        }

        let trimmed = result.trim_end_matches('\n').to_string();
        Ok(ConsoleSnapshot {
            content: trimmed,
            width,
        })
    }
}

/// Convert a Windows console attribute word to an ANSI SGR escape sequence.
#[cfg(windows)]
fn win_attr_to_ansi(attr: u16) -> String {
    // Windows console color bits (BGR order):
    //   Foreground: bits 0-3 (B=0x01, G=0x02, R=0x04, Intensity=0x08)
    //   Background: bits 4-7 (B=0x10, G=0x20, R=0x40, Intensity=0x80)
    //
    // ANSI standard color order (index 0-7):
    //   0=black 1=red 2=green 3=yellow 4=blue 5=magenta 6=cyan 7=white

    // Map Windows BGR bits (0-7) to ANSI color index
    const WIN_TO_ANSI: [u8; 8] = [0, 4, 2, 6, 1, 5, 3, 7];

    let fg_bits = (attr & 0x07) as usize;
    let fg_intense = (attr & 0x08) != 0;
    let bg_bits = ((attr >> 4) & 0x07) as usize;
    let bg_intense = (attr & 0x80) != 0;

    let fg_ansi = WIN_TO_ANSI[fg_bits];
    let bg_ansi = WIN_TO_ANSI[bg_bits];

    let mut codes = Vec::new();

    // Foreground
    if fg_intense {
        codes.push(format!("{}", 90 + fg_ansi)); // bright fg
    } else {
        codes.push(format!("{}", 30 + fg_ansi)); // normal fg
    }

    // Background (only if non-black)
    if bg_bits != 0 || bg_intense {
        if bg_intense {
            codes.push(format!("{}", 100 + bg_ansi));
        } else {
            codes.push(format!("{}", 40 + bg_ansi));
        }
    }

    format!("\x1b[{}m", codes.join(";"))
}