cargo hollywood
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.toml | 24 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | src/cargo.rs | 255 | ||||
| -rw-r--r-- | src/logger.rs | 57 | ||||
| -rw-r--r-- | src/main.rs | 95 | ||||
| -rw-r--r-- | src/test/mod.rs | 170 | ||||
| -rw-r--r-- | src/test/ui/inspector.rs | 103 | ||||
| -rw-r--r-- | src/test/ui/ls.rs | 38 | ||||
| -rw-r--r-- | src/test/ui/mod.rs | 88 | ||||
| -rw-r--r-- | src/test/ui/progress.rs | 39 | ||||
| -rw-r--r-- | src/test/ui/stdout.rs | 15 | ||||
| -rw-r--r-- | src/test/ui/test_list.rs | 130 |
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" @@ -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 + +[](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, + ); +} |