use std::collections::VecDeque;
use thiserror::Error;
use crate::notification::Notification;
use crate::parser::{Event, Parser, Reply};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CommandId(u64);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommandOutput {
pub lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum CommandError {
#[error("tmux command failed: {}", .lines.join("; "))]
Failed { lines: Vec<String> },
#[error("control session disconnected before reply")]
Disconnected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Incoming {
Notification(Notification),
Reply {
id: CommandId,
result: Result<CommandOutput, CommandError>,
},
}
#[derive(Debug, Default)]
pub struct Engine {
parser: Parser,
pending: VecDeque<CommandId>,
next_id: u64,
buf: Vec<u8>,
last_number: Option<u64>,
}
impl Engine {
pub fn new() -> Self {
Self::default()
}
pub fn register_command(&mut self) -> CommandId {
let id = CommandId(self.next_id);
self.next_id += 1;
self.pending.push_back(id);
id
}
pub fn feed(&mut self, bytes: &[u8]) -> Vec<Incoming> {
self.buf.extend_from_slice(bytes);
let mut out = Vec::new();
while let Some(newline) = self.buf.iter().position(|&b| b == b'\n') {
let mut line: Vec<u8> = self.buf.drain(..=newline).collect();
line.pop(); if let Some(incoming) = self.on_line(&line) {
out.push(incoming);
}
}
out
}
pub fn on_line(&mut self, line: &[u8]) -> Option<Incoming> {
match self.parser.push(line)? {
Event::Notification(notification) => Some(Incoming::Notification(notification)),
Event::Reply(reply) => self.correlate(reply),
}
}
fn correlate(&mut self, reply: Reply) -> Option<Incoming> {
if !reply.control {
return None;
}
debug_assert!(
self.last_number.is_none_or(|last| reply.number > last),
"control-reply numbers must strictly increase (FIFO desync): \
{} did not exceed {:?}",
reply.number,
self.last_number,
);
self.last_number = Some(reply.number);
let id = self.pending.pop_front()?;
let result = match reply.error {
false => Ok(CommandOutput {
lines: reply.output,
}),
true => Err(CommandError::Failed {
lines: reply.output,
}),
};
Some(Incoming::Reply { id, result })
}
pub fn on_eof(&mut self) -> Vec<Incoming> {
self.pending
.drain(..)
.map(|id| Incoming::Reply {
id,
result: Err(CommandError::Disconnected),
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::WindowId;
#[test]
fn notification_passes_through() {
let mut engine = Engine::new();
let incoming = engine.on_line(b"%sessions-changed");
assert_eq!(
incoming,
Some(Incoming::Notification(Notification::SessionsChanged))
);
}
#[test]
fn correlates_successful_reply() {
let mut engine = Engine::new();
let id = engine.register_command();
assert_eq!(engine.on_line(b"%begin 1 10 1"), None);
assert_eq!(engine.on_line(b"out"), None);
let done = engine.on_line(b"%end 1 10 1");
assert_eq!(
done,
Some(Incoming::Reply {
id,
result: Ok(CommandOutput {
lines: vec!["out".to_string()],
}),
})
);
}
#[test]
fn error_reply_resolves_to_err() {
let mut engine = Engine::new();
let id = engine.register_command();
engine.on_line(b"%begin 1 11 1");
engine.on_line(b"no such window");
let done = engine.on_line(b"%error 1 11 1");
assert_eq!(
done,
Some(Incoming::Reply {
id,
result: Err(CommandError::Failed {
lines: vec!["no such window".to_string()],
}),
})
);
}
#[test]
fn correlates_two_commands_in_fifo_order() {
let mut engine = Engine::new();
let first = engine.register_command();
let second = engine.register_command();
engine.on_line(b"%begin 1 20 1");
let a = engine.on_line(b"%end 1 20 1");
engine.on_line(b"%begin 1 21 1");
let b = engine.on_line(b"%end 1 21 1");
assert!(matches!(a, Some(Incoming::Reply { id, .. }) if id == first));
assert!(matches!(b, Some(Incoming::Reply { id, .. }) if id == second));
}
#[test]
fn server_internal_reply_does_not_consume_pending() {
let mut engine = Engine::new();
let id = engine.register_command();
engine.on_line(b"%begin 1 30 0");
let internal = engine.on_line(b"%end 1 30 0");
assert_eq!(internal, None);
engine.on_line(b"%begin 1 31 1");
let ours = engine.on_line(b"%end 1 31 1");
assert!(matches!(ours, Some(Incoming::Reply { id: got, .. }) if got == id));
}
#[test]
fn notification_interleaves_before_a_reply() {
let mut engine = Engine::new();
let id = engine.register_command();
let note = engine.on_line(b"%window-add @3");
assert_eq!(
note,
Some(Incoming::Notification(Notification::WindowAdd(WindowId(3))))
);
engine.on_line(b"%begin 1 40 1");
let done = engine.on_line(b"%end 1 40 1");
assert!(matches!(done, Some(Incoming::Reply { id: got, .. }) if got == id));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "strictly increase")]
fn non_increasing_reply_number_trips_desync() {
let mut engine = Engine::new();
engine.register_command();
engine.register_command();
engine.on_line(b"%begin 1 5 1");
engine.on_line(b"%end 1 5 1");
engine.on_line(b"%begin 1 5 1"); engine.on_line(b"%end 1 5 1");
}
#[test]
fn control_reply_without_pending_is_dropped() {
let mut engine = Engine::new();
engine.on_line(b"%begin 1 50 1");
let orphan = engine.on_line(b"%end 1 50 1");
assert_eq!(orphan, None);
}
#[test]
fn on_eof_drains_pending_as_disconnected() {
let mut engine = Engine::new();
let first = engine.register_command();
let second = engine.register_command();
let drained = engine.on_eof();
assert_eq!(
drained,
vec![
Incoming::Reply {
id: first,
result: Err(CommandError::Disconnected),
},
Incoming::Reply {
id: second,
result: Err(CommandError::Disconnected),
},
]
);
assert!(engine.on_eof().is_empty());
}
#[test]
fn reply_preserves_interior_blank_lines() {
let mut engine = Engine::new();
let id = engine.register_command();
let out = engine.feed(b"%begin 1 1 1\na\n\nb\n%end 1 1 1\n");
assert_eq!(
out,
vec![Incoming::Reply {
id,
result: Ok(CommandOutput {
lines: vec!["a".to_string(), String::new(), "b".to_string()],
}),
}]
);
}
#[test]
fn feed_frames_lines_across_chunk_boundaries() {
let mut engine = Engine::new();
assert!(engine.feed(b"%sessions-chan").is_empty()); let first = engine.feed(b"ged\n%output %1 ab");
assert_eq!(
first,
vec![Incoming::Notification(Notification::SessionsChanged)]
);
let second = engine.feed(&[0xff, b'\n']); assert_eq!(
second,
vec![Incoming::Notification(Notification::Output {
pane: crate::ids::PaneId(1),
bytes: vec![b'a', b'b', 0xff],
})]
);
}
}