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,
41 pub cols: usize,
46 pub scrollback: usize,
52}
53
54impl Default for LiveConfig {
55 fn default() -> Self {
56 Self {
57 rows: 5,
58 cols: 80,
59 scrollback: 200,
60 }
61 }
62}
63
64impl LiveConfig {
65 #[must_use]
75 pub fn content_cols(&self) -> usize {
76 self.cols.saturating_sub(TILE_INDENT.len()).max(1)
77 }
78}
79
80struct Region {
84 bar: ProgressBar,
85 screen: RegionScreen,
86 label: String,
87 retained: VecDeque<String>,
88}
89
90pub struct LiveConsole {
97 multi: MultiProgress,
98 header: ProgressBar,
99 regions: HashMap<String, Region>,
100 config: LiveConfig,
101}
102
103impl LiveConsole {
104 #[must_use]
109 pub fn to_stderr(config: LiveConfig) -> Self {
110 Self::with_target(ProgressDrawTarget::stderr(), config)
111 }
112
113 #[must_use]
116 pub fn hidden(config: LiveConfig) -> Self {
117 Self::with_target(ProgressDrawTarget::hidden(), config)
118 }
119
120 fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
121 config.rows = config.rows.max(1);
124 config.cols = config.cols.max(1);
125 let multi = MultiProgress::with_draw_target(target);
126 let header = multi.add(ProgressBar::new(0));
127 header.set_style(message_style());
128 Self {
129 multi,
130 header,
131 regions: HashMap::new(),
132 config,
133 }
134 }
135
136 #[must_use]
141 pub fn content_cols(&self) -> usize {
142 self.config.content_cols()
143 }
144
145 pub fn set_header(&self, text: impl Into<String>) {
147 self.header.set_message(text.into());
148 }
149
150 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 };
170 region.bar.set_message(render_tile(
171 ®ion.label,
172 &[] as &[&str],
173 self.config.cols,
174 self.config.rows,
175 ));
176 self.regions.insert(id, region);
177 }
178
179 pub fn feed(&mut self, id: &str, bytes: &[u8]) {
186 let scrollback = self.config.scrollback;
187 let Some(region) = self.regions.get_mut(id) else {
188 return;
189 };
190 for line in region.screen.feed(bytes) {
191 region.retained.push_back(line);
192 while region.retained.len() > scrollback {
193 region.retained.pop_front();
194 }
195 }
196 let visible = region.screen.render();
197 region.bar.set_message(render_tile(
198 ®ion.label,
199 &visible,
200 self.config.cols,
201 self.config.rows,
202 ));
203 }
204
205 pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
213 if let Some(region) = self.regions.remove(id) {
214 region.bar.finish_and_clear();
215 self.multi.remove(®ion.bar);
216 }
217 self.multi.println(verdict.as_ref())
218 }
219
220 pub fn finish_with_replay(
228 &mut self,
229 id: &str,
230 verdict: impl AsRef<str>,
231 ) -> std::io::Result<()> {
232 if let Some(region) = self.regions.remove(id) {
233 let Region {
234 bar,
235 screen,
236 label,
237 retained,
238 } = region;
239 for line in replay_body(&retained, screen.drain()) {
240 let prefixed = scrollback_line(&label, &line);
241 self.multi.println(truncate(&prefixed, self.config.cols))?;
242 }
243 bar.finish_and_clear();
244 self.multi.remove(&bar);
245 }
246 self.multi.println(verdict.as_ref())
247 }
248
249 pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
255 self.multi.println(line.as_ref())
256 }
257
258 pub fn clear(&mut self) -> std::io::Result<()> {
265 for (_, region) in self.regions.drain() {
266 region.bar.finish_and_clear();
267 self.multi.remove(®ion.bar);
268 }
269 self.header.set_message("");
270 self.multi.clear()
271 }
272
273 #[cfg(test)]
275 fn retained(&self, id: &str) -> Option<Vec<String>> {
276 self.regions
277 .get(id)
278 .map(|region| region.retained.iter().cloned().collect())
279 }
280}
281
282fn message_style() -> ProgressStyle {
284 ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
285}
286
287fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
294 let header = format!("{}", console::style(format!("• {label}")).bold());
295 let mut out = truncate(&header, cols);
296 for index in 0..rows {
297 out.push('\n');
298 let line = lines.get(index).map_or("", AsRef::as_ref);
299 out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
300 }
301 out
302}
303
304fn scrollback_line(label: &str, line: &str) -> String {
306 format!("{} {line}", console::style(format!("{label} │")).dim())
307}
308
309fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
313 let mut lines: Vec<String> = retained.iter().cloned().collect();
314 lines.extend(on_screen);
315 while lines.last().is_some_and(String::is_empty) {
316 lines.pop();
317 }
318 lines
319}
320
321fn truncate(line: &str, width: usize) -> String {
324 if width == 0 {
325 return line.to_string();
326 }
327 console::truncate_str(line, width, "…").into_owned()
328}
329
330#[cfg(test)]
331mod tests {
332 use std::collections::VecDeque;
333
334 use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
335
336 fn config(rows: usize, cols: usize) -> LiveConfig {
337 LiveConfig {
338 rows,
339 cols,
340 ..LiveConfig::default()
341 }
342 }
343
344 #[test]
345 fn content_cols_reserves_the_indent_and_never_underflows() {
346 assert_eq!(config(6, 80).content_cols(), 78);
347 assert_eq!(config(6, 1).content_cols(), 1);
349 assert_eq!(config(6, 0).content_cols(), 1);
350 }
351
352 #[test]
353 fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
354 let mut console = LiveConsole::hidden(config(2, 40));
355 console.set_header("wave 1/2 · running 1");
356 console.begin("u1", "rust:core#test");
357 console.feed("u1", b"compiling\r\n");
358 console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
359 console.finish("u1", "ok rust:core#test")?;
360 console.clear()
361 }
362
363 #[test]
364 fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
365 let mut console = LiveConsole::hidden(LiveConfig::default());
366 console.begin("u1", "first");
367 console.feed("u1", b"old\n");
368 console.begin("u1", "second");
369 console.feed("u1", b"new\n");
370 console.finish("u1", "ok")
371 }
372
373 #[test]
374 fn console_is_reusable_after_clear() -> std::io::Result<()> {
375 let mut console = LiveConsole::hidden(LiveConfig::default());
376 console.set_header("first pass");
377 console.begin("u1", "task");
378 console.feed("u1", b"partial output\n");
379 console.clear()?;
380 console.set_header("second pass");
381 console.begin("u1", "task");
382 console.feed("u1", b"more\n");
383 console.finish("u1", "ok")
384 }
385
386 #[test]
387 fn zero_rows_still_renders_content() -> std::io::Result<()> {
388 let mut console = LiveConsole::hidden(config(0, 40));
389 console.begin("u1", "task");
390 console.feed("u1", b"visible line\r\n");
391 console.finish("u1", "ok")
392 }
393
394 #[test]
395 fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
396 let mut console = LiveConsole::hidden(LiveConfig::default());
397 console.feed("ghost", b"noise\n");
398 console.finish("ghost", "done")
399 }
400
401 #[test]
402 fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
403 let console = LiveConsole::hidden(LiveConfig::default());
404 console.note("standalone line")
405 }
406
407 #[test]
408 fn scrolled_rows_are_retained_for_replay_not_lost() {
409 let mut console = LiveConsole::hidden(LiveConfig {
410 rows: 2,
411 cols: 20,
412 scrollback: 200,
413 });
414 console.begin("u1", "task");
415 console.feed("u1", b"l1\nl2\nl3\nl4\n");
416 assert_eq!(
419 console.retained("u1"),
420 Some(vec!["l1".into(), "l2".into(), "l3".into()])
421 );
422 }
423
424 #[test]
425 fn retention_ring_is_bounded_under_a_long_feed() {
426 let mut console = LiveConsole::hidden(LiveConfig {
427 rows: 2,
428 cols: 20,
429 scrollback: 3,
430 });
431 console.begin("u1", "task");
432 for _ in 0..50 {
433 console.feed("u1", b"line\n");
434 }
435 assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
436 }
437
438 #[test]
439 fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
440 let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
441 let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
442 assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
443 }
444
445 #[test]
446 fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
447 let mut console = LiveConsole::hidden(config(2, 20));
448 console.begin("ok", "task");
449 console.feed("ok", b"noise\n");
450 console.finish("ok", "ok task")?;
451 assert!(console.retained("ok").is_none());
452
453 console.begin("bad", "task");
454 console.feed("bad", b"panic!\n");
455 console.finish_with_replay("bad", "failed task")?;
456 assert!(console.retained("bad").is_none());
457 Ok(())
458 }
459
460 #[test]
461 fn render_tile_labels_and_indents_lines() {
462 let tile = render_tile("core", &["a", "b"], 0, 2);
463 let stripped = console::strip_ansi_codes(&tile);
464 assert_eq!(stripped, "• core\n a\n b");
465 }
466
467 #[test]
468 fn render_tile_pads_to_fixed_height() {
469 let tile = render_tile("core", &["only"], 0, 3);
470 let stripped = console::strip_ansi_codes(&tile);
471 assert_eq!(stripped, "• core\n only\n \n ");
472 }
473
474 #[test]
475 fn render_tile_truncates_header_and_content_to_width() {
476 let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
477 for line in console::strip_ansi_codes(&tile).lines() {
478 assert!(console::measure_text_width(line) <= 8);
479 }
480 }
481
482 #[test]
483 fn scrollback_line_prefixes_with_label() {
484 let line = scrollback_line("core", "hello");
485 let stripped = console::strip_ansi_codes(&line);
486 assert_eq!(stripped, "core │ hello");
487 }
488
489 #[test]
490 fn replay_line_clamped_to_width_never_wraps() {
491 let content = "x".repeat(200);
495 let composed = scrollback_line("rust@rust:core#test", &content);
496 let clamped = truncate(&composed, 140);
497 assert!(console::measure_text_width(&clamped) <= 140);
498 }
499
500 #[test]
501 fn truncate_respects_width_and_passthrough() {
502 assert_eq!(truncate("hello world", 0), "hello world");
503 let cut = truncate("hello world", 5);
504 assert!(console::measure_text_width(&cut) <= 5);
505 }
506}