tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! OSC sequence parser for terminal title extraction.
//!
//! Supports:
//! - `ESC ] Ps ; <title> BEL` (0x07)
//! - `ESC ] Ps ; <title> ST` (ESC \)
//!
//! where `Ps` is 0, 1, or 2 (window/icon title).

/// OSC parser states
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum State {
    #[default]
    Ground,
    Escape,
    OscStart,
    OscParam,
    OscString,
    OscEscape,
}

/// Parsed OSC result
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OscResult {
    /// Window/icon title (OSC 0, 1, 2)
    Title(String),
}

/// OSC sequence parser with state machine
#[derive(Debug, Default)]
pub struct OscParser {
    state: State,
    param: u8,
    buffer: Vec<u8>,
}

const ESC: u8 = 0x1B;
const BEL: u8 = 0x07;

impl OscParser {
    /// Create new parser in ground state
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Feed single byte, optionally emit result
    ///
    /// State transitions:
    /// ```text
    /// Ground ─ESC→ Escape ─]→ OscStart ─0-9→ OscParam ─;→ OscString
    ///    ///                                                 BEL/ST ↓
    ///                                                     Ground
    /// ```
    pub fn feed(&mut self, byte: u8) -> Option<OscResult> {
        match self.state {
            State::Ground => {
                if byte == ESC {
                    self.state = State::Escape;
                }
                None
            }
            State::Escape => {
                if byte == b']' {
                    self.state = State::OscStart;
                    self.param = 0;
                    self.buffer.clear();
                } else {
                    self.reset();
                }
                None
            }
            State::OscStart => {
                if byte.is_ascii_digit() {
                    self.param = byte - b'0';
                    self.state = State::OscParam;
                } else {
                    self.reset();
                }
                None
            }
            State::OscParam => {
                if byte.is_ascii_digit() {
                    self.param = self.param.saturating_mul(10).saturating_add(byte - b'0');
                } else if byte == b';' {
                    self.state = State::OscString;
                } else {
                    self.reset();
                }
                None
            }
            State::OscString => {
                if byte == BEL {
                    self.emit_title()
                } else if byte == ESC {
                    self.state = State::OscEscape;
                    None
                } else {
                    self.buffer.push(byte);
                    None
                }
            }
            State::OscEscape => {
                if byte == b'\\' {
                    self.emit_title()
                } else {
                    self.reset();
                    None
                }
            }
        }
    }

    /// Feed slice, collect all results
    pub fn feed_slice(&mut self, data: &[u8]) -> Vec<OscResult> {
        data.iter().filter_map(|&b| self.feed(b)).collect()
    }

    /// Reset to ground state
    pub fn reset(&mut self) {
        self.state = State::Ground;
        self.param = 0;
        self.buffer.clear();
    }

    fn emit_title(&mut self) -> Option<OscResult> {
        // OSC 0 = icon+title, OSC 1 = icon, OSC 2 = title
        let result = if self.param <= 2 {
            String::from_utf8_lossy(&self.buffer)
                .into_owned()
                .pipe(OscResult::Title)
                .pipe(Some)
        } else {
            None
        };
        self.reset();
        result
    }
}

trait Pipe: Sized {
    fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
        f(self)
    }
}

impl<T> Pipe for T {}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn parse_title_bel() {
        let mut parser = OscParser::new();
        let results = parser.feed_slice(b"\x1b]0;Hello\x07");
        assert_eq!(results, vec![OscResult::Title("Hello".to_string())]);
    }

    #[test]
    fn parse_title_st() {
        let mut parser = OscParser::new();
        let results = parser.feed_slice(b"\x1b]0;World\x1b\\");
        assert_eq!(results, vec![OscResult::Title("World".to_string())]);
    }

    #[test]
    fn parse_title_osc2() {
        let mut parser = OscParser::new();
        let results = parser.feed_slice(b"\x1b]2;Window Title\x07");
        assert_eq!(results, vec![OscResult::Title("Window Title".to_string())]);
    }

    #[test]
    fn partial_then_complete() {
        let mut parser = OscParser::new();
        assert!(parser.feed_slice(b"\x1b]0;Hel").is_empty());
        let results = parser.feed_slice(b"lo\x07");
        assert_eq!(results, vec![OscResult::Title("Hello".to_string())]);
    }

    #[test]
    fn ignore_non_title_osc() {
        let mut parser = OscParser::new();
        // OSC 7 = current directory (not title)
        let results = parser.feed_slice(b"\x1b]7;file:///path\x07");
        assert!(results.is_empty());
    }

    #[test]
    fn invalid_sequence_reset() {
        let mut parser = OscParser::new();
        // Invalid: missing ]
        let results = parser.feed_slice(b"\x1bXinvalid\x07");
        assert!(results.is_empty());
        // Should recover and parse valid sequence
        let results = parser.feed_slice(b"\x1b]0;Valid\x07");
        assert_eq!(results, vec![OscResult::Title("Valid".to_string())]);
    }

    #[test]
    fn multiple_sequences() {
        let mut parser = OscParser::new();
        let results = parser.feed_slice(b"\x1b]0;First\x07text\x1b]0;Second\x1b\\");
        assert_eq!(
            results,
            vec![
                OscResult::Title("First".to_string()),
                OscResult::Title("Second".to_string()),
            ]
        );
    }

    #[test]
    fn utf8_title() {
        let mut parser = OscParser::new();
        let results = parser.feed_slice("\x1b]0;日本語タイトル\x07".as_bytes());
        assert_eq!(
            results,
            vec![OscResult::Title("日本語タイトル".to_string())]
        );
    }

    #[test]
    fn empty_title() {
        let mut parser = OscParser::new();
        let results = parser.feed_slice(b"\x1b]0;\x07");
        assert_eq!(results, vec![OscResult::Title(String::new())]);
    }

    #[test]
    fn multi_digit_param() {
        let mut parser = OscParser::new();
        // OSC 10 = foreground color (ignored, param > 2)
        let results = parser.feed_slice(b"\x1b]10;#ffffff\x07");
        assert!(results.is_empty());
    }

    #[test]
    fn invalid_after_escape() {
        let mut parser = OscParser::new();
        // ESC followed by non-] character should reset
        let results = parser.feed_slice(b"\x1b[0m");
        assert!(results.is_empty());
        // Should still be able to parse valid sequence
        let results = parser.feed_slice(b"\x1b]0;Test\x07");
        assert_eq!(results, vec![OscResult::Title("Test".to_string())]);
    }

    #[test]
    fn invalid_osc_param_non_digit_start() {
        let mut parser = OscParser::new();
        // Non-digit character after ] should reset (in OscStart state)
        let results = parser.feed_slice(b"\x1b]X;Title\x07");
        assert!(results.is_empty());
    }

    #[test]
    fn invalid_osc_param_invalid_char() {
        let mut parser = OscParser::new();
        // Start parsing with digit, then send invalid char (tests OscParam state reset)
        let results = parser.feed_slice(b"\x1b]0X;Title\x07");
        assert!(results.is_empty());
        // Should recover
        let results = parser.feed_slice(b"\x1b]0;Valid\x07");
        assert_eq!(results, vec![OscResult::Title("Valid".to_string())]);
    }

    #[test]
    fn invalid_st_terminator() {
        let mut parser = OscParser::new();
        // ESC not followed by \ in string terminator should reset
        let results = parser.feed_slice(b"\x1b]0;Title\x1bX");
        assert!(results.is_empty());
        // Should recover
        let results = parser.feed_slice(b"\x1b]0;Valid\x07");
        assert_eq!(results, vec![OscResult::Title("Valid".to_string())]);
    }

    #[test]
    fn reset_method() {
        let mut parser = OscParser::new();
        // Start parsing
        let _ = parser.feed_slice(b"\x1b]0;Part");
        // Reset manually
        parser.reset();
        // Should start fresh
        let results = parser.feed_slice(b"ial\x07");
        assert!(results.is_empty());
    }
}