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}