use std::collections::HashMap;
use std::collections::VecDeque;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use super::screen::RegionScreen;
const TILE_INDENT: &str = " ";
#[derive(Debug, Clone, Copy)]
pub struct LiveConfig {
pub rows: usize,
pub cols: usize,
pub scrollback: usize,
}
impl Default for LiveConfig {
fn default() -> Self {
Self {
rows: 5,
cols: 80,
scrollback: 200,
}
}
}
impl LiveConfig {
#[must_use]
pub fn content_cols(&self) -> usize {
self.cols.saturating_sub(TILE_INDENT.len()).max(1)
}
}
struct Region {
bar: ProgressBar,
screen: RegionScreen,
label: String,
retained: VecDeque<String>,
}
pub struct LiveConsole {
multi: MultiProgress,
header: ProgressBar,
regions: HashMap<String, Region>,
config: LiveConfig,
}
impl LiveConsole {
#[must_use]
pub fn to_stderr(config: LiveConfig) -> Self {
Self::with_target(ProgressDrawTarget::stderr(), config)
}
#[must_use]
pub fn hidden(config: LiveConfig) -> Self {
Self::with_target(ProgressDrawTarget::hidden(), config)
}
fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
config.rows = config.rows.max(1);
config.cols = config.cols.max(1);
let multi = MultiProgress::with_draw_target(target);
let header = multi.add(ProgressBar::new(0));
header.set_style(message_style());
Self {
multi,
header,
regions: HashMap::new(),
config,
}
}
#[must_use]
pub fn content_cols(&self) -> usize {
self.config.content_cols()
}
pub fn set_header(&self, text: impl Into<String>) {
self.header.set_message(text.into());
}
pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
let id = id.into();
let label = label.into();
if let Some(old) = self.regions.remove(&id) {
old.bar.finish_and_clear();
self.multi.remove(&old.bar);
}
let bar = self.multi.add(ProgressBar::new(0));
bar.set_style(message_style());
let region = Region {
bar,
screen: RegionScreen::new(self.config.rows, self.content_cols()),
label,
retained: VecDeque::new(),
};
region.bar.set_message(render_tile(
®ion.label,
&[] as &[&str],
self.config.cols,
self.config.rows,
));
self.regions.insert(id, region);
}
pub fn feed(&mut self, id: &str, bytes: &[u8]) {
let scrollback = self.config.scrollback;
let Some(region) = self.regions.get_mut(id) else {
return;
};
for line in region.screen.feed(bytes) {
region.retained.push_back(line);
while region.retained.len() > scrollback {
region.retained.pop_front();
}
}
let visible = region.screen.render();
region.bar.set_message(render_tile(
®ion.label,
&visible,
self.config.cols,
self.config.rows,
));
}
pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
if let Some(region) = self.regions.remove(id) {
region.bar.finish_and_clear();
self.multi.remove(®ion.bar);
}
self.multi.println(verdict.as_ref())
}
pub fn finish_with_replay(
&mut self,
id: &str,
verdict: impl AsRef<str>,
) -> std::io::Result<()> {
if let Some(region) = self.regions.remove(id) {
let Region {
bar,
screen,
label,
retained,
} = region;
for line in replay_body(&retained, screen.drain()) {
let prefixed = scrollback_line(&label, &line);
self.multi.println(truncate(&prefixed, self.config.cols))?;
}
bar.finish_and_clear();
self.multi.remove(&bar);
}
self.multi.println(verdict.as_ref())
}
pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
self.multi.println(line.as_ref())
}
pub fn clear(&mut self) -> std::io::Result<()> {
for (_, region) in self.regions.drain() {
region.bar.finish_and_clear();
self.multi.remove(®ion.bar);
}
self.header.set_message("");
self.multi.clear()
}
#[cfg(test)]
fn retained(&self, id: &str) -> Option<Vec<String>> {
self.regions
.get(id)
.map(|region| region.retained.iter().cloned().collect())
}
}
fn message_style() -> ProgressStyle {
ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
}
fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
let header = format!("{}", console::style(format!("• {label}")).bold());
let mut out = truncate(&header, cols);
for index in 0..rows {
out.push('\n');
let line = lines.get(index).map_or("", AsRef::as_ref);
out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
}
out
}
fn scrollback_line(label: &str, line: &str) -> String {
format!("{} {line}", console::style(format!("{label} │")).dim())
}
fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
let mut lines: Vec<String> = retained.iter().cloned().collect();
lines.extend(on_screen);
while lines.last().is_some_and(String::is_empty) {
lines.pop();
}
lines
}
fn truncate(line: &str, width: usize) -> String {
if width == 0 {
return line.to_string();
}
console::truncate_str(line, width, "…").into_owned()
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
fn config(rows: usize, cols: usize) -> LiveConfig {
LiveConfig {
rows,
cols,
..LiveConfig::default()
}
}
#[test]
fn content_cols_reserves_the_indent_and_never_underflows() {
assert_eq!(config(6, 80).content_cols(), 78);
assert_eq!(config(6, 1).content_cols(), 1);
assert_eq!(config(6, 0).content_cols(), 1);
}
#[test]
fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
let mut console = LiveConsole::hidden(config(2, 40));
console.set_header("wave 1/2 · running 1");
console.begin("u1", "rust:core#test");
console.feed("u1", b"compiling\r\n");
console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
console.finish("u1", "ok rust:core#test")?;
console.clear()
}
#[test]
fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
let mut console = LiveConsole::hidden(LiveConfig::default());
console.begin("u1", "first");
console.feed("u1", b"old\n");
console.begin("u1", "second");
console.feed("u1", b"new\n");
console.finish("u1", "ok")
}
#[test]
fn console_is_reusable_after_clear() -> std::io::Result<()> {
let mut console = LiveConsole::hidden(LiveConfig::default());
console.set_header("first pass");
console.begin("u1", "task");
console.feed("u1", b"partial output\n");
console.clear()?;
console.set_header("second pass");
console.begin("u1", "task");
console.feed("u1", b"more\n");
console.finish("u1", "ok")
}
#[test]
fn zero_rows_still_renders_content() -> std::io::Result<()> {
let mut console = LiveConsole::hidden(config(0, 40));
console.begin("u1", "task");
console.feed("u1", b"visible line\r\n");
console.finish("u1", "ok")
}
#[test]
fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
let mut console = LiveConsole::hidden(LiveConfig::default());
console.feed("ghost", b"noise\n");
console.finish("ghost", "done")
}
#[test]
fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
let console = LiveConsole::hidden(LiveConfig::default());
console.note("standalone line")
}
#[test]
fn scrolled_rows_are_retained_for_replay_not_lost() {
let mut console = LiveConsole::hidden(LiveConfig {
rows: 2,
cols: 20,
scrollback: 200,
});
console.begin("u1", "task");
console.feed("u1", b"l1\nl2\nl3\nl4\n");
assert_eq!(
console.retained("u1"),
Some(vec!["l1".into(), "l2".into(), "l3".into()])
);
}
#[test]
fn retention_ring_is_bounded_under_a_long_feed() {
let mut console = LiveConsole::hidden(LiveConfig {
rows: 2,
cols: 20,
scrollback: 3,
});
console.begin("u1", "task");
for _ in 0..50 {
console.feed("u1", b"line\n");
}
assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
}
#[test]
fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
}
#[test]
fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
let mut console = LiveConsole::hidden(config(2, 20));
console.begin("ok", "task");
console.feed("ok", b"noise\n");
console.finish("ok", "ok task")?;
assert!(console.retained("ok").is_none());
console.begin("bad", "task");
console.feed("bad", b"panic!\n");
console.finish_with_replay("bad", "failed task")?;
assert!(console.retained("bad").is_none());
Ok(())
}
#[test]
fn render_tile_labels_and_indents_lines() {
let tile = render_tile("core", &["a", "b"], 0, 2);
let stripped = console::strip_ansi_codes(&tile);
assert_eq!(stripped, "• core\n a\n b");
}
#[test]
fn render_tile_pads_to_fixed_height() {
let tile = render_tile("core", &["only"], 0, 3);
let stripped = console::strip_ansi_codes(&tile);
assert_eq!(stripped, "• core\n only\n \n ");
}
#[test]
fn render_tile_truncates_header_and_content_to_width() {
let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
for line in console::strip_ansi_codes(&tile).lines() {
assert!(console::measure_text_width(line) <= 8);
}
}
#[test]
fn scrollback_line_prefixes_with_label() {
let line = scrollback_line("core", "hello");
let stripped = console::strip_ansi_codes(&line);
assert_eq!(stripped, "core │ hello");
}
#[test]
fn replay_line_clamped_to_width_never_wraps() {
let content = "x".repeat(200);
let composed = scrollback_line("rust@rust:core#test", &content);
let clamped = truncate(&composed, 140);
assert!(console::measure_text_width(&clamped) <= 140);
}
#[test]
fn truncate_respects_width_and_passthrough() {
assert_eq!(truncate("hello world", 0), "hello world");
let cut = truncate("hello world", 5);
assert!(console::measure_text_width(&cut) <= 5);
}
}