1use std::collections::HashMap;
18use std::collections::VecDeque;
19
20use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
21
22use super::screen::RegionScreen;
23
24const TILE_INDENT: &str = " ";
28
29#[derive(Debug, Clone, Copy)]
36pub struct LiveConfig {
37 pub rows: usize,
42 pub cols: usize,
46 pub scrollback: usize,
51}
52
53impl Default for LiveConfig {
54 fn default() -> Self {
55 Self {
56 rows: 5,
57 cols: 80,
58 scrollback: 200,
59 }
60 }
61}
62
63impl LiveConfig {
64 #[must_use]
73 pub fn content_cols(&self) -> usize {
74 self.cols.saturating_sub(TILE_INDENT.len()).max(1)
75 }
76}
77
78struct Region {
82 bar: ProgressBar,
83 screen: RegionScreen,
84 label: String,
85 retained: VecDeque<String>,
86 high_water: usize,
91}
92
93pub struct LiveConsole {
100 multi: MultiProgress,
101 header: ProgressBar,
102 regions: HashMap<String, Region>,
103 config: LiveConfig,
104}
105
106impl LiveConsole {
107 #[must_use]
111 pub fn to_stderr(config: LiveConfig) -> Self {
112 Self::with_target(ProgressDrawTarget::stderr(), config)
113 }
114
115 #[must_use]
118 pub fn hidden(config: LiveConfig) -> Self {
119 Self::with_target(ProgressDrawTarget::hidden(), config)
120 }
121
122 fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
123 config.rows = config.rows.max(1);
126 config.cols = config.cols.max(1);
127 let multi = MultiProgress::with_draw_target(target);
128 let header = multi.add(ProgressBar::new(0));
129 header.set_style(message_style());
130 Self {
131 multi,
132 header,
133 regions: HashMap::new(),
134 config,
135 }
136 }
137
138 #[must_use]
142 pub fn content_cols(&self) -> usize {
143 self.config.content_cols()
144 }
145
146 pub fn set_header(&self, text: impl Into<String>) {
148 self.header.set_message(text.into());
149 }
150
151 pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
156 let id = id.into();
157 let label = label.into();
158 if let Some(old) = self.regions.remove(&id) {
159 old.bar.finish_and_clear();
160 self.multi.remove(&old.bar);
161 }
162 let bar = self.multi.add(ProgressBar::new(0));
163 bar.set_style(message_style());
164 let region = Region {
165 bar,
166 screen: RegionScreen::new(self.config.rows, self.content_cols()),
167 label,
168 retained: VecDeque::new(),
169 high_water: 0,
170 };
171 region.bar.set_message(render_tile(
174 ®ion.label,
175 &[] as &[&str],
176 self.config.cols,
177 0,
178 ));
179 self.regions.insert(id, region);
180 }
181
182 pub fn feed(&mut self, id: &str, bytes: &[u8]) {
188 let scrollback = self.config.scrollback;
189 let Some(region) = self.regions.get_mut(id) else {
190 return;
191 };
192 for line in region.screen.feed(bytes) {
193 region.retained.push_back(line);
194 while region.retained.len() > scrollback {
195 region.retained.pop_front();
196 }
197 }
198 let visible = region.screen.render();
199 let filled = content_height(&visible);
204 region.high_water = region.high_water.max(filled).min(self.config.rows);
205 region.bar.set_message(render_tile(
206 ®ion.label,
207 &visible,
208 self.config.cols,
209 region.high_water,
210 ));
211 }
212
213 pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
220 if let Some(region) = self.regions.remove(id) {
221 region.bar.finish_and_clear();
222 self.multi.remove(®ion.bar);
223 }
224 self.multi.println(verdict.as_ref())
225 }
226
227 pub fn finish_with_replay(
236 &mut self,
237 id: &str,
238 verdict: impl AsRef<str>,
239 ) -> std::io::Result<Vec<String>> {
240 let mut body = Vec::new();
241 if let Some(region) = self.regions.remove(id) {
242 let Region {
243 bar,
244 screen,
245 label,
246 retained,
247 high_water: _,
248 } = region;
249 for line in replay_body(&retained, screen.drain()) {
250 let prefixed = scrollback_line(&label, &line);
251 self.multi.println(truncate(&prefixed, self.config.cols))?;
252 body.push(line);
253 }
254 bar.finish_and_clear();
255 self.multi.remove(&bar);
256 }
257 self.multi.println(verdict.as_ref())?;
258 Ok(body)
259 }
260
261 pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
267 self.multi.println(line.as_ref())
268 }
269
270 pub fn clear(&mut self) -> std::io::Result<()> {
277 for (_, region) in self.regions.drain() {
278 region.bar.finish_and_clear();
279 self.multi.remove(®ion.bar);
280 }
281 self.header.set_message("");
282 self.multi.clear()
283 }
284
285 #[cfg(test)]
287 fn retained(&self, id: &str) -> Option<Vec<String>> {
288 self.regions
289 .get(id)
290 .map(|region| region.retained.iter().cloned().collect())
291 }
292
293 #[cfg(test)]
296 fn tile_height(&self, id: &str) -> Option<usize> {
297 self.regions.get(id).map(|region| region.high_water + 1)
298 }
299}
300
301fn message_style() -> ProgressStyle {
303 ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
304}
305
306fn content_height<S: AsRef<str>>(lines: &[S]) -> usize {
310 lines
311 .iter()
312 .rposition(|line| !line.as_ref().is_empty())
313 .map_or(0, |index| index + 1)
314}
315
316fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
322 let header = format!("{}", console::style(format!("• {label}")).bold());
323 let mut out = truncate(&header, cols);
324 for index in 0..rows {
325 out.push('\n');
326 let line = lines.get(index).map_or("", AsRef::as_ref);
327 out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
328 }
329 out
330}
331
332fn scrollback_line(label: &str, line: &str) -> String {
334 format!("{} {line}", console::style(format!("{label} │")).dim())
335}
336
337fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
341 let mut lines: Vec<String> = retained.iter().cloned().collect();
342 lines.extend(on_screen);
343 while lines.last().is_some_and(String::is_empty) {
344 lines.pop();
345 }
346 lines
347}
348
349fn truncate(line: &str, width: usize) -> String {
352 if width == 0 {
353 return line.to_string();
354 }
355 console::truncate_str(line, width, "…").into_owned()
356}
357
358#[cfg(test)]
359mod tests {
360 use std::collections::VecDeque;
361
362 use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
363
364 fn config(rows: usize, cols: usize) -> LiveConfig {
365 LiveConfig {
366 rows,
367 cols,
368 ..LiveConfig::default()
369 }
370 }
371
372 #[test]
373 fn content_cols_reserves_the_indent_and_never_underflows() {
374 assert_eq!(config(6, 80).content_cols(), 78);
375 assert_eq!(config(6, 1).content_cols(), 1);
377 assert_eq!(config(6, 0).content_cols(), 1);
378 }
379
380 #[test]
381 fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
382 let mut console = LiveConsole::hidden(config(2, 40));
383 console.set_header("wave 1/2 · running 1");
384 console.begin("u1", "rust:core#test");
385 console.feed("u1", b"compiling\r\n");
386 console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
387 console.finish("u1", "ok rust:core#test")?;
388 console.clear()
389 }
390
391 #[test]
392 fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
393 let mut console = LiveConsole::hidden(LiveConfig::default());
394 console.begin("u1", "first");
395 console.feed("u1", b"old\n");
396 console.begin("u1", "second");
397 console.feed("u1", b"new\n");
398 console.finish("u1", "ok")
399 }
400
401 #[test]
402 fn console_is_reusable_after_clear() -> std::io::Result<()> {
403 let mut console = LiveConsole::hidden(LiveConfig::default());
404 console.set_header("first pass");
405 console.begin("u1", "task");
406 console.feed("u1", b"partial output\n");
407 console.clear()?;
408 console.set_header("second pass");
409 console.begin("u1", "task");
410 console.feed("u1", b"more\n");
411 console.finish("u1", "ok")
412 }
413
414 #[test]
415 fn zero_rows_still_renders_content() -> std::io::Result<()> {
416 let mut console = LiveConsole::hidden(config(0, 40));
417 console.begin("u1", "task");
418 console.feed("u1", b"visible line\r\n");
419 console.finish("u1", "ok")
420 }
421
422 #[test]
423 fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
424 let mut console = LiveConsole::hidden(LiveConfig::default());
425 console.feed("ghost", b"noise\n");
426 console.finish("ghost", "done")
427 }
428
429 #[test]
430 fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
431 let console = LiveConsole::hidden(LiveConfig::default());
432 console.note("standalone line")
433 }
434
435 #[test]
436 fn scrolled_rows_are_retained_for_replay_not_lost() {
437 let mut console = LiveConsole::hidden(LiveConfig {
438 rows: 2,
439 cols: 20,
440 scrollback: 200,
441 });
442 console.begin("u1", "task");
443 console.feed("u1", b"l1\nl2\nl3\nl4\n");
444 assert_eq!(
447 console.retained("u1"),
448 Some(vec!["l1".into(), "l2".into(), "l3".into()])
449 );
450 }
451
452 #[test]
453 fn retention_ring_is_bounded_under_a_long_feed() {
454 let mut console = LiveConsole::hidden(LiveConfig {
455 rows: 2,
456 cols: 20,
457 scrollback: 3,
458 });
459 console.begin("u1", "task");
460 for _ in 0..50 {
461 console.feed("u1", b"line\n");
462 }
463 assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
464 }
465
466 #[test]
467 fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
468 let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
469 let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
470 assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
471 }
472
473 #[test]
474 fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
475 let mut console = LiveConsole::hidden(config(2, 20));
476 console.begin("ok", "task");
477 console.feed("ok", b"noise\n");
478 console.finish("ok", "ok task")?;
479 assert!(console.retained("ok").is_none());
480
481 console.begin("bad", "task");
482 console.feed("bad", b"panic!\n");
483 console.finish_with_replay("bad", "failed task")?;
484 assert!(console.retained("bad").is_none());
485 Ok(())
486 }
487
488 #[test]
489 fn finish_with_replay_returns_the_transcript_for_an_end_of_run_epilogue() -> std::io::Result<()>
490 {
491 let mut console = LiveConsole::hidden(config(2, 20));
492 console.begin("bad", "task");
493 console.feed("bad", b"line one\nline two\n");
494 let body = console.finish_with_replay("bad", "failed task")?;
497 assert_eq!(body, vec!["line one".to_string(), "line two".to_string()]);
498 Ok(())
499 }
500
501 #[test]
502 fn finish_with_replay_for_an_unknown_region_returns_an_empty_body() -> std::io::Result<()> {
503 let mut console = LiveConsole::hidden(config(2, 20));
504 let body = console.finish_with_replay("ghost", "failed")?;
505 assert!(body.is_empty());
506 Ok(())
507 }
508
509 #[test]
510 fn render_tile_labels_and_indents_lines() {
511 let tile = render_tile("core", &["a", "b"], 0, 2);
512 let stripped = console::strip_ansi_codes(&tile);
513 assert_eq!(stripped, "• core\n a\n b");
514 }
515
516 #[test]
517 fn content_height_counts_up_to_the_last_non_blank_row() {
518 assert_eq!(super::content_height::<&str>(&[]), 0);
519 assert_eq!(super::content_height(&["", "", ""]), 0);
520 assert_eq!(super::content_height(&["a", "", ""]), 1);
521 assert_eq!(super::content_height(&["a", "", "b", ""]), 3);
522 }
523
524 #[test]
525 fn a_silent_region_renders_only_its_header() {
526 let mut console = LiveConsole::hidden(config(12, 40));
527 console.begin("u1", "rust:core#test");
528 assert_eq!(console.tile_height("u1"), Some(1));
530 }
531
532 #[test]
533 fn a_tile_grows_with_content_up_to_the_cap() {
534 let mut console = LiveConsole::hidden(config(3, 40));
535 console.begin("u1", "task");
536 console.feed("u1", b"one\n");
537 assert_eq!(console.tile_height("u1"), Some(2));
539 console.feed("u1", b"two\nthree\nfour\nfive");
540 assert_eq!(console.tile_height("u1"), Some(4));
542 }
543
544 #[test]
545 fn a_grown_tile_does_not_shrink_when_output_clears() {
546 let mut console = LiveConsole::hidden(config(3, 40));
547 console.begin("u1", "task");
548 console.feed("u1", b"a\r\nb\r\nc");
549 assert_eq!(console.tile_height("u1"), Some(4));
550 console.feed("u1", b"\x1b[H\x1b[2Jshort");
552 assert_eq!(console.tile_height("u1"), Some(4));
553 }
554
555 #[test]
556 fn render_tile_pads_to_fixed_height() {
557 let tile = render_tile("core", &["only"], 0, 3);
558 let stripped = console::strip_ansi_codes(&tile);
559 assert_eq!(stripped, "• core\n only\n \n ");
560 }
561
562 #[test]
563 fn render_tile_truncates_header_and_content_to_width() {
564 let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
565 for line in console::strip_ansi_codes(&tile).lines() {
566 assert!(console::measure_text_width(line) <= 8);
567 }
568 }
569
570 #[test]
571 fn scrollback_line_prefixes_with_label() {
572 let line = scrollback_line("core", "hello");
573 let stripped = console::strip_ansi_codes(&line);
574 assert_eq!(stripped, "core │ hello");
575 }
576
577 #[test]
578 fn replay_line_clamped_to_width_never_wraps() {
579 let content = "x".repeat(200);
584 let composed = scrollback_line("rust@rust:core#test", &content);
585 let clamped = truncate(&composed, 140);
586 assert!(console::measure_text_width(&clamped) <= 140);
587 }
588
589 #[test]
590 fn truncate_respects_width_and_passthrough() {
591 assert_eq!(truncate("hello world", 0), "hello world");
592 let cut = truncate("hello world", 5);
593 assert!(console::measure_text_width(&cut) <= 5);
594 }
595}