返回 DeepSeek-TUI-2026
line_buffer.rs
根目录 / crates / tui / src / tui / streaming / line_buffer.rs
1 //! Newline-boundary gate for streaming text.
2 //!
3 //! `LineBuffer` is an upstream-of-the-chunker safety layer that holds back any
4 //! text after the LAST `\n` until the next newline arrives. This prevents
5 //! partial multi-character markdown — most importantly partial code fences
6 //! (` ``` `) whose meaning flips depending on what follows on the same line —
7 //! from ever becoming visible state in the renderer.
8 //!
9 //! Mental model:
10 //! - `push(delta)` appends raw stream text to an internal pending buffer.
11 //! - `take_committable()` returns only the prefix up to and including the
12 //! LAST `\n` and clears that prefix. Whatever follows the last `\n` stays
13 //! in the buffer for the next push.
14 //! - `flush()` returns whatever is left, used at end-of-stream when the model
15 //! signals the turn is done. (The contract upstream of the chunker is that
16 //! only complete-line text is committed; `flush()` is the explicit escape
17 //! hatch when we know no more text will arrive.)
18 //!
19 //! See `cx5_chx5_newline_gate.md` in the task brief for full rationale.
20
21 /// Holds streaming text until a newline boundary is reached.
22 ///
23 /// This is upstream of [`StreamChunker`](super::commit_tick::StreamChunker)
24 /// in the streaming pipeline:
25 ///
26 /// ```text
27 /// raw delta -> LineBuffer.push -> take_committable -> StreamChunker.push_delta -> commit tick
28 /// ```
29 ///
30 /// The chunker also enforces a "drain-up-to-last-newline" rule on its pending
31 /// buffer, but `LineBuffer` exists as a *separate* layer so that:
32 /// 1. The contract is explicit and locally testable.
33 /// 2. Future downstream consumers (e.g. live preview that renders queued lines
34 /// optimistically) cannot accidentally see a partial fence.
35 /// 3. End-of-turn flush semantics are owned by the gate, not the policy.
36 #[derive(Debug, Default, Clone)]
37 pub struct LineBuffer {
38 /// Pending text not yet released because no terminating `\n` has been seen
39 /// since the last commit.
40 pending: String,
41 }
42
43 impl LineBuffer {
44 /// Create an empty buffer.
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 /// Append a raw delta.
50 pub fn push(&mut self, delta: &str) {
51 if delta.is_empty() {
52 return;
53 }
54 self.pending.push_str(delta);
55 }
56
57 /// Return the prefix of the pending buffer up to and including the LAST
58 /// `\n`. Whatever follows that newline (if anything) stays buffered.
59 ///
60 /// Returns an empty string when the buffer is empty or contains no
61 /// newline yet — callers can treat the empty-string case as "nothing
62 /// committable on this push".
63 pub fn take_committable(&mut self) -> String {
64 let Some(last_nl) = self.pending.rfind('\n') else {
65 return String::new();
66 };
67 // Drain everything up to and including the last newline. The remaining
68 // tail (post-newline) stays in `pending` and is concatenated with the
69 // next `push` before the next commit decision is made.
70 self.pending.drain(..=last_nl).collect()
71 }
72
73 /// Return whatever is left in the buffer, even if it is not newline
74 /// terminated. Used when the stream ends so we don't strand the final
75 /// partial line.
76 pub fn flush(&mut self) -> String {
77 std::mem::take(&mut self.pending)
78 }
79
80 /// Whether the buffer holds any uncommitted text.
81 pub fn is_empty(&self) -> bool {
82 self.pending.is_empty()
83 }
84
85 /// Length of the pending tail in bytes (testing/observability).
86 pub fn pending_len(&self) -> usize {
87 self.pending.len()
88 }
89
90 /// Reset the buffer (e.g. on stream restart).
91 pub fn reset(&mut self) {
92 self.pending.clear();
93 }
94 }
95
96 #[cfg(test)]
97 mod tests {
98 use super::*;
99
100 #[test]
101 fn push_without_newline_holds_everything() {
102 // Cornerstone invariant: nothing escapes the gate until a newline
103 // terminates the line. This is what protects partial code fences
104 // (e.g. ``` arriving in chunk N, language tag in chunk N+1).
105 let mut buf = LineBuffer::new();
106 buf.push("hello");
107 assert_eq!(buf.take_committable(), "");
108 assert_eq!(buf.pending_len(), 5);
109 assert!(!buf.is_empty());
110 }
111
112 #[test]
113 fn push_with_trailing_partial_returns_only_prefix() {
114 let mut buf = LineBuffer::new();
115 buf.push("hello\nwo");
116 assert_eq!(buf.take_committable(), "hello\n");
117 // Tail is held for next call.
118 assert_eq!(buf.pending_len(), 2);
119 assert!(!buf.is_empty());
120 }
121
122 #[test]
123 fn next_push_is_concatenated_with_held_tail() {
124 let mut buf = LineBuffer::new();
125 buf.push("hello\nwo");
126 assert_eq!(buf.take_committable(), "hello\n");
127 // The held "wo" is concatenated with "rld\n", and the whole line
128 // becomes committable.
129 buf.push("rld\n");
130 assert_eq!(buf.take_committable(), "world\n");
131 assert!(buf.is_empty());
132 }
133
134 #[test]
135 fn flush_returns_unterminated_tail() {
136 let mut buf = LineBuffer::new();
137 buf.push("trailing without newline");
138 // No newline → nothing committable.
139 assert_eq!(buf.take_committable(), "");
140 // End-of-stream flush returns it raw.
141 assert_eq!(buf.flush(), "trailing without newline");
142 assert!(buf.is_empty());
143 }
144
145 #[test]
146 fn flush_is_empty_when_buffer_drained() {
147 let mut buf = LineBuffer::new();
148 buf.push("a\n");
149 assert_eq!(buf.take_committable(), "a\n");
150 assert_eq!(buf.flush(), "");
151 }
152
153 #[test]
154 fn multi_line_burst_returns_prefix_through_last_newline() {
155 // Multiple newlines in one push: the entire prefix up through the
156 // last newline is committable in one go; only the unterminated tail
157 // is held.
158 let mut buf = LineBuffer::new();
159 buf.push("a\nb\nc\nd");
160 assert_eq!(buf.take_committable(), "a\nb\nc\n");
161 assert_eq!(buf.pending_len(), 1);
162 // Finishing "d" with a newline releases it on the next take.
163 buf.push("\n");
164 assert_eq!(buf.take_committable(), "d\n");
165 }
166
167 #[test]
168 fn partial_code_fence_never_escapes_the_gate() {
169 // Acceptance scenario from CX#5: a fenced code block whose opener
170 // arrives split across deltas must never expose "foo```rust" without
171 // a terminating newline. We assert that on every intermediate
172 // commit, the *committed* text either contains a newline or is empty
173 // — i.e. the pre-language partial fence never leaks.
174 let mut buf = LineBuffer::new();
175
176 // Chunk 1: a paragraph fragment ending with the fence opener.
177 buf.push("foo```");
178 let c1 = buf.take_committable();
179 assert!(
180 c1.is_empty() || c1.ends_with('\n'),
181 "partial fence leaked: {c1:?}"
182 );
183 assert!(
184 !c1.contains("foo```"),
185 "fence opener escaped without newline: {c1:?}"
186 );
187
188 // Chunk 2: language tag + start of body. The fence line is now
189 // newline-terminated, so it can commit; the post-newline body is
190 // held.
191 buf.push("rust\nlet x");
192 let c2 = buf.take_committable();
193 assert!(
194 c2.ends_with('\n'),
195 "expected newline-terminated commit: {c2:?}"
196 );
197 assert_eq!(c2, "foo```rust\n");
198
199 // Chunk 3: rest of body and the fence closer.
200 buf.push("= 1;\n```\n");
201 let c3 = buf.take_committable();
202 assert_eq!(c3, "let x= 1;\n```\n");
203 assert!(buf.is_empty());
204 }
205
206 #[test]
207 fn empty_push_is_a_noop() {
208 let mut buf = LineBuffer::new();
209 buf.push("");
210 assert!(buf.is_empty());
211 assert_eq!(buf.take_committable(), "");
212 }
213
214 #[test]
215 fn reset_clears_pending_tail() {
216 let mut buf = LineBuffer::new();
217 buf.push("partial");
218 assert_eq!(buf.pending_len(), 7);
219 buf.reset();
220 assert!(buf.is_empty());
221 assert_eq!(buf.flush(), "");
222 }
223 }
224
224 lines RUST