winx_code_agent/state/
line_ring.rs1use std::collections::VecDeque;
13
14#[derive(Debug)]
16pub struct LineRing {
17 lines: VecDeque<String>,
19 partial: String,
22 capacity: usize,
24 max_partial_bytes: usize,
26 dropped_lines: u64,
28 partial_clipped: bool,
31}
32
33impl LineRing {
34 pub fn new(capacity: usize, max_partial_bytes: usize) -> Self {
35 Self {
36 lines: VecDeque::with_capacity(capacity),
37 partial: String::new(),
38 capacity,
39 max_partial_bytes,
40 dropped_lines: 0,
41 partial_clipped: false,
42 }
43 }
44
45 pub fn push_chunk(&mut self, chunk: &str) {
48 let combined = if self.partial.is_empty() {
49 chunk.to_string()
50 } else {
51 let mut s = std::mem::take(&mut self.partial);
52 s.push_str(chunk);
53 s
54 };
55
56 let mut last_nl_end: Option<usize> = None;
57 for (idx, ch) in combined.char_indices() {
58 if ch == '\n' {
59 let end = idx + ch.len_utf8();
60 let start = last_nl_end.unwrap_or(0);
61 let line = combined[start..idx].trim_end_matches('\r').to_string();
64 if self.lines.len() == self.capacity {
65 self.lines.pop_front();
66 self.dropped_lines += 1;
67 }
68 self.lines.push_back(line);
69 last_nl_end = Some(end);
70 }
71 }
72
73 if let Some(end) = last_nl_end {
74 self.partial = combined[end..].to_string();
75 } else {
76 self.partial = combined;
77 }
78
79 if self.partial.len() > self.max_partial_bytes {
84 let cut = self.partial.len() - self.max_partial_bytes;
85 let cut = crate::utils::floor_char_boundary(&self.partial, cut);
86 self.partial.drain(..cut);
87 self.partial_clipped = true;
88 }
89 }
90
91 pub fn raw(&self, lines: usize) -> String {
94 if lines == 0 {
95 return String::new();
96 }
97 let start = self.lines.len().saturating_sub(lines);
98 let mut out = String::new();
99 for line in self.lines.iter().skip(start) {
100 out.push_str(line);
101 out.push('\n');
102 }
103 if !self.partial.is_empty() {
104 out.push_str(&self.partial);
105 }
106 out
107 }
108
109 pub fn scrollback_notice(&self, lines: usize) -> Option<String> {
113 let mut notes: Vec<String> = Vec::new();
114 if self.dropped_lines > 0 && lines >= self.lines.len() {
118 notes.push(format!(
119 "{} earlier line(s) dropped — scrollback keeps only the most recent {}",
120 self.dropped_lines, self.capacity
121 ));
122 }
123 if self.partial_clipped && lines > 0 {
124 notes.push(format!(
125 "a long unterminated line was clipped to its last {} bytes",
126 self.max_partial_bytes
127 ));
128 }
129 if notes.is_empty() {
130 None
131 } else {
132 Some(format!("[winx: {}]", notes.join("; ")))
133 }
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 fn feed_lines(ring: &mut LineRing, n: usize) {
142 for i in 0..n {
143 ring.push_chunk(&format!("line {i}\n"));
144 }
145 }
146
147 #[test]
148 fn push_chunk_splits_lines_and_keeps_partial() {
149 let mut ring = LineRing::new(10, 1024);
150 ring.push_chunk("a\nb\nc"); assert_eq!(ring.raw(10), "a\nb\nc");
152 ring.push_chunk("cc\n"); assert_eq!(ring.raw(10), "a\nb\nccc\n");
154 }
155
156 #[test]
157 fn no_notice_when_within_capacity() {
158 let mut ring = LineRing::new(2000, 1024);
159 feed_lines(&mut ring, 50);
160 assert_eq!(ring.scrollback_notice(2000), None);
161 }
162
163 #[test]
164 fn notice_when_older_lines_were_dropped() {
165 let mut ring = LineRing::new(100, 1024);
168 feed_lines(&mut ring, 250);
169 let notice = ring.scrollback_notice(100).unwrap_or_default();
170 assert!(notice.contains("150"), "notice should report 150 dropped lines: {notice}");
171 assert!(notice.to_lowercase().contains("scrollback"), "notice text: {notice}");
172 }
173
174 #[test]
175 fn notice_reports_clipped_partial_line() {
176 let mut ring = LineRing::new(100, 64);
177 ring.push_chunk(&"x".repeat(200));
179 let notice = ring.scrollback_notice(100).unwrap_or_default();
180 assert!(notice.to_lowercase().contains("clip"), "notice text: {notice}");
181 }
182}