use std::collections::VecDeque;
use std::time::{SystemTime, UNIX_EPOCH};
pub use tear_types::Block;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Phase {
Idle,
Prompt,
Command,
Output,
}
pub struct BlockExtractor {
blocks: VecDeque<Block>,
cap: usize,
current: Option<Block>,
phase: Phase,
next_index: u64,
current_cwd: Option<String>,
}
impl Default for BlockExtractor {
fn default() -> Self {
Self::new(10_000)
}
}
impl BlockExtractor {
#[must_use]
pub fn new(cap: usize) -> Self {
Self {
blocks: VecDeque::new(),
cap,
current: None,
phase: Phase::Idle,
next_index: 0,
current_cwd: None,
}
}
pub fn set_cwd_from_osc7(&mut self, raw: &str) {
if let Some(rest) = raw.strip_prefix("file://") {
if let Some(slash) = rest.find('/') {
self.current_cwd = Some(rest[slash..].to_owned());
return;
}
}
self.current_cwd = Some(raw.to_owned());
}
#[must_use]
pub fn current_cwd(&self) -> Option<&str> {
self.current_cwd.as_deref()
}
#[must_use]
pub fn len(&self) -> usize {
self.blocks.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.blocks.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Block> {
self.blocks.iter()
}
#[must_use]
pub fn get(&self, index: u64) -> Option<&Block> {
self.blocks.iter().find(|b| b.index == index)
}
#[must_use]
pub fn current(&self) -> Option<&Block> {
self.current.as_ref()
}
pub fn on_print(&mut self, c: char) {
let Some(block) = self.current.as_mut() else {
return;
};
match self.phase {
Phase::Prompt => block.prompt.push(c),
Phase::Command => block.command.push(c),
Phase::Output => block.output.push(c),
Phase::Idle => {}
}
}
pub fn on_raw_byte(&mut self, b: u8) {
let Some(block) = self.current.as_mut() else {
return;
};
if matches!(self.phase, Phase::Output) {
block.output.push(b as char);
}
}
pub fn on_osc_133(&mut self, marker: &str) {
let kind = marker.chars().next().unwrap_or(' ');
match kind {
'A' => self.start_prompt(),
'B' => self.start_command(),
'C' => self.start_output(),
'D' => self.end_output(parse_exit_code(marker)),
_ => {}
}
}
fn start_prompt(&mut self) {
if self.current.is_some() {
self.finalize_current(None);
}
let now = now_ms();
self.current = Some(Block {
index: self.next_index,
prompt: String::new(),
command: String::new(),
output: String::new(),
exit_code: None,
started_at_unix_ms: now,
ended_at_unix_ms: None,
cwd: self.current_cwd.clone(),
});
self.next_index += 1;
self.phase = Phase::Prompt;
}
fn start_command(&mut self) {
if self.current.is_some() {
self.phase = Phase::Command;
}
}
fn start_output(&mut self) {
if self.current.is_some() {
self.phase = Phase::Output;
}
}
fn end_output(&mut self, exit_code: Option<i32>) {
self.finalize_current(exit_code);
}
fn finalize_current(&mut self, exit_code: Option<i32>) {
let Some(mut block) = self.current.take() else {
return;
};
block.exit_code = exit_code;
block.ended_at_unix_ms = Some(now_ms());
if self.blocks.len() == self.cap {
self.blocks.pop_front();
}
self.blocks.push_back(block);
self.phase = Phase::Idle;
}
}
fn parse_exit_code(marker: &str) -> Option<i32> {
marker.split(';').nth(1).and_then(|s| s.trim().parse().ok())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_extractor_drops_prints() {
let mut bx = BlockExtractor::default();
bx.on_print('x');
assert!(bx.is_empty());
assert!(bx.current().is_none());
}
#[test]
fn full_block_lifecycle_captures_all_phases() {
let mut bx = BlockExtractor::default();
bx.on_osc_133("A");
for c in "$ ".chars() {
bx.on_print(c);
}
bx.on_osc_133("B");
for c in "ls".chars() {
bx.on_print(c);
}
bx.on_osc_133("C");
for c in "a b c".chars() {
bx.on_print(c);
}
bx.on_osc_133("D;0");
assert_eq!(bx.len(), 1);
let b = bx.iter().next().unwrap();
assert_eq!(b.prompt, "$ ");
assert_eq!(b.command, "ls");
assert_eq!(b.output, "a b c");
assert_eq!(b.exit_code, Some(0));
assert!(b.ended_at_unix_ms.is_some());
assert_eq!(b.index, 0);
assert!(bx.current().is_none());
}
#[test]
fn exit_code_optional_when_d_marker_omits_it() {
let mut bx = BlockExtractor::default();
bx.on_osc_133("A");
bx.on_osc_133("B");
bx.on_osc_133("C");
bx.on_osc_133("D");
let b = bx.iter().next().unwrap();
assert_eq!(b.exit_code, None);
}
#[test]
fn unfinished_block_is_orphaned_when_next_prompt_starts() {
let mut bx = BlockExtractor::default();
bx.on_osc_133("A");
for c in "p1".chars() {
bx.on_print(c);
}
bx.on_osc_133("A");
for c in "p2".chars() {
bx.on_print(c);
}
bx.on_osc_133("B");
bx.on_osc_133("C");
bx.on_osc_133("D;0");
assert_eq!(bx.len(), 2);
let mut iter = bx.iter();
let first = iter.next().unwrap();
let second = iter.next().unwrap();
assert_eq!(first.prompt, "p1");
assert_eq!(first.exit_code, None);
assert_eq!(second.prompt, "p2");
assert_eq!(second.exit_code, Some(0));
}
#[test]
fn ring_buffer_caps_at_max() {
let mut bx = BlockExtractor::new(3);
for i in 0..5 {
bx.on_osc_133("A");
for c in format!("p{i}").chars() {
bx.on_print(c);
}
bx.on_osc_133("D;0");
}
assert_eq!(bx.len(), 3);
let indices: Vec<u64> = bx.iter().map(|b| b.index).collect();
assert_eq!(indices, vec![2, 3, 4]);
}
#[test]
fn get_by_index_returns_block_or_none() {
let mut bx = BlockExtractor::default();
bx.on_osc_133("A");
bx.on_osc_133("D;0");
bx.on_osc_133("A");
bx.on_osc_133("D;1");
assert_eq!(bx.get(0).map(|b| b.exit_code), Some(Some(0)));
assert_eq!(bx.get(1).map(|b| b.exit_code), Some(Some(1)));
assert!(bx.get(99).is_none());
}
#[test]
fn osc7_cwd_stamped_onto_next_block() {
let mut bx = BlockExtractor::default();
bx.set_cwd_from_osc7("file://localhost/Users/me/code");
bx.on_osc_133("A");
bx.on_osc_133("D;0");
let b = bx.iter().next().unwrap();
assert_eq!(b.cwd.as_deref(), Some("/Users/me/code"));
}
#[test]
fn osc7_without_file_scheme_passes_through_verbatim() {
let mut bx = BlockExtractor::default();
bx.set_cwd_from_osc7("/tmp/raw-path");
assert_eq!(bx.current_cwd(), Some("/tmp/raw-path"));
}
#[test]
fn block_duration_ms_computes_on_finalize() {
let mut bx = BlockExtractor::default();
bx.on_osc_133("A");
std::thread::sleep(std::time::Duration::from_millis(5));
bx.on_osc_133("D;0");
let b = bx.iter().next().unwrap();
let d = b.duration_ms().expect("finalized block has duration");
assert!(d < 5_000, "absurd duration: {d}ms");
}
#[test]
fn parse_exit_code_handles_typical_shapes() {
assert_eq!(parse_exit_code("D"), None);
assert_eq!(parse_exit_code("D;"), None);
assert_eq!(parse_exit_code("D;0"), Some(0));
assert_eq!(parse_exit_code("D;127"), Some(127));
assert_eq!(parse_exit_code("D ; 130"), Some(130));
}
}