ailloli_ui_terminal_pty/
batch.rs

1//! Time-, newline-, and threshold-triggered PTY output batching.
2
3use std::time::{Duration, Instant};
4
5use crate::PtyEvent;
6
7/// Flush thresholds for [`PtyOutputBatcher`].
8///
9/// `max_bytes` is a trigger, not a hard output bound: one pushed slice is
10/// appended whole before the threshold is checked, so a resulting event can be
11/// larger. `flush_timeout` measures inactivity since the latest non-empty push.
12///
13/// # Examples
14///
15/// ```
16/// use std::time::Duration;
17/// use ailloli_ui_terminal_pty::PtyBatchConfig;
18///
19/// let config = PtyBatchConfig { max_bytes: 1024, flush_timeout: Duration::from_millis(5) };
20/// assert_eq!(config.max_bytes, 1024);
21/// ```
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct PtyBatchConfig {
24    /// Pending-byte count at or above which a push flushes; zero clamps to one.
25    pub max_bytes: usize,
26    /// Minimum inactivity before [`PtyOutputBatcher::tick`] flushes.
27    ///
28    /// A zero duration makes the next tick immediately eligible.
29    pub flush_timeout: Duration,
30}
31
32impl Default for PtyBatchConfig {
33    /// Uses a 4,096-byte trigger and a 12-millisecond inactivity timeout.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use std::time::Duration;
39    /// use ailloli_ui_terminal_pty::PtyBatchConfig;
40    /// let config = PtyBatchConfig::default();
41    /// assert_eq!(config.max_bytes, 4_096);
42    /// assert_eq!(config.flush_timeout, Duration::from_millis(12));
43    /// ```
44    fn default() -> Self {
45        Self {
46            max_bytes: 4096,
47            flush_timeout: Duration::from_millis(12),
48        }
49    }
50}
51
52/// Single-threaded accumulator producing ordered [`PtyEvent::Output`] batches.
53///
54/// The batcher owns raw bytes and performs no decoding, parsing, redaction, or
55/// I/O. It records wall-clock instants internally, so timeout tests/callers must
56/// drive [`Self::tick`] using the real monotonic clock.
57///
58/// # Examples
59///
60/// ```
61/// use ailloli_ui_terminal_pty::{PtyEvent, PtyOutputBatcher};
62/// let mut batcher = PtyOutputBatcher::new();
63/// assert!(batcher.push(b"partial").is_empty());
64/// assert_eq!(batcher.flush(), Some(PtyEvent::Output(b"partial".to_vec())));
65/// ```
66#[derive(Debug)]
67pub struct PtyOutputBatcher {
68    /// Normalized trigger configuration.
69    config: PtyBatchConfig,
70    /// Bytes accumulated since the previous flush.
71    pending: Vec<u8>,
72    /// Time of the most recent non-empty push, or `None` when empty/flushed.
73    last_input_at: Option<Instant>,
74}
75
76impl PtyOutputBatcher {
77    /// Creates an empty batcher with [`PtyBatchConfig::default`].
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use ailloli_ui_terminal_pty::PtyOutputBatcher;
83    /// let mut batcher = PtyOutputBatcher::new();
84    /// assert_eq!(batcher.flush(), None);
85    /// ```
86    pub fn new() -> Self {
87        Self::with_config(PtyBatchConfig::default())
88    }
89
90    /// Creates an empty batcher, clamping `max_bytes` to at least one.
91    ///
92    /// The timeout is retained exactly, including zero. The normalized config is
93    /// private; behavior rather than configuration introspection exposes it.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use ailloli_ui_terminal_pty::{PtyBatchConfig, PtyEvent, PtyOutputBatcher};
99    /// let mut batcher = PtyOutputBatcher::with_config(PtyBatchConfig {
100    ///     max_bytes: 0,
101    ///     flush_timeout: std::time::Duration::ZERO,
102    /// });
103    /// assert_eq!(batcher.push(b"x"), vec![PtyEvent::Output(b"x".to_vec())]);
104    /// ```
105    pub fn with_config(mut config: PtyBatchConfig) -> Self {
106        config.max_bytes = config.max_bytes.max(1);
107        Self {
108            config,
109            pending: Vec::new(),
110            last_input_at: None,
111        }
112    }
113
114    /// Appends a raw chunk and returns zero or one flushed output event.
115    ///
116    /// An empty slice is a complete no-op and does not reset the inactivity
117    /// timer. A non-empty slice flushes the entire pending buffer when its final
118    /// length reaches the byte trigger or the **new slice** contains any newline.
119    /// Bytes after that newline remain in the same event; input is never split.
120    /// Otherwise the timer is reset to now and an empty vector is returned.
121    ///
122    /// # Panics
123    ///
124    /// Growing the pending buffer can panic or abort if allocation fails.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use ailloli_ui_terminal_pty::{PtyEvent, PtyOutputBatcher};
130    /// let mut batcher = PtyOutputBatcher::new();
131    /// assert!(batcher.push(b"a").is_empty());
132    /// assert_eq!(batcher.push(b"b\nc"), vec![PtyEvent::Output(b"ab\nc".to_vec())]);
133    /// ```
134    pub fn push(&mut self, bytes: &[u8]) -> Vec<PtyEvent> {
135        if bytes.is_empty() {
136            return Vec::new();
137        }
138        self.pending.extend_from_slice(bytes);
139        self.last_input_at = Some(Instant::now());
140        if self.pending.len() >= self.config.max_bytes || bytes.contains(&b'\n') {
141            self.flush().into_iter().collect()
142        } else {
143            Vec::new()
144        }
145    }
146
147    /// Flushes pending bytes once the configured inactivity timeout has elapsed.
148    ///
149    /// Returns `None` when empty or still inside the timeout. A successful flush
150    /// resets the internal timer.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use std::time::Duration;
156    /// use ailloli_ui_terminal_pty::{PtyBatchConfig, PtyEvent, PtyOutputBatcher};
157    /// let mut batcher = PtyOutputBatcher::with_config(PtyBatchConfig {
158    ///     max_bytes: 10,
159    ///     flush_timeout: Duration::ZERO,
160    /// });
161    /// assert!(batcher.push(b"x").is_empty());
162    /// assert_eq!(batcher.tick(), Some(PtyEvent::Output(b"x".to_vec())));
163    /// ```
164    pub fn tick(&mut self) -> Option<PtyEvent> {
165        if self.pending.is_empty() {
166            return None;
167        }
168        let last_input_at = self.last_input_at?;
169        if last_input_at.elapsed() >= self.config.flush_timeout {
170            self.flush()
171        } else {
172            None
173        }
174    }
175
176    /// Immediately moves all pending bytes into one output event.
177    ///
178    /// Returns `None` when empty. The replacement pending vector has no promised
179    /// retained capacity, and the inactivity timestamp is cleared.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use ailloli_ui_terminal_pty::{PtyEvent, PtyOutputBatcher};
185    /// let mut batcher = PtyOutputBatcher::new(); batcher.push(b"raw");
186    /// assert_eq!(batcher.flush(), Some(PtyEvent::Output(b"raw".to_vec())));
187    /// assert_eq!(batcher.flush(), None);
188    /// ```
189    pub fn flush(&mut self) -> Option<PtyEvent> {
190        if self.pending.is_empty() {
191            return None;
192        }
193        self.last_input_at = None;
194        Some(PtyEvent::Output(std::mem::take(&mut self.pending)))
195    }
196}
197
198impl Default for PtyOutputBatcher {
199    /// Creates the same empty batcher as [`Self::new`].
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use ailloli_ui_terminal_pty::PtyOutputBatcher;
205    /// assert_eq!(PtyOutputBatcher::default().flush(), None);
206    /// ```
207    fn default() -> Self {
208        Self::new()
209    }
210}