1use std::collections::VecDeque;
4use std::ops::RangeInclusive;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8pub struct ScreenCaptureRange {
9 pub start: Option<i64>,
11 pub end: Option<i64>,
13 pub start_is_absolute: bool,
15 pub end_is_absolute: bool,
17}
18
19impl ScreenCaptureRange {
20 #[must_use]
22 pub const fn new(start: Option<i64>, end: Option<i64>) -> Self {
23 Self {
24 start,
25 end,
26 start_is_absolute: false,
27 end_is_absolute: false,
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Transcript {
35 lines: VecDeque<String>,
36 limit: usize,
37}
38
39impl Transcript {
40 #[must_use]
42 pub fn new(limit: usize) -> Self {
43 Self {
44 lines: VecDeque::new(),
45 limit,
46 }
47 }
48
49 #[must_use]
51 pub const fn limit(&self) -> usize {
52 self.limit
53 }
54
55 #[must_use]
57 pub fn line_count(&self) -> usize {
58 self.lines.len()
59 }
60
61 pub fn set_limit(&mut self, limit: usize) {
63 self.limit = limit;
64 self.enforce_limit();
65 }
66
67 pub fn append_line(&mut self, line: impl Into<String>) {
69 if self.limit == 0 {
70 return;
71 }
72
73 self.lines.push_back(line.into());
74 self.enforce_limit();
75 }
76
77 #[must_use]
79 pub const fn lines(&self) -> &VecDeque<String> {
80 &self.lines
81 }
82
83 #[must_use]
91 pub fn capture(&self, start: Option<i64>, end: Option<i64>) -> Vec<u8> {
92 let Some(range) = resolve_relative_capture_range(start, end, self.lines.len()) else {
93 return Vec::new();
94 };
95
96 let mut output = Vec::new();
97 for (index, line) in self.lines.iter().enumerate() {
98 if index < *range.start() {
99 continue;
100 }
101 if index > *range.end() {
102 break;
103 }
104 output.extend_from_slice(line.as_bytes());
105 output.push(b'\n');
106 }
107 output
108 }
109
110 #[must_use]
112 pub fn byte_size(&self) -> usize {
113 self.lines.iter().map(|line| line.len() + 1).sum()
114 }
115
116 fn enforce_limit(&mut self) {
117 while self.lines.len() > self.limit {
118 self.lines.pop_front();
119 }
120 }
121}
122
123impl Default for Transcript {
124 fn default() -> Self {
125 Self::new(2000)
126 }
127}
128
129#[cfg_attr(not(test), allow(dead_code))]
131#[must_use]
132pub fn capture_screen_lines<'a>(
133 lines: impl IntoIterator<Item = &'a str>,
134 line_count: usize,
135 history_size: usize,
136 range: ScreenCaptureRange,
137) -> Vec<u8> {
138 let Some(range) = resolve_screen_capture_range(range, history_size, line_count) else {
139 return Vec::new();
140 };
141
142 let mut output = Vec::new();
143 for (index, line) in lines.into_iter().enumerate() {
144 if index < *range.start() {
145 continue;
146 }
147 if index > *range.end() {
148 break;
149 }
150 output.extend_from_slice(line.as_bytes());
151 output.push(b'\n');
152 }
153 output
154}
155
156#[must_use]
158pub fn resolve_screen_capture_range(
159 range: ScreenCaptureRange,
160 history_size: usize,
161 total_lines: usize,
162) -> Option<RangeInclusive<usize>> {
163 if total_lines == 0 {
164 return None;
165 }
166
167 let last_line = total_lines - 1;
168 let default_top = history_size.min(last_line);
169 let mut top = if range.start_is_absolute {
170 0
171 } else {
172 resolve_screen_bound(range.start, default_top, history_size, last_line)
173 };
174 let mut bottom = if range.end_is_absolute {
175 last_line
176 } else {
177 resolve_screen_bound(range.end, last_line, history_size, last_line)
178 };
179 if bottom < top {
180 std::mem::swap(&mut top, &mut bottom);
181 }
182 Some(top..=bottom)
183}
184
185fn resolve_screen_bound(
186 bound: Option<i64>,
187 default: usize,
188 history_size: usize,
189 last_line: usize,
190) -> usize {
191 let Some(bound) = bound else {
192 return default;
193 };
194 if bound >= 0 {
195 return history_size
196 .saturating_add(usize::try_from(bound).unwrap_or(usize::MAX))
197 .min(last_line);
198 }
199
200 let magnitude = usize::try_from(bound.unsigned_abs()).unwrap_or(usize::MAX);
201 if magnitude > history_size {
202 0
203 } else {
204 history_size.saturating_sub(magnitude).min(last_line)
205 }
206}
207
208fn resolve_relative_capture_range(
209 start: Option<i64>,
210 end: Option<i64>,
211 len: usize,
212) -> Option<RangeInclusive<usize>> {
213 if len == 0 {
214 return None;
215 }
216
217 let mut start = resolve_relative_bound(start, len, 0)?;
218 let mut end = resolve_relative_bound(end, len, len.saturating_sub(1))?;
219 if start > end {
220 std::mem::swap(&mut start, &mut end);
221 }
222 Some(start..=end)
223}
224
225fn resolve_relative_bound(bound: Option<i64>, len: usize, default: usize) -> Option<usize> {
226 let bound = match bound {
227 Some(bound) => bound,
228 None => return Some(default),
229 };
230
231 if bound >= 0 {
232 return usize::try_from(bound).ok().map(|index| index.min(len - 1));
233 }
234
235 let from_newest = bound.unsigned_abs();
236 let len = u64::try_from(len).ok()?;
237 if from_newest > len {
238 Some(0)
239 } else {
240 usize::try_from(len - from_newest).ok()
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::{
247 capture_screen_lines, resolve_screen_capture_range, ScreenCaptureRange, Transcript,
248 };
249
250 #[test]
251 fn empty_transcript_captures_empty_output() {
252 let transcript = Transcript::new(10);
253
254 assert!(transcript.capture(None, None).is_empty());
255 }
256
257 #[test]
258 fn single_line_capture_includes_trailing_newline() {
259 let mut transcript = Transcript::new(10);
260 transcript.append_line("only");
261
262 assert_eq!(transcript.capture(None, None), b"only\n");
263 }
264
265 #[test]
266 fn capture_clamps_out_of_range_boundaries() {
267 let transcript = transcript(["zero", "one", "two"]);
268
269 assert_eq!(transcript.capture(Some(-99), Some(99)), b"zero\none\ntwo\n");
270 }
271
272 #[test]
273 fn negative_indices_count_back_from_newest_line() {
274 let transcript = transcript(["zero", "one", "two", "three"]);
275
276 assert_eq!(transcript.capture(Some(-2), Some(-1)), b"two\nthree\n");
277 assert_eq!(transcript.capture(Some(-3), Some(-2)), b"one\ntwo\n");
278 }
279
280 #[test]
281 fn reversed_relative_ranges_are_swapped() {
282 let transcript = transcript(["zero", "one", "two"]);
283
284 assert_eq!(transcript.capture(Some(2), Some(1)), b"one\ntwo\n");
285 assert_eq!(transcript.capture(Some(-1), Some(-2)), b"one\ntwo\n");
286 }
287
288 #[test]
289 fn exact_history_limit_evicts_oldest_lines() {
290 let mut transcript = Transcript::new(3);
291 transcript.append_line("zero");
292 transcript.append_line("one");
293 transcript.append_line("two");
294 transcript.append_line("three");
295
296 assert_eq!(
297 transcript.lines().iter().collect::<Vec<_>>(),
298 vec!["one", "two", "three"]
299 );
300 assert_eq!(transcript.capture(None, None), b"one\ntwo\nthree\n");
301 }
302
303 #[test]
304 fn screen_range_defaults_to_visible_rows() {
305 let lines = ["h0", "h1", "v0", "v1"];
306 let range = ScreenCaptureRange::default();
307
308 assert_eq!(
309 capture_screen_lines(lines.iter().copied(), lines.len(), 2, range),
310 b"v0\nv1\n"
311 );
312 }
313
314 #[test]
315 fn screen_range_dash_captures_full_history_and_visible_rows() {
316 let lines = ["h0", "h1", "v0", "v1"];
317 let range = ScreenCaptureRange {
318 start_is_absolute: true,
319 end_is_absolute: true,
320 ..ScreenCaptureRange::default()
321 };
322
323 assert_eq!(
324 capture_screen_lines(lines.iter().copied(), lines.len(), 2, range),
325 b"h0\nh1\nv0\nv1\n"
326 );
327 }
328
329 #[test]
330 fn screen_range_negative_values_are_relative_to_history_size() {
331 let range = resolve_screen_capture_range(ScreenCaptureRange::new(Some(-1), Some(0)), 2, 4)
332 .expect("range exists");
333 assert_eq!(range, 1..=2);
334 }
335
336 #[test]
337 fn screen_range_swaps_reversed_bounds() {
338 let range = resolve_screen_capture_range(ScreenCaptureRange::new(Some(1), Some(-1)), 2, 4)
339 .expect("range exists");
340 assert_eq!(range, 1..=3);
341 }
342
343 fn transcript(lines: impl IntoIterator<Item = &'static str>) -> Transcript {
344 let mut transcript = Transcript::new(10);
345 for line in lines {
346 transcript.append_line(line);
347 }
348 transcript
349 }
350}