1use std::collections::HashMap;
19use std::collections::VecDeque;
20
21use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
22
23use super::screen::RegionScreen;
24
25const TILE_INDENT: &str = " ";
30
31#[derive(Debug, Clone, Copy)]
38pub struct LiveConfig {
39 pub rows: usize,
45 pub cols: usize,
50 pub scrollback: usize,
56}
57
58impl Default for LiveConfig {
59 fn default() -> Self {
60 Self {
61 rows: 5,
62 cols: 80,
63 scrollback: 200,
64 }
65 }
66}
67
68impl LiveConfig {
69 #[must_use]
79 pub fn content_cols(&self) -> usize {
80 self.cols.saturating_sub(TILE_INDENT.len()).max(1)
81 }
82}
83
84struct Region {
88 bar: ProgressBar,
89 screen: RegionScreen,
90 label: String,
91 retained: VecDeque<String>,
92 high_water: usize,
97}
98
99pub struct LiveConsole {
106 multi: MultiProgress,
107 header: ProgressBar,
108 regions: HashMap<String, Region>,
109 config: LiveConfig,
110}
111
112impl LiveConsole {
113 #[must_use]
118 pub fn to_stderr(config: LiveConfig) -> Self {
119 Self::with_target(ProgressDrawTarget::stderr(), config)
120 }
121
122 #[must_use]
125 pub fn hidden(config: LiveConfig) -> Self {
126 Self::with_target(ProgressDrawTarget::hidden(), config)
127 }
128
129 fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
130 config.rows = config.rows.max(1);
133 config.cols = config.cols.max(1);
134 let multi = MultiProgress::with_draw_target(target);
135 let header = multi.add(ProgressBar::new(0));
136 header.set_style(message_style());
137 Self {
138 multi,
139 header,
140 regions: HashMap::new(),
141 config,
142 }
143 }
144
145 #[must_use]
150 pub fn content_cols(&self) -> usize {
151 self.config.content_cols()
152 }
153
154 pub fn set_header(&self, text: impl Into<String>) {
156 self.header.set_message(text.into());
157 }
158
159 pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
165 let id = id.into();
166 let label = label.into();
167 if let Some(old) = self.regions.remove(&id) {
168 old.bar.finish_and_clear();
169 self.multi.remove(&old.bar);
170 }
171 let bar = self.multi.add(ProgressBar::new(0));
172 bar.set_style(message_style());
173 let region = Region {
174 bar,
175 screen: RegionScreen::new(self.config.rows, self.content_cols()),
176 label,
177 retained: VecDeque::new(),
178 high_water: 0,
179 };
180 region.bar.set_message(render_tile(
184 ®ion.label,
185 &[] as &[&str],
186 self.config.cols,
187 0,
188 ));
189 self.regions.insert(id, region);
190 }
191
192 pub fn feed(&mut self, id: &str, bytes: &[u8]) {
199 let scrollback = self.config.scrollback;
200 let Some(region) = self.regions.get_mut(id) else {
201 return;
202 };
203 for line in region.screen.feed(bytes) {
204 region.retained.push_back(line);
205 while region.retained.len() > scrollback {
206 region.retained.pop_front();
207 }
208 }
209 let visible = region.screen.render();
210 let filled = content_height(&visible);
215 region.high_water = region.high_water.max(filled).min(self.config.rows);
216 region.bar.set_message(render_tile(
217 ®ion.label,
218 &visible,
219 self.config.cols,
220 region.high_water,
221 ));
222 }
223
224 pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
232 if let Some(region) = self.regions.remove(id) {
233 region.bar.finish_and_clear();
234 self.multi.remove(®ion.bar);
235 }
236 self.multi.println(verdict.as_ref())
237 }
238
239 pub fn finish_with_replay(
249 &mut self,
250 id: &str,
251 verdict: impl AsRef<str>,
252 ) -> std::io::Result<Vec<String>> {
253 let mut body = Vec::new();
254 if let Some(region) = self.regions.remove(id) {
255 let Region {
256 bar,
257 screen,
258 label,
259 retained,
260 high_water: _,
261 } = region;
262 for line in replay_body(&retained, screen.drain()) {
263 let prefixed = scrollback_line(&label, &line);
264 self.multi.println(truncate(&prefixed, self.config.cols))?;
265 body.push(line);
266 }
267 bar.finish_and_clear();
268 self.multi.remove(&bar);
269 }
270 self.multi.println(verdict.as_ref())?;
271 Ok(body)
272 }
273
274 pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
280 self.multi.println(line.as_ref())
281 }
282
283 pub fn clear(&mut self) -> std::io::Result<()> {
290 for (_, region) in self.regions.drain() {
291 region.bar.finish_and_clear();
292 self.multi.remove(®ion.bar);
293 }
294 self.header.set_message("");
295 self.multi.clear()
296 }
297
298 #[cfg(test)]
300 fn retained(&self, id: &str) -> Option<Vec<String>> {
301 self.regions
302 .get(id)
303 .map(|region| region.retained.iter().cloned().collect())
304 }
305
306 #[cfg(test)]
309 fn tile_height(&self, id: &str) -> Option<usize> {
310 self.regions.get(id).map(|region| region.high_water + 1)
311 }
312}
313
314fn message_style() -> ProgressStyle {
316 ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
317}
318
319fn content_height<S: AsRef<str>>(lines: &[S]) -> usize {
323 lines
324 .iter()
325 .rposition(|line| !line.as_ref().is_empty())
326 .map_or(0, |index| index + 1)
327}
328
329fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
336 let header = format!("{}", console::style(format!("• {label}")).bold());
337 let mut out = truncate(&header, cols);
338 for index in 0..rows {
339 out.push('\n');
340 let line = lines.get(index).map_or("", AsRef::as_ref);
341 out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
342 }
343 out
344}
345
346fn scrollback_line(label: &str, line: &str) -> String {
348 format!("{} {line}", console::style(format!("{label} │")).dim())
349}
350
351fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
355 let mut lines: Vec<String> = retained.iter().cloned().collect();
356 lines.extend(on_screen);
357 while lines.last().is_some_and(String::is_empty) {
358 lines.pop();
359 }
360 lines
361}
362
363fn truncate(line: &str, width: usize) -> String {
366 if width == 0 {
367 return line.to_string();
368 }
369 console::truncate_str(line, width, "…").into_owned()
370}
371
372#[cfg(test)]
373mod tests {
374 use std::collections::VecDeque;
375
376 use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
377
378 fn config(rows: usize, cols: usize) -> LiveConfig {
379 LiveConfig {
380 rows,
381 cols,
382 ..LiveConfig::default()
383 }
384 }
385
386 #[test]
387 fn content_cols_reserves_the_indent_and_never_underflows() {
388 assert_eq!(config(6, 80).content_cols(), 78);
389 assert_eq!(config(6, 1).content_cols(), 1);
391 assert_eq!(config(6, 0).content_cols(), 1);
392 }
393
394 #[test]
395 fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
396 let mut console = LiveConsole::hidden(config(2, 40));
397 console.set_header("wave 1/2 · running 1");
398 console.begin("u1", "rust:core#test");
399 console.feed("u1", b"compiling\r\n");
400 console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
401 console.finish("u1", "ok rust:core#test")?;
402 console.clear()
403 }
404
405 #[test]
406 fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
407 let mut console = LiveConsole::hidden(LiveConfig::default());
408 console.begin("u1", "first");
409 console.feed("u1", b"old\n");
410 console.begin("u1", "second");
411 console.feed("u1", b"new\n");
412 console.finish("u1", "ok")
413 }
414
415 #[test]
416 fn console_is_reusable_after_clear() -> std::io::Result<()> {
417 let mut console = LiveConsole::hidden(LiveConfig::default());
418 console.set_header("first pass");
419 console.begin("u1", "task");
420 console.feed("u1", b"partial output\n");
421 console.clear()?;
422 console.set_header("second pass");
423 console.begin("u1", "task");
424 console.feed("u1", b"more\n");
425 console.finish("u1", "ok")
426 }
427
428 #[test]
429 fn zero_rows_still_renders_content() -> std::io::Result<()> {
430 let mut console = LiveConsole::hidden(config(0, 40));
431 console.begin("u1", "task");
432 console.feed("u1", b"visible line\r\n");
433 console.finish("u1", "ok")
434 }
435
436 #[test]
437 fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
438 let mut console = LiveConsole::hidden(LiveConfig::default());
439 console.feed("ghost", b"noise\n");
440 console.finish("ghost", "done")
441 }
442
443 #[test]
444 fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
445 let console = LiveConsole::hidden(LiveConfig::default());
446 console.note("standalone line")
447 }
448
449 #[test]
450 fn scrolled_rows_are_retained_for_replay_not_lost() {
451 let mut console = LiveConsole::hidden(LiveConfig {
452 rows: 2,
453 cols: 20,
454 scrollback: 200,
455 });
456 console.begin("u1", "task");
457 console.feed("u1", b"l1\nl2\nl3\nl4\n");
458 assert_eq!(
461 console.retained("u1"),
462 Some(vec!["l1".into(), "l2".into(), "l3".into()])
463 );
464 }
465
466 #[test]
467 fn retention_ring_is_bounded_under_a_long_feed() {
468 let mut console = LiveConsole::hidden(LiveConfig {
469 rows: 2,
470 cols: 20,
471 scrollback: 3,
472 });
473 console.begin("u1", "task");
474 for _ in 0..50 {
475 console.feed("u1", b"line\n");
476 }
477 assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
478 }
479
480 #[test]
481 fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
482 let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
483 let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
484 assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
485 }
486
487 #[test]
488 fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
489 let mut console = LiveConsole::hidden(config(2, 20));
490 console.begin("ok", "task");
491 console.feed("ok", b"noise\n");
492 console.finish("ok", "ok task")?;
493 assert!(console.retained("ok").is_none());
494
495 console.begin("bad", "task");
496 console.feed("bad", b"panic!\n");
497 console.finish_with_replay("bad", "failed task")?;
498 assert!(console.retained("bad").is_none());
499 Ok(())
500 }
501
502 #[test]
503 fn finish_with_replay_returns_the_transcript_for_an_end_of_run_epilogue() -> std::io::Result<()>
504 {
505 let mut console = LiveConsole::hidden(config(2, 20));
506 console.begin("bad", "task");
507 console.feed("bad", b"line one\nline two\n");
508 let body = console.finish_with_replay("bad", "failed task")?;
511 assert_eq!(body, vec!["line one".to_string(), "line two".to_string()]);
512 Ok(())
513 }
514
515 #[test]
516 fn finish_with_replay_for_an_unknown_region_returns_an_empty_body() -> std::io::Result<()> {
517 let mut console = LiveConsole::hidden(config(2, 20));
518 let body = console.finish_with_replay("ghost", "failed")?;
519 assert!(body.is_empty());
520 Ok(())
521 }
522
523 #[test]
524 fn render_tile_labels_and_indents_lines() {
525 let tile = render_tile("core", &["a", "b"], 0, 2);
526 let stripped = console::strip_ansi_codes(&tile);
527 assert_eq!(stripped, "• core\n a\n b");
528 }
529
530 #[test]
531 fn content_height_counts_up_to_the_last_non_blank_row() {
532 assert_eq!(super::content_height::<&str>(&[]), 0);
533 assert_eq!(super::content_height(&["", "", ""]), 0);
534 assert_eq!(super::content_height(&["a", "", ""]), 1);
535 assert_eq!(super::content_height(&["a", "", "b", ""]), 3);
536 }
537
538 #[test]
539 fn a_silent_region_renders_only_its_header() {
540 let mut console = LiveConsole::hidden(config(12, 40));
541 console.begin("u1", "rust:core#test");
542 assert_eq!(console.tile_height("u1"), Some(1));
545 }
546
547 #[test]
548 fn a_tile_grows_with_content_up_to_the_cap() {
549 let mut console = LiveConsole::hidden(config(3, 40));
550 console.begin("u1", "task");
551 console.feed("u1", b"one\n");
552 assert_eq!(console.tile_height("u1"), Some(2));
554 console.feed("u1", b"two\nthree\nfour\nfive");
555 assert_eq!(console.tile_height("u1"), Some(4));
557 }
558
559 #[test]
560 fn a_grown_tile_does_not_shrink_when_output_clears() {
561 let mut console = LiveConsole::hidden(config(3, 40));
562 console.begin("u1", "task");
563 console.feed("u1", b"a\r\nb\r\nc");
564 assert_eq!(console.tile_height("u1"), Some(4));
565 console.feed("u1", b"\x1b[H\x1b[2Jshort");
568 assert_eq!(console.tile_height("u1"), Some(4));
569 }
570
571 #[test]
572 fn render_tile_pads_to_fixed_height() {
573 let tile = render_tile("core", &["only"], 0, 3);
574 let stripped = console::strip_ansi_codes(&tile);
575 assert_eq!(stripped, "• core\n only\n \n ");
576 }
577
578 #[test]
579 fn render_tile_truncates_header_and_content_to_width() {
580 let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
581 for line in console::strip_ansi_codes(&tile).lines() {
582 assert!(console::measure_text_width(line) <= 8);
583 }
584 }
585
586 #[test]
587 fn scrollback_line_prefixes_with_label() {
588 let line = scrollback_line("core", "hello");
589 let stripped = console::strip_ansi_codes(&line);
590 assert_eq!(stripped, "core │ hello");
591 }
592
593 #[test]
594 fn replay_line_clamped_to_width_never_wraps() {
595 let content = "x".repeat(200);
599 let composed = scrollback_line("rust@rust:core#test", &content);
600 let clamped = truncate(&composed, 140);
601 assert!(console::measure_text_width(&clamped) <= 140);
602 }
603
604 #[test]
605 fn truncate_respects_width_and_passthrough() {
606 assert_eq!(truncate("hello world", 0), "hello world");
607 let cut = truncate("hello world", 5);
608 assert!(console::measure_text_width(&cut) <= 5);
609 }
610}