use std::io::Read;
use std::time::{Duration, Instant};
use ratatui::{
backend::{Backend, CrosstermBackend},
crossterm,
layout::{Constraint, Direction, Layout},
style::{Color, Style, Stylize},
widgets::{Block, Cell, Paragraph, Row, Table},
Terminal,
};
use baztet_core::baztet_bep::BepOutput;
use baztet_core::exec::{ExecChild, ExecResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DashMode {
Test,
Build,
}
pub async fn run(
mode: DashMode,
child: ExecChild,
pending: Vec<String>,
) -> anyhow::Result<ExecResult> {
let mut terminal = TuiTerminal::enter()?;
let result = terminal.drive(mode, child, pending).await;
drop(terminal);
result
}
struct TuiTerminal {
terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
}
impl TuiTerminal {
fn enter() -> anyhow::Result<Self> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
let terminal = match Terminal::new(CrosstermBackend::new(std::io::stdout())) {
Ok(t) => t,
Err(e) => {
restore();
return Err(e.into());
}
};
Ok(Self { terminal })
}
async fn drive(
&mut self,
mode: DashMode,
child: ExecChild,
pending: Vec<String>,
) -> anyhow::Result<ExecResult> {
let started = Instant::now();
let bep_path = child.bep_path().to_owned();
let console = child.console_handle();
let mut waiter = Some(tokio::spawn(async move {
child.wait().await.map_err(anyhow::Error::from)
}));
let mut state = BepOutput::default();
let mut offset = 0usize;
let outcome: anyhow::Result<ExecResult> = loop {
if waiter.as_ref().unwrap().is_finished() {
let result = match waiter.take().unwrap().await {
Ok(Ok(res)) => Ok(res),
Ok(Err(e)) => Err(e),
Err(e) => Err(e.into()),
};
if let Ok(res) = &result {
if let Some(bep) = &res.bep {
state = bep.clone();
}
}
let _ = draw(
&mut self.terminal,
mode,
&state,
pending.len(),
started.elapsed(),
&console,
);
break result;
}
let key_ready = match crossterm::event::poll(Duration::from_millis(200)) {
Ok(ready) => ready,
Err(e) => break Err(e.into()),
};
if key_ready {
match crossterm::event::read() {
Ok(crossterm::event::Event::Key(k)) => {
let quit = k.kind == crossterm::event::KeyEventKind::Press
&& (k.code == crossterm::event::KeyCode::Char('q')
|| (k
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& k.code == crossterm::event::KeyCode::Char('c')));
if quit {
if waiter.as_ref().unwrap().is_finished() {
break match waiter.take().unwrap().await {
Ok(Ok(res)) => Ok(res),
Ok(Err(e)) => Err(e),
Err(e) => Err(e.into()),
};
}
waiter.as_ref().unwrap().abort();
ingest(&mut state, &bep_path, offset);
break Ok(ExecResult {
code: 130,
bep: Some(state),
..Default::default()
});
}
}
Err(e) => break Err(e.into()),
_ => {}
}
}
offset = ingest(&mut state, &bep_path, offset);
if let Err(e) = draw(
&mut self.terminal,
mode,
&state,
pending.len(),
started.elapsed(),
&console,
) {
break Err(e);
}
};
if outcome.is_err() {
if let Some(h) = waiter.as_ref() {
h.abort();
}
}
outcome
}
}
fn ingest(state: &mut BepOutput, path: &std::path::Path, offset: usize) -> usize {
use std::io::{Seek, SeekFrom};
if let Ok(mut f) = std::fs::File::open(path) {
if f.seek(SeekFrom::Start(offset as u64)).is_ok() {
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_ok() {
if let Ok((delta, consumed)) = baztet_core::baztet_bep::parse_prefix(&buf, 0) {
state.extend(delta);
return offset + consumed;
}
}
}
}
offset
}
impl Drop for TuiTerminal {
fn drop(&mut self) {
restore();
}
}
fn restore() {
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::cursor::Show,
);
let _ = crossterm::terminal::disable_raw_mode();
}
fn draw(
terminal: &mut Terminal<impl Backend>,
mode: DashMode,
out: &BepOutput,
requested: usize,
elapsed: Duration,
console: &baztet_core::exec::ConsoleCapture,
) -> anyhow::Result<()> {
let (done, mut rows): (usize, Vec<Row>) = match mode {
DashMode::Test => {
let rows: Vec<Row> = out
.test_summaries
.iter()
.map(|s| {
Row::new([Cell::from(s.label.clone()), Cell::from(s.status.clone())])
.style(test_style(&s.status))
})
.collect();
(out.test_summaries.len(), rows)
}
DashMode::Build => {
let mut failures: Vec<Row> = Vec::new();
let mut built: Vec<Row> = Vec::new();
out.target_status.iter().for_each(|(label, success)| {
let verdict = |txt: &str| {
Row::new([Cell::from(label.clone()), Cell::from(txt.to_string())]).style(
if *success {
Style::new().fg(Color::Green)
} else {
Style::new().fg(Color::Red)
},
)
};
if *success {
built.push(verdict("BUILT"));
} else {
failures.push(verdict("FAILED"));
}
});
let done = out.target_status.len();
(done, [failures, built].concat())
}
};
let total = requested.max(done);
let header = format!(
" baztet {} — {done}/{total} targets · {:.0}s",
mode_title(mode),
elapsed.as_secs_f64()
);
if requested > done {
rows.push(
Row::new([Cell::from(format!(
"… {} still running/pending",
requested - done
))])
.style(Style::new().dim()),
);
}
terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(f.area());
let table = Table::new(
rows,
[Constraint::Percentage(75), Constraint::Percentage(25)],
)
.header(Row::new(["target", "status"]).style(Style::new().bold()))
.block(Block::bordered().title(header));
f.render_widget(table, chunks[0]);
let progress = console.last_progress();
let footer = if progress.is_empty() {
"q / ctrl-c cancels bazel".to_string()
} else {
format!("q cancels bazel · {progress}")
};
f.render_widget(Paragraph::new(footer), chunks[1]);
})?;
Ok(())
}
fn test_style(status: &str) -> Style {
match status {
"PASSED" => Style::new().fg(Color::Green),
"FAILED" | "TIMEOUT" | "ERROR" => Style::new().fg(Color::Red),
"FLAKY" => Style::new().fg(Color::Yellow),
_ => Style::new(),
}
}
fn mode_title(mode: DashMode) -> &'static str {
match mode {
DashMode::Test => "test",
DashMode::Build => "build/coverage",
}
}
#[cfg(test)]
mod tests {
use super::*;
use baztet_core::baztet_bep::TestSummary;
use ratatui::backend::TestBackend;
#[test]
fn dashboard_rows_by_mode() {
let out = BepOutput {
target_status: [
("//a:good".to_string(), true),
("//a:bad".to_string(), false),
]
.into(),
..BepOutput::default()
};
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).unwrap();
draw(
&mut terminal,
DashMode::Build,
&out,
5,
Duration::from_secs(3),
&baztet_core::exec::ConsoleCapture::default(),
)
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("//a:bad"), "failure row missing: {text}");
assert!(text.contains("FAILED"), "FAILED verdict missing: {text}");
assert!(text.contains("2/5 targets"), "header got: {text}");
assert!(text.contains("3 still running"), "pending missing: {text}");
}
fn summary(label: &str, status: &str) -> TestSummary {
TestSummary {
label: label.to_string(),
status: status.to_string(),
}
}
#[test]
fn header_denominator_stabilizes() {
let requested = 10;
let out = BepOutput {
test_summaries: [summary("//a:one", "PASSED"), summary("//a:two", "FAILED")].into(),
..BepOutput::default()
};
let backend = TestBackend::new(60, 8);
let mut terminal = Terminal::new(backend).unwrap();
draw(
&mut terminal,
DashMode::Test,
&out,
requested,
Duration::from_secs(5),
&baztet_core::exec::ConsoleCapture::default(),
)
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("2/10 targets"), "header got: {text}");
assert!(text.contains("//a:one"), "row missing: {text}");
assert!(
text.contains("8 still running"),
"pending row missing: {text}"
);
}
#[test]
fn header_shows_full_when_all_finish() {
let requested = 2;
let out = BepOutput {
test_summaries: [summary("//a:one", "PASSED"), summary("//a:two", "FLAKY")].into(),
..BepOutput::default()
};
let backend = TestBackend::new(60, 8);
let mut terminal = Terminal::new(backend).unwrap();
draw(
&mut terminal,
DashMode::Test,
&out,
requested,
Duration::from_secs(9),
&baztet_core::exec::ConsoleCapture::default(),
)
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("2/2 targets"), "header got: {text}");
assert!(!text.contains("still running"), "leftover: {text}");
}
#[test]
fn test_statuses_map_to_verdicts() {
assert_eq!(test_style("PASSED").fg, Some(Color::Green));
assert_eq!(test_style("FAILED").fg, Some(Color::Red));
assert_eq!(test_style("TIMEOUT").fg, Some(Color::Red));
assert_eq!(test_style("ERROR").fg, Some(Color::Red));
assert_eq!(test_style("FLAKY").fg, Some(Color::Yellow));
assert_eq!(test_style("NO_STATUS").fg, None);
}
}