cargo hollywood
bendn 2023-10-07
commit 839bce3
-rw-r--r--.gitignore2
-rw-r--r--Cargo.toml24
-rw-r--r--LICENSE21
-rw-r--r--README.md8
-rw-r--r--src/cargo.rs255
-rw-r--r--src/logger.rs57
-rw-r--r--src/main.rs95
-rw-r--r--src/test/mod.rs170
-rw-r--r--src/test/ui/inspector.rs103
-rw-r--r--src/test/ui/ls.rs38
-rw-r--r--src/test/ui/mod.rs88
-rw-r--r--src/test/ui/progress.rs39
-rw-r--r--src/test/ui/stdout.rs15
-rw-r--r--src/test/ui/test_list.rs130
14 files changed, 1045 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..cf9dd55
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "cargo-kewl"
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+description = "dashboards; graphs; charts-- for cargo!"
+repository = "https://bend-n/cargo-kewl"
+authors = ["bendn <[email protected]>"]
+
+[dependencies]
+clap = { version = "4.4.6", features = ["derive"] }
+ratatui = { version = "0.23.0", features = ["crossterm"] }
+log = "0.4.20"
+comat = "0.1.3"
+ansi-to-tui = "3.1.0"
+humantime = "2.1.0"
+
+anyhow = "1.0.75"
+crossbeam = { version = "0.8.2", features = ["crossbeam-channel"] }
+crossterm = "0.27.0"
+serde = "1"
+serde_derive = "1"
+serde_json = "1"
+toml = "0.8.2"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1fafc15
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 bendn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cb73e00
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# cargo-kewl
+
+[![asciicast](https://asciinema.org/a/612415.svg)](https://asciinema.org/a/612415)
+
+## TODO
+
+- [ ] cargo kewl bench
+- [ ] more graphs \ No newline at end of file
diff --git a/src/cargo.rs b/src/cargo.rs
new file mode 100644
index 0000000..35007af
--- /dev/null
+++ b/src/cargo.rs
@@ -0,0 +1,255 @@
+use anyhow::{bail, Result};
+use crossbeam::channel::Sender;
+use serde_derive::Deserialize;
+use std::path::Path;
+use std::{
+ error::Error,
+ io::Read,
+ process::{Command, Stdio},
+};
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "lowercase")]
+enum Type {
+ Test,
+ Suite,
+}
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "lowercase")]
+enum Event {
+ Ok,
+ #[serde(rename = "started")]
+ Start,
+ #[serde(rename = "failed")]
+ Fail,
+ #[serde(rename = "ignored")]
+ Ignore,
+}
+
+#[derive(Deserialize, Debug)]
+// todo: figure out if theres a cool serde trick
+struct TestRaw {
+ #[serde(rename = "type")]
+ ty: Type,
+ event: Event,
+ name: Option<String>,
+ passed: Option<usize>,
+ failed: Option<usize>,
+ ignored: Option<usize>,
+ measured: Option<usize>,
+ filtered_out: Option<usize>,
+ test_count: Option<usize>,
+ stdout: Option<String>,
+ exec_time: Option<f32>,
+}
+
+#[derive(Debug, PartialEq)]
+pub struct TestResult {
+ pub name: String,
+ pub exec_time: f32,
+ pub stdout: Option<String>,
+}
+
+#[derive(Debug, PartialEq)]
+pub enum TestEvent {
+ SuiteStart {
+ test_count: usize,
+ },
+ SuiteOk {
+ failed: usize,
+ passed: usize,
+ ignored: usize,
+ measured: usize,
+ filtered_out: usize,
+ exec_time: f32,
+ },
+ SuiteFail {
+ passed: usize,
+ failed: usize,
+ ignored: usize,
+ measured: usize,
+ filtered_out: usize,
+ exec_time: f32,
+ },
+ TestStart {
+ name: String,
+ },
+ TestOk(TestResult),
+ TestFail(TestResult),
+ TestIgnore {
+ name: String,
+ },
+}
+
+#[derive(Debug)]
+pub enum TestMessage {
+ Event(TestEvent),
+ Finished,
+}
+
+pub fn test(to: Sender<TestMessage>, at: Option<&Path>) -> Result<TestEvent> {
+ let mut proc = Command::new("cargo");
+ if let Some(at) = at {
+ proc.arg("-C");
+ proc.arg(at.as_os_str());
+ }
+ proc.args([
+ "-Zunstable-options",
+ "test",
+ "--",
+ "-Zunstable-options",
+ "--report-time",
+ "--show-output",
+ "--format",
+ "json",
+ ]);
+ log::trace!("running {proc:?}");
+ let mut proc = proc.stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?;
+ let mut out = proc.stdout.take().unwrap();
+ let mut tmp = Vec::with_capacity(32);
+ let mut stdout = [0; 4096];
+ loop {
+ let n = out.read(&mut stdout)?;
+ for &byte in &stdout[..n] {
+ match byte {
+ b'\n' => {
+ log::debug!("got first event, returning");
+ let event = parse_test(std::str::from_utf8(&tmp).unwrap()).unwrap();
+ tmp.clear();
+ log::debug!("spawning thread");
+ std::thread::spawn(move || loop {
+ let n = out.read(&mut stdout).unwrap();
+ for &byte in &stdout[..n] {
+ match byte {
+ b'\n' => {
+ let event =
+ parse_test(std::str::from_utf8(&tmp).unwrap()).unwrap();
+ tmp.clear();
+ to.send(TestMessage::Event(event)).unwrap();
+ }
+ b => tmp.push(b),
+ }
+ }
+ if let Ok(Some(_)) = proc.try_wait() {
+ to.send(TestMessage::Finished).unwrap();
+ log::debug!("proc exited, joining thread");
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ });
+ return Ok(event);
+ }
+ b => tmp.push(b),
+ }
+ }
+ if let Some(exit) = proc.try_wait()? {
+ log::trace!("process died, we die");
+ bail!("process exited too early ({exit})");
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+}
+
+#[derive(Debug)]
+struct Should(&'static str);
+impl std::fmt::Display for Should {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "should have had a {}, dang", self.0)
+ }
+}
+impl Error for Should {}
+
+fn parse_test(s: &str) -> Result<TestEvent> {
+ let raw = serde_json::from_str::<TestRaw>(s)?;
+ log::trace!("got raw event {raw:?}");
+ macro_rules! take {
+ ($thing:ident { $($holds:ident),+ $(?= $($opt:ident),+)?}) => {
+ $thing {
+ $($holds: raw.$holds.ok_or(Should(stringify!($holds)))?,)+
+ $($($opt: raw.$opt),+)?
+ }
+ };
+ ($thing:ident($inner:ident { $($holds:ident),+ $(?= $($opt:ident),+)? })) => {
+ $thing(take!($inner { $($holds),+ $(?= $($opt),+)? }))
+ }
+ }
+ use TestEvent::*;
+ Ok(match raw.ty {
+ Type::Test => match raw.event {
+ Event::Start => take!(TestStart { name }),
+ Event::Ok => take!(TestOk(TestResult {
+ name,
+ exec_time ?= stdout
+ })),
+ Event::Fail => take!(TestFail(TestResult {
+ name,
+ exec_time ?= stdout
+ })),
+ Event::Ignore => take!(TestIgnore { name }),
+ },
+ Type::Suite => match raw.event {
+ Event::Start => take!(SuiteStart { test_count }),
+ Event::Ok => take!(SuiteOk {
+ failed,
+ passed,
+ ignored,
+ measured,
+ filtered_out,
+ exec_time
+ }),
+ Event::Fail => {
+ take!(SuiteFail {
+ failed,
+ passed,
+ ignored,
+ measured,
+ filtered_out,
+ exec_time
+ })
+ }
+ Event::Ignore => panic!("ignore suite???"),
+ },
+ })
+}
+
+#[derive(Deserialize)]
+pub struct Package {
+ pub name: String,
+}
+
+#[derive(Deserialize)]
+pub struct Metadata {
+ pub package: Package,
+}
+
+pub fn meta(at: &Path) -> Result<Metadata> {
+ Ok(toml::from_str(&std::fs::read_to_string(
+ at.join("Cargo.toml"),
+ )?)?)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn test_output() {
+ macro_rules! run {
+ ($($input:literal parses to $output:expr),+) => {
+ $(assert_eq!(parse_test($input).unwrap(), $output);)+
+ };
+ }
+ run![
+ r#"{ "type": "suite", "event": "started", "test_count": 2 }"# parses to TestEvent::SuiteStart { test_count: 2},
+ r#"{ "type": "test", "event": "started", "name": "fail" }"# parses to TestEvent::TestStart { name: "fail".into() },
+ r#"{ "type": "test", "name": "fail", "event": "ok", "exec_time": 0.000003428, "stdout": "hello world" }"# parses to TestEvent::TestOk(TestResult { name: "fail".into(), exec_time: 0.000003428, stdout: Some("hello world".into()) }),
+ r#"{ "type": "test", "event": "started", "name": "nope" }"# parses to TestEvent::TestStart { name: "nope".into() },
+ r#"{ "type": "test", "name": "nope", "event": "ignored" }"# parses to TestEvent::TestIgnore { name: "nope".into() },
+ r#"{ "type": "suite", "event": "ok", "passed": 1, "failed": 0, "ignored": 1, "measured": 0, "filtered_out": 0, "exec_time": 0.000684028 }"# parses to TestEvent::SuiteOk { passed: 1, failed: 0, ignored: 1, measured: 0, filtered_out: 0, exec_time: 0.000684028 }
+ ];
+ r#"
+ { "type": "suite", "event": "started", "test_count": 1 }
+ { "type": "test", "event": "started", "name": "fail" }
+ { "type": "test", "name": "fail", "event": "failed", "exec_time": 0.000081092, "stdout": "thread 'fail' panicked at src/main.rs:3:5:\nexplicit panic\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n" }
+ { "type": "suite", "event": "failed", "passed": 0, "failed": 1, "ignored": 0, "measured": 0, "filtered_out": 0, "exec_time": 0.000731068 }
+ "#;
+ }
+}
diff --git a/src/logger.rs b/src/logger.rs
new file mode 100644
index 0000000..3359644
--- /dev/null
+++ b/src/logger.rs
@@ -0,0 +1,57 @@
+use comat::{cformat_args, cwriteln};
+use log::{Level, Metadata, Record};
+use std::{
+ fs::File,
+ io::Write,
+ path::PathBuf,
+ sync::{Mutex, OnceLock, PoisonError},
+ time::Instant,
+};
+
+#[derive(Debug)]
+pub struct Logger {
+ start: Instant,
+ file: Mutex<File>,
+}
+
+impl Logger {
+ pub fn init(level: Level, f: PathBuf) {
+ static LOGGER: OnceLock<Logger> = OnceLock::new();
+ LOGGER
+ .set(Self {
+ start: Instant::now(),
+ file: Mutex::new(File::create(f).unwrap()),
+ })
+ .unwrap();
+ log::set_logger(LOGGER.get().unwrap())
+ .map(|()| log::set_max_level(level.to_level_filter()))
+ .unwrap();
+ }
+}
+
+impl log::Log for Logger {
+ fn enabled(&self, _: &Metadata) -> bool {
+ true
+ }
+
+ fn log(&self, record: &Record) {
+ cwriteln!(
+ self.file.lock().unwrap_or_else(PoisonError::into_inner),
+ "[{} {:bold_blue}:{:blue}{green}@{:yellow}] {}",
+ match record.level() {
+ Level::Error => cformat_args!("{bold_red}err{reset}"),
+ Level::Warn => cformat_args!("{bold_yellow}wrn{reset}"),
+ Level::Trace => cformat_args!("{magenta}trc{reset}"),
+ Level::Debug => cformat_args!("{green}dbg{reset}"),
+ Level::Info => cformat_args!("{blue}inf{reset}"),
+ },
+ record.file().unwrap_or("<source>"),
+ record.line().unwrap_or(0),
+ humantime::format_duration(self.start.elapsed()),
+ record.args(),
+ )
+ .unwrap();
+ }
+
+ fn flush(&self) {}
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..4e89d88
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,95 @@
+use std::path::PathBuf;
+
+use anyhow::Result;
+use clap::Parser;
+use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture},
+ execute,
+ terminal::{
+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
+ },
+};
+use log::Level as RLevel;
+use ratatui::prelude::*;
+mod cargo;
+mod logger;
+mod test;
+
+#[derive(Parser)]
+/// Kewl cargo addon for dashboards
+struct Args {
+ #[arg(short = 'C')]
+ /// Change to DIRECTORY before doing anything
+ directory: Option<PathBuf>,
+ #[arg(short = 'l')]
+ /// Log to LOG_FILE
+ log_file: Option<PathBuf>,
+ #[arg(default_value = "trace", long = "level")]
+ log_level: Level,
+}
+
+#[repr(usize)]
+#[derive(clap::ValueEnum, Clone, Copy)]
+enum Level {
+ Error = 1,
+ Warn,
+ Info,
+ Debug,
+ Trace,
+}
+
+impl From<Level> for RLevel {
+ fn from(value: Level) -> Self {
+ // SAFETY: same
+ unsafe { std::mem::transmute(value) }
+ }
+}
+macro_rules! ctext {
+ ($fmt:literal $(, $arg:expr)* $(,)?) => {
+ <String as ansi_to_tui::IntoText>::into_text(&comat::cformat!($fmt $(, $arg)*)).expect("WHAT")
+ };
+}
+use ctext;
+
+fn main() -> Result<()> {
+ let args = if std::env::args().next().unwrap().contains(".cargo/bin") {
+ Args::parse_from(std::env::args().skip(1))
+ } else {
+ Args::parse()
+ };
+ if let Some(log) = args.log_file {
+ logger::Logger::init(args.log_level.into(), log);
+ }
+ log::info!("startup");
+ let mut stdout = std::io::stdout();
+ let meta = cargo::meta(
+ args.directory
+ .as_deref()
+ .unwrap_or(&std::env::current_dir()?),
+ )?;
+
+ enable_raw_mode()?;
+ execute!(
+ stdout,
+ EnableMouseCapture,
+ EnterAlternateScreen,
+ SetTitle("testing")
+ )?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+ let h = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |panic| {
+ disable_raw_mode().unwrap();
+ execute!(std::io::stdout(), DisableMouseCapture, LeaveAlternateScreen).unwrap();
+ h(panic);
+ }));
+ let res = test::run(&mut terminal, args.directory.as_deref(), &meta);
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ DisableMouseCapture,
+ LeaveAlternateScreen
+ )?;
+ terminal.show_cursor()?;
+ res
+}
diff --git a/src/test/mod.rs b/src/test/mod.rs
new file mode 100644
index 0000000..7b7d0bb
--- /dev/null
+++ b/src/test/mod.rs
@@ -0,0 +1,170 @@
+mod ui;
+use anyhow::Result;
+use crossbeam::channel::{unbounded, Receiver};
+use crossterm::event::{self, Event, KeyCode};
+use ratatui::prelude::*;
+use ratatui::Terminal;
+use std::path::Path;
+use std::time::{Duration, Instant};
+
+use crate::cargo;
+use crate::cargo::{test, TestEvent, TestMessage, TestResult};
+use crate::test::ui::stdout::Stdout;
+
+#[derive(Default, PartialEq, Eq)]
+pub enum Screen {
+ #[default]
+ Main,
+ Stdout,
+}
+
+enum Test {
+ InProgress { name: String },
+ Succeeded(TestResult),
+ Failed(TestResult),
+ Ignored { name: String },
+}
+
+impl Test {
+ fn name(&self) -> &str {
+ let (Self::InProgress { name }
+ | Self::Succeeded(TestResult { name, .. })
+ | Self::Failed(TestResult { name, .. })
+ | Self::Ignored { name }) = self;
+ name
+ }
+
+ fn stdout(&self) -> Option<&str> {
+ match self {
+ Self::Succeeded(TestResult { stdout, .. })
+ | Self::Failed(TestResult { stdout, .. }) => stdout.as_deref(),
+ _ => None,
+ }
+ }
+}
+
+trait VAt {
+ fn at(&mut self, which: &str) -> Option<&mut Test>;
+}
+
+impl VAt for Vec<Test> {
+ fn at(&mut self, which: &str) -> Option<&mut Test> {
+ let p = self.iter().position(|t| t.name() == which)?;
+ Some(&mut self[p])
+ }
+}
+
+pub struct TestState {
+ tests: Vec<Test>,
+ test_list: ui::test_list::TestList,
+ rx: Receiver<TestMessage>,
+ screen: Screen,
+ test_count: usize,
+ stdout: Stdout,
+ time: f32,
+ done: bool,
+}
+
+impl TestState {
+ pub fn new(dir: Option<&Path>) -> Result<Self> {
+ log::info!("initializing test state");
+ let (tx, rx) = unbounded();
+ let TestEvent::SuiteStart { test_count } = test(tx, dir)? else {
+ panic!("first ev should be suite start")
+ };
+ Ok(Self {
+ test_list: ui::test_list::TestList::default(),
+ tests: vec![],
+ rx,
+ screen: Screen::default(),
+ done: false,
+ test_count,
+ time: 0.,
+ stdout: Stdout::default(),
+ })
+ }
+
+ pub fn recv(&mut self) {
+ if self.done {
+ return;
+ }
+ let deadline = Instant::now() + Duration::from_millis(50);
+ while let Ok(event) = self.rx.recv_deadline(deadline) {
+ log::debug!("got event {event:?}");
+ let event = match event {
+ TestMessage::Event(e) => e,
+ TestMessage::Finished => {
+ self.done = true;
+ return;
+ }
+ };
+ match event {
+ TestEvent::TestStart { name } => {
+ self.tests.push(Test::InProgress { name });
+ }
+ TestEvent::TestOk(r) => {
+ let pre = self.tests.at(&r.name).unwrap();
+ *pre = Test::Succeeded(r);
+ }
+ TestEvent::TestFail(r) => {
+ let pre = self.tests.at(&r.name).unwrap();
+ *pre = Test::Failed(r);
+ }
+ TestEvent::TestIgnore { name } => {
+ let pre = self.tests.at(&name).unwrap();
+ *pre = Test::Ignored { name };
+ }
+ TestEvent::SuiteOk { exec_time, .. } | TestEvent::SuiteFail { exec_time, .. } => {
+ self.time += exec_time;
+ }
+ TestEvent::SuiteStart { test_count } => {
+ log::trace!("have {test_count} tests");
+ self.test_count += test_count;
+ }
+ };
+ }
+ }
+}
+
+pub fn run<B: Backend>(
+ terminal: &mut Terminal<B>,
+ dir: Option<&Path>,
+ meta: &cargo::Metadata,
+) -> Result<()> {
+ let mut state = TestState::new(dir)?;
+ loop {
+ terminal.draw(|f| ui::ui(f, &mut state, &meta))?;
+ if event::poll(Duration::from_millis(5))? {
+ if let Event::Key(key) = event::read()? {
+ match state.screen {
+ Screen::Main => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Down | KeyCode::Char('s') => state.test_list.next(),
+ KeyCode::Up | KeyCode::Char('w') => state.test_list.prev(),
+ KeyCode::Right | KeyCode::Char('d')
+ if state.test_list.stdout(&state).is_some() =>
+ {
+ state.screen = Screen::Stdout;
+ state.stdout.scroll = 0;
+ state.stdout.lines = u16::try_from(
+ state.test_list.stdout(&state).unwrap().lines().count(),
+ )?;
+ }
+ _ => {}
+ },
+ Screen::Stdout => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Down | KeyCode::Char('s') => state.stdout.incr(),
+ KeyCode::Up | KeyCode::Char('w') => state.stdout.decr(),
+ KeyCode::Left | KeyCode::Char('a') => {
+ state.screen = Screen::Main;
+ state.stdout.scroll = 0;
+ }
+ _ => {}
+ },
+ }
+ }
+ }
+ state.recv();
+ }
+}
diff --git a/src/test/ui/inspector.rs b/src/test/ui/inspector.rs
new file mode 100644
index 0000000..5c2b502
--- /dev/null
+++ b/src/test/ui/inspector.rs
@@ -0,0 +1,103 @@
+use ratatui::{
+ layout::Direction::Vertical,
+ prelude::*,
+ style::Stylize,
+ widgets::{Block, BorderType::*, Borders, Paragraph, Wrap},
+ Frame,
+};
+
+use crate::{
+ cargo::TestResult,
+ ctext,
+ test::{Screen, Test, TestState},
+};
+
+pub fn inspector<B: Backend>(f: &mut Frame<B>, state: &TestState, chunk: Rect) {
+ let Some(t) = state.test_list.selects(state) else {
+ return;
+ };
+ let b = Block::default().title("inspect test").borders(Borders::ALL);
+ let stdblock = || {
+ let b = Block::default().borders(Borders::ALL).title("stdout");
+ if state.screen == Screen::Stdout {
+ return b.border_type(Thick).title_style(Style::default().italic());
+ }
+ b
+ };
+ match t {
+ Test::Ignored { name } => {
+ f.render_widget(
+ Paragraph::new(ctext!("test {:bold_yellow} was ignored", name))
+ .alignment(Alignment::Center)
+ .block(b)
+ .wrap(Wrap { trim: true }),
+ chunk,
+ );
+ }
+ Test::Failed(TestResult { name, stdout, .. }) => {
+ if let Some(stdout) = stdout {
+ let chunks = Layout::new()
+ .direction(Vertical)
+ .constraints([Constraint::Percentage(10), Constraint::Percentage(90)])
+ .split(chunk);
+ f.render_widget(
+ Paragraph::new(ctext!("test {:bold_red} failed", name))
+ .alignment(Alignment::Center)
+ .block(b),
+ chunks[0],
+ );
+ f.render_widget(
+ Paragraph::new(<String as ansi_to_tui::IntoText>::into_text(stdout).unwrap())
+ .block(stdblock())
+ .scroll((state.stdout.scroll, 0)),
+ chunks[1],
+ );
+ } else {
+ f.render_widget(
+ Paragraph::new(ctext!("test {:bold_red} failed", name))
+ .alignment(Alignment::Center)
+ .block(b)
+ .wrap(Wrap { trim: true }),
+ chunk,
+ );
+ }
+ }
+ Test::Succeeded(TestResult { name, stdout, .. }) => {
+ if let Some(stdout) = stdout {
+ let chunks = Layout::new()
+ .direction(Vertical)
+ .constraints([Constraint::Percentage(10), Constraint::Percentage(90)])
+ .split(chunk);
+ f.render_widget(
+ Paragraph::new(ctext!("test {:bold_green} passed", name))
+ .alignment(Alignment::Center)
+ .block(b),
+ chunks[0],
+ );
+ f.render_widget(
+ Paragraph::new(<String as ansi_to_tui::IntoText>::into_text(stdout).unwrap())
+ .block(stdblock())
+ .scroll((state.stdout.scroll, 0)),
+ chunks[1],
+ );
+ } else {
+ f.render_widget(
+ Paragraph::new(ctext!("test {:bold_green} passed", name))
+ .alignment(Alignment::Center)
+ .block(b)
+ .wrap(Wrap { trim: true }),
+ chunk,
+ );
+ }
+ }
+ Test::InProgress { name } => {
+ f.render_widget(
+ Paragraph::new(ctext!("test {:bold_yellow} in progress", name))
+ .alignment(Alignment::Center)
+ .block(b)
+ .wrap(Wrap { trim: true }),
+ chunk,
+ );
+ }
+ }
+}
diff --git a/src/test/ui/ls.rs b/src/test/ui/ls.rs
new file mode 100644
index 0000000..e41521c
--- /dev/null
+++ b/src/test/ui/ls.rs
@@ -0,0 +1,38 @@
+use ratatui::widgets::ListState;
+
+#[derive(Default)]
+pub struct SList {
+ pub state: ListState,
+ itemc: usize,
+}
+
+pub const fn incr(what: usize, cap: usize) -> usize {
+ if what >= cap - 1 {
+ 0
+ } else {
+ what + 1
+ }
+}
+
+pub const fn decr(what: usize, cap: usize) -> usize {
+ if what == 0 {
+ cap - 1
+ } else {
+ what - 1
+ }
+}
+
+impl SList {
+ pub fn next(&mut self) {
+ let i = self.state.selected().map_or(0, |x| incr(x, self.itemc));
+ self.state.select(Some(i));
+ }
+
+ pub fn prev(&mut self) {
+ let i = self.state.selected().map_or(0, |x| decr(x, self.itemc));
+ self.state.select(Some(i));
+ }
+ pub fn has(&mut self, n: usize) {
+ self.itemc = n;
+ }
+}
diff --git a/src/test/ui/mod.rs b/src/test/ui/mod.rs
new file mode 100644
index 0000000..40fcb59
--- /dev/null
+++ b/src/test/ui/mod.rs
@@ -0,0 +1,88 @@
+use ratatui::{
+ layout::{
+ Constraint::{Length, Min, Percentage},
+ Direction::{Horizontal, Vertical},
+ },
+ prelude::*,
+ widgets::{Block, BorderType::Rounded, Borders, Paragraph},
+ Frame,
+};
+
+mod inspector;
+mod ls;
+mod progress;
+pub mod stdout;
+pub mod test_list;
+use super::{Screen, Test};
+use crate::cargo;
+use crate::ctext;
+
+pub fn ui<B: Backend>(f: &mut Frame<B>, state: &mut super::TestState, meta: &cargo::Metadata) {
+ let chunks = Layout::default()
+ .direction(Vertical)
+ .constraints([Length(3), Min(1), Length(1)])
+ .split(f.size());
+ let title_chunks = Layout::default()
+ .direction(Horizontal)
+ .constraints([Percentage(10), Percentage(80)])
+ .split(chunks[0]);
+ f.render_widget(
+ Paragraph::new(ctext!(
+ "{green}testing {:bold_cyan}{reset}",
+ meta.package.name
+ ))
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_type(Rounded)
+ .style(Style::default()),
+ ),
+ title_chunks[0],
+ );
+ progress::progress(f, state, title_chunks[1]);
+ if state.test_list.selects(state).is_some() {
+ let main_panels = match state.screen {
+ Screen::Main => Layout::default()
+ .direction(Horizontal)
+ .constraints([Percentage(80), Percentage(20)])
+ .split(chunks[1]),
+ Screen::Stdout => Layout::default()
+ .direction(Horizontal)
+ .constraints([Percentage(60), Percentage(40)])
+ .split(chunks[1]),
+ };
+ test_list::test_list(f, state, main_panels[0]);
+ inspector::inspector(f, state, main_panels[1]);
+ } else {
+ test_list::test_list(f, state, chunks[1]);
+ }
+ let footer_chunks = Layout::default()
+ .direction(Horizontal)
+ .constraints([Percentage(50), Percentage(50)])
+ .split(chunks[2]);
+ let usage = match state.screen {
+ Screen::Main => match state.test_list.selects(state) {
+ Some(t) if t.stdout().is_some() => {
+ Paragraph::new(ctext!("press {green}right{reset} to view the stdout"))
+ }
+ _ => Paragraph::new(ctext!(
+ "press {green}up{reset} or {red}down{reset} to change selection"
+ )),
+ },
+ Screen::Stdout => {
+ Paragraph::new(ctext!("press {blue}left{reset} to go back to tests | press {green}up{reset} or {red}down{reset} to scroll stdout"))
+ }
+ };
+ f.render_widget(usage, footer_chunks[0]);
+ let status = match state.screen {
+ Screen::Main => match state.test_list.selects(state) {
+ Some(t) => Paragraph::new(ctext!("viewing test {:blue}", t.name())),
+ None => Paragraph::new("listing tests"),
+ },
+ Screen::Stdout => Paragraph::new(ctext!(
+ "viewing stdout of test {:blue}",
+ state.test_list.selects(state).unwrap().name()
+ )),
+ };
+ f.render_widget(status, footer_chunks[1]);
+}
diff --git a/src/test/ui/progress.rs b/src/test/ui/progress.rs
new file mode 100644
index 0000000..d30c755
--- /dev/null
+++ b/src/test/ui/progress.rs
@@ -0,0 +1,39 @@
+use ratatui::{
+ prelude::*,
+ widgets::{Block, BorderType::Rounded, Borders, Paragraph},
+ Frame,
+};
+
+use crate::{
+ ctext,
+ test::{Test, TestState},
+};
+
+pub fn progress<B: Backend>(f: &mut Frame<B>, state: &TestState, chunk: Rect) {
+ let size =
+ |n| (n as f32 / state.test_count as f32 * f32::from(chunk.width)).round() as usize * 3;
+ const LINE: &str = "──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
+ let mut passing = 0;
+ let mut ignored = 0;
+ let mut failing = 0;
+ let mut running = 0;
+ for test in &state.tests {
+ match test {
+ Test::Succeeded(_) => passing += 1,
+ Test::Ignored { .. } => ignored += 1,
+ Test::Failed(_) => failing += 1,
+ Test::InProgress { .. } => running += 1,
+ }
+ }
+ let progress = Paragraph::new(ctext!(
+ "{:cyan}{:green}{:red}{:yellow}",
+ &LINE[..size(ignored)],
+ &LINE[..size(passing)],
+ &LINE[..size(failing)],
+ &LINE[..size(running)],
+ ));
+ f.render_widget(
+ progress.block(Block::default().borders(Borders::ALL).border_type(Rounded)),
+ chunk,
+ );
+}
diff --git a/src/test/ui/stdout.rs b/src/test/ui/stdout.rs
new file mode 100644
index 0000000..c645c4f
--- /dev/null
+++ b/src/test/ui/stdout.rs
@@ -0,0 +1,15 @@
+#[derive(Default)]
+pub struct Stdout {
+ pub scroll: u16,
+ pub lines: u16,
+}
+
+impl Stdout {
+ pub fn incr(&mut self) {
+ self.scroll = std::cmp::min(self.lines, self.scroll + 1);
+ }
+
+ pub fn decr(&mut self) {
+ self.scroll = self.scroll.saturating_sub(1);
+ }
+}
diff --git a/src/test/ui/test_list.rs b/src/test/ui/test_list.rs
new file mode 100644
index 0000000..c4d9da2
--- /dev/null
+++ b/src/test/ui/test_list.rs
@@ -0,0 +1,130 @@
+use crate::cargo::TestResult;
+use crate::test::TestState;
+
+use super::ls::SList;
+use super::Test;
+use ratatui::{
+ layout::{Constraint::Percentage, Direction::Horizontal},
+ prelude::*,
+ style::Stylize,
+ widgets::{Block, Borders, List, ListItem},
+ Frame,
+};
+use std::time::Duration;
+#[derive(Default)]
+pub struct TestList {
+ a: SList,
+ b: SList,
+ c: SList,
+}
+
+trait RExt<'a> {
+ fn pl(&mut self, list: impl Into<Line<'a>>);
+}
+
+impl<'a> RExt<'a> for Vec<ListItem<'a>> {
+ fn pl(&mut self, list: impl Into<Line<'a>>) {
+ self.push(ListItem::new(list.into()));
+ }
+}
+
+impl TestList {
+ fn has(&mut self, n: usize) {
+ self.all().map(|a| a.has(n));
+ }
+
+ fn all(&mut self) -> [&mut SList; 3] {
+ [&mut self.a, &mut self.b, &mut self.c]
+ }
+
+ pub fn next(&mut self) {
+ self.all().map(SList::next);
+ }
+
+ pub fn prev(&mut self) {
+ self.all().map(SList::prev);
+ }
+
+ pub fn selects<'a>(&'a self, state: &'a TestState) -> Option<&Test> {
+ state.tests.get(self.a.state.selected()?)
+ }
+
+ pub fn stdout<'a>(&'a self, state: &'a TestState) -> Option<&str> {
+ self.selects(state)?.stdout()
+ }
+}
+
+pub fn test_list<B: Backend>(f: &mut Frame<B>, state: &mut TestState, chunk: Rect) {
+ let mut tests = Vec::<ListItem>::new();
+ let mut test_side1 = Vec::<ListItem>::new();
+ let mut test_side2 = Vec::<ListItem>::new();
+ fn time<'v>(secs: f32) -> Line<'v> {
+ let dur = Duration::from_secs_f32(secs);
+ let time = humantime::format_duration(dur).to_string();
+ match (secs / 16.).round() as usize {
+ 0 => Line::styled(time, Style::default().green()),
+ 1 => Line::styled(time, Style::default().yellow()),
+ _ => Line::styled(time, Style::default().red()),
+ }
+ }
+ for test in &state.tests {
+ match test {
+ Test::InProgress { name } => {
+ tests.pl(name.bold().yellow());
+ test_side1.pl("in progress".yellow().italic());
+ test_side2.pl("");
+ }
+ Test::Succeeded(TestResult {
+ name,
+ exec_time,
+ stdout: _,
+ }) => {
+ tests.pl(name.bold().green());
+ test_side1.pl("passed".green().italic());
+ test_side2.pl(time(*exec_time));
+ }
+ Test::Failed(TestResult {
+ name,
+ exec_time,
+ stdout: _,
+ }) => {
+ tests.pl(name.bold().red());
+ test_side1.pl("failed".red().bold().italic());
+ test_side2.pl(time(*exec_time));
+ }
+ Test::Ignored { name } => {
+ tests.pl(name.bold().yellow());
+ test_side1.pl("ignored".yellow().italic());
+ test_side2.pl("");
+ }
+ }
+ }
+ let sides = Layout::default()
+ .direction(Horizontal)
+ .constraints([Percentage(80), Percentage(10), Percentage(10)])
+ .split(chunk);
+ let hl = Style::default().on_light_green().italic();
+ state.test_list.has(tests.len());
+ f.render_stateful_widget(
+ List::new(tests)
+ .highlight_style(hl)
+ .highlight_symbol("> ")
+ .block(Block::default().borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM)),
+ sides[0],
+ &mut state.test_list.a.state,
+ );
+ f.render_stateful_widget(
+ List::new(test_side1)
+ .highlight_style(hl)
+ .block(Block::default().borders(Borders::TOP | Borders::BOTTOM)),
+ sides[1],
+ &mut state.test_list.b.state,
+ );
+ f.render_stateful_widget(
+ List::new(test_side2)
+ .highlight_style(hl)
+ .block(Block::default().borders(Borders::TOP | Borders::BOTTOM | Borders::RIGHT)),
+ sides[2],
+ &mut state.test_list.c.state,
+ );
+}