use std::collections::VecDeque;
#[derive(Debug)]
pub struct LineRing {
lines: VecDeque<String>,
partial: String,
capacity: usize,
max_partial_bytes: usize,
dropped_lines: u64,
partial_clipped: bool,
}
impl LineRing {
pub fn new(capacity: usize, max_partial_bytes: usize) -> Self {
Self {
lines: VecDeque::with_capacity(capacity),
partial: String::new(),
capacity,
max_partial_bytes,
dropped_lines: 0,
partial_clipped: false,
}
}
pub fn push_chunk(&mut self, chunk: &str) {
let combined = if self.partial.is_empty() {
chunk.to_string()
} else {
let mut s = std::mem::take(&mut self.partial);
s.push_str(chunk);
s
};
let mut last_nl_end: Option<usize> = None;
for (idx, ch) in combined.char_indices() {
if ch == '\n' {
let end = idx + ch.len_utf8();
let start = last_nl_end.unwrap_or(0);
let line = combined[start..idx].trim_end_matches('\r').to_string();
if self.lines.len() == self.capacity {
self.lines.pop_front();
self.dropped_lines += 1;
}
self.lines.push_back(line);
last_nl_end = Some(end);
}
}
if let Some(end) = last_nl_end {
self.partial = combined[end..].to_string();
} else {
self.partial = combined;
}
if self.partial.len() > self.max_partial_bytes {
let cut = self.partial.len() - self.max_partial_bytes;
let cut = crate::utils::floor_char_boundary(&self.partial, cut);
self.partial.drain(..cut);
self.partial_clipped = true;
}
}
pub fn raw(&self, lines: usize) -> String {
if lines == 0 {
return String::new();
}
let start = self.lines.len().saturating_sub(lines);
let mut out = String::new();
for line in self.lines.iter().skip(start) {
out.push_str(line);
out.push('\n');
}
if !self.partial.is_empty() {
out.push_str(&self.partial);
}
out
}
pub fn scrollback_notice(&self, lines: usize) -> Option<String> {
let mut notes: Vec<String> = Vec::new();
if self.dropped_lines > 0 && lines >= self.lines.len() {
notes.push(format!(
"{} earlier line(s) dropped — scrollback keeps only the most recent {}",
self.dropped_lines, self.capacity
));
}
if self.partial_clipped && lines > 0 {
notes.push(format!(
"a long unterminated line was clipped to its last {} bytes",
self.max_partial_bytes
));
}
if notes.is_empty() {
None
} else {
Some(format!("[winx: {}]", notes.join("; ")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_lines(ring: &mut LineRing, n: usize) {
for i in 0..n {
ring.push_chunk(&format!("line {i}\n"));
}
}
#[test]
fn push_chunk_splits_lines_and_keeps_partial() {
let mut ring = LineRing::new(10, 1024);
ring.push_chunk("a\nb\nc"); assert_eq!(ring.raw(10), "a\nb\nc");
ring.push_chunk("cc\n"); assert_eq!(ring.raw(10), "a\nb\nccc\n");
}
#[test]
fn no_notice_when_within_capacity() {
let mut ring = LineRing::new(2000, 1024);
feed_lines(&mut ring, 50);
assert_eq!(ring.scrollback_notice(2000), None);
}
#[test]
fn notice_when_older_lines_were_dropped() {
let mut ring = LineRing::new(100, 1024);
feed_lines(&mut ring, 250);
let notice = ring.scrollback_notice(100).unwrap_or_default();
assert!(notice.contains("150"), "notice should report 150 dropped lines: {notice}");
assert!(notice.to_lowercase().contains("scrollback"), "notice text: {notice}");
}
#[test]
fn notice_reports_clipped_partial_line() {
let mut ring = LineRing::new(100, 64);
ring.push_chunk(&"x".repeat(200));
let notice = ring.scrollback_notice(100).unwrap_or_default();
assert!(notice.to_lowercase().contains("clip"), "notice text: {notice}");
}
}