diff --git a/.gitignore b/.gitignore index 587821670..086b82ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /.bundle /Cargo.lock /target -/tmp +/ruby/tmp /ruby/LICENSE /ruby/VERSION diff --git a/manager/Cargo.toml b/manager/Cargo.toml index 8e34d1eaa..8c44cf6a0 100644 --- a/manager/Cargo.toml +++ b/manager/Cargo.toml @@ -12,4 +12,4 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } -toml = "0.8" +ratatui = "0.29" diff --git a/manager/src/check.rs b/manager/src/check.rs new file mode 100644 index 000000000..732c65692 --- /dev/null +++ b/manager/src/check.rs @@ -0,0 +1,284 @@ +use crate::tasks; +use ratatui::{ + TerminalOptions, Viewport, + crossterm::{ + ExecutableCommand, cursor, + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode}, + }, + prelude::*, + widgets::{Cell, Row, Table}, +}; +use std::{ + io::{BufRead, BufReader, stdout}, + process::{Child, Command, Stdio}, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +#[derive(Clone, PartialEq)] +enum TaskStatus { + Pending, + Running, + Success, + Failed, +} + +#[derive(Clone)] +struct TaskState { + status: TaskStatus, + last_line: String, + full_output: Vec, +} + +impl Default for TaskState { + fn default() -> Self { + Self { + status: TaskStatus::Pending, + last_line: String::new(), + full_output: Vec::new(), + } + } +} + +struct AppState { + tasks: Vec, +} + +pub fn run() { + crate::prepare(); + + let state = Arc::new(Mutex::new(AppState { + tasks: vec![TaskState::default(); tasks::ALL.len()], + })); + + let mut handles: Vec> = Vec::new(); + let mut children: Vec>>> = Vec::new(); + + for (index, task) in tasks::ALL.iter().enumerate() { + let state = Arc::clone(&state); + let child_holder: Arc>> = Arc::new(Mutex::new(None)); + let child_holder_clone = Arc::clone(&child_holder); + children.push(child_holder); + + let args: Vec<&'static str> = task.args.to_vec(); + + let handle = thread::spawn(move || { + run_task(index, &args, state, child_holder_clone); + }); + handles.push(handle); + } + + if let Err(error) = run_tui(Arc::clone(&state)) { + eprintln!("TUI error: {}", error); + } + + for child_holder in &children { + if let Some(ref mut child) = *child_holder.lock().unwrap() { + let _ = child.kill(); + } + } + + for handle in handles { + let _ = handle.join(); + } + + let final_state = state.lock().unwrap(); + let failed_tasks: Vec<_> = tasks::ALL + .iter() + .zip(final_state.tasks.iter()) + .filter(|(_, task_state)| task_state.status == TaskStatus::Failed) + .collect(); + + if !failed_tasks.is_empty() { + println!("\n\x1b[1;31m=== Failed Tasks ===\x1b[0m\n"); + for (task, task_state) in failed_tasks { + println!("\x1b[1;31m--- {} ---\x1b[0m", task.name); + for line in &task_state.full_output { + println!("{}", line); + } + println!(); + } + } + + let failed = final_state + .tasks + .iter() + .any(|t| t.status == TaskStatus::Failed); + + std::process::exit(if failed { 1 } else { 0 }); +} + +fn run_task( + index: usize, + args: &[&str], + state: Arc>, + child_holder: Arc>>, +) { + { + let mut state = state.lock().unwrap(); + state.tasks[index].status = TaskStatus::Running; + } + + let mut child = match Command::new("bundle") + .arg("exec") + .args(args) + .current_dir("ruby") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(error) => { + let mut state = state.lock().unwrap(); + state.tasks[index].status = TaskStatus::Failed; + state.tasks[index].last_line = format!("Failed to spawn: {}", error); + return; + } + }; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + *child_holder.lock().unwrap() = Some(child); + + let state_clone = Arc::clone(&state); + let stdout_handle = stdout.map(|out| { + let state = Arc::clone(&state_clone); + thread::spawn(move || { + let reader = BufReader::new(out); + for line in reader.lines().map_while(Result::ok) { + let mut state = state.lock().unwrap(); + state.tasks[index].last_line = line.clone(); + state.tasks[index].full_output.push(line); + } + }) + }); + + let stderr_handle = stderr.map(|err| { + let state = Arc::clone(&state); + thread::spawn(move || { + let reader = BufReader::new(err); + for line in reader.lines().map_while(Result::ok) { + let mut state = state.lock().unwrap(); + state.tasks[index].last_line = line.clone(); + state.tasks[index].full_output.push(line); + } + }) + }); + + if let Some(handle) = stdout_handle { + let _ = handle.join(); + } + if let Some(handle) = stderr_handle { + let _ = handle.join(); + } + + let exit_status = child_holder + .lock() + .unwrap() + .as_mut() + .and_then(|c| c.wait().ok()); + + let mut state = state.lock().unwrap(); + state.tasks[index].status = match exit_status { + Some(status) if status.success() => TaskStatus::Success, + _ => TaskStatus::Failed, + }; +} + +fn run_tui(state: Arc>) -> std::io::Result<()> { + let num_lines = tasks::ALL.len() as u16 + 2; // +2 for header and spacing + + // Reserve space for our output + for _ in 0..num_lines { + println!(); + } + stdout().execute(cursor::MoveUp(num_lines))?; + stdout().execute(cursor::SavePosition)?; + + enable_raw_mode()?; + + let mut terminal = Terminal::with_options( + CrosstermBackend::new(stdout()), + TerminalOptions { + viewport: Viewport::Inline(num_lines), + }, + )?; + + loop { + terminal.draw(|frame| { + let state = state.lock().unwrap(); + render(frame, &state); + })?; + + if event::poll(Duration::from_millis(100))? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + && key.code == KeyCode::Char('q') + { + break; + } + + let state = state.lock().unwrap(); + let all_done = state + .tasks + .iter() + .all(|t| t.status == TaskStatus::Success || t.status == TaskStatus::Failed); + if all_done { + drop(state); + thread::sleep(Duration::from_secs(1)); + break; + } + } + + disable_raw_mode()?; + stdout().execute(cursor::MoveDown(num_lines))?; + println!(); + + Ok(()) +} + +fn render(frame: &mut Frame, state: &AppState) { + let rows: Vec = tasks::ALL + .iter() + .zip(state.tasks.iter()) + .map(|(task, task_state)| { + let status = match task_state.status { + TaskStatus::Pending => ("⏳", Style::default().fg(Color::Gray)), + TaskStatus::Running => ("🔄", Style::default().fg(Color::Yellow)), + TaskStatus::Success => ("✓", Style::default().fg(Color::Green)), + TaskStatus::Failed => ("✗", Style::default().fg(Color::Red)), + }; + + Row::new(vec![ + Cell::from(status.0).style(status.1), + Cell::from(task.name), + Cell::from(truncate_line(&task_state.last_line, 80)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(3), + Constraint::Length(28), + Constraint::Fill(1), + ]; + + let table = Table::new(rows, widths).header( + Row::new(vec!["", "Task", "Output"]) + .style(Style::default().bold()) + .bottom_margin(1), + ); + + frame.render_widget(table, frame.area()); +} + +fn truncate_line(line: &str, max_len: usize) -> String { + if line.len() > max_len { + format!("{}...", &line[..max_len - 3]) + } else { + line.to_string() + } +} diff --git a/manager/src/main.rs b/manager/src/main.rs index b331131aa..ac1b7d3a4 100644 --- a/manager/src/main.rs +++ b/manager/src/main.rs @@ -3,6 +3,9 @@ use std::fs; use std::os::unix::process::CommandExt; use std::process::Command; +mod check; +mod tasks; + #[derive(Parser)] #[command(name = "manager")] #[command(about = "Mutant development manager")] @@ -13,6 +16,8 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Run all checks in parallel with TUI + Check, /// Execute commands in Ruby environment Ruby { #[command(subcommand)] @@ -94,53 +99,38 @@ fn main() { let cli = Cli::parse(); match cli.command { + Commands::Check => check::run(), Commands::Ruby { action } => { prepare(); match action { Ruby::Prepare => {} Ruby::Exec { arguments } => bundle_exec(&arguments), Ruby::Rspec { action } => match action { - Rspec::SpecUnit { arguments } => { - bundle_exec_with_args(&["rspec", "spec/unit"], &arguments) + Rspec::SpecUnit { arguments } => run_task(&tasks::RSPEC_SPEC_UNIT, &arguments), + Rspec::IntegrationMisc { arguments } => { + run_task(&tasks::RSPEC_INTEGRATION_MISC, &arguments) } - Rspec::IntegrationMisc { arguments } => bundle_exec_with_args( - &[ - "rspec", - "spec/integration/mutant/null_spec.rb", - "spec/integration/mutant/isolation/fork_spec.rb", - "spec/integration/mutant/test_mutator_handles_types_spec.rb", - "spec/integration/mutant/parallel_spec.rb", - ], - &arguments, - ), - Rspec::IntegrationMinitest { arguments } => bundle_exec_with_args( - &["rspec", "spec/integration", "-e", "minitest"], - &arguments, - ), - Rspec::IntegrationRspec { arguments } => bundle_exec_with_args( - &["rspec", "spec/integration", "-e", "rspec"], - &arguments, - ), - Rspec::IntegrationGeneration { arguments } => bundle_exec_with_args( - &["rspec", "spec/integration", "-e", "generation"], - &arguments, - ), - }, - Ruby::Mutant { action } => match action { - Mutant::Test { arguments } => { - bundle_exec_with_args(&["mutant", "test", "spec/unit"], &arguments) + Rspec::IntegrationMinitest { arguments } => { + run_task(&tasks::RSPEC_INTEGRATION_MINITEST, &arguments) } - Mutant::Run { arguments } => { - bundle_exec_with_args(&["mutant", "run"], &arguments) + Rspec::IntegrationRspec { arguments } => { + run_task(&tasks::RSPEC_INTEGRATION_RSPEC, &arguments) } + Rspec::IntegrationGeneration { arguments } => { + run_task(&tasks::RSPEC_INTEGRATION_GENERATION, &arguments) + } + }, + Ruby::Mutant { action } => match action { + Mutant::Test { arguments } => run_task(&tasks::MUTANT_TEST, &arguments), + Mutant::Run { arguments } => run_task(&tasks::MUTANT_RUN, &arguments), }, - Ruby::Rubocop { arguments } => bundle_exec_with_args(&["rubocop"], &arguments), + Ruby::Rubocop { arguments } => run_task(&tasks::RUBOCOP, &arguments), } } } } -fn prepare() { +pub fn prepare() { fs::write("ruby/VERSION", format!("{}\n", env!("CARGO_PKG_VERSION"))).unwrap_or_else(|error| { panic!("Failed to write ruby/VERSION: {}", error); }); @@ -150,8 +140,8 @@ fn prepare() { }); } -fn bundle_exec_with_args(base_args: &[&str], extra_args: &[String]) { - let mut args: Vec<&str> = base_args.to_vec(); +fn run_task(task: &tasks::Task, extra_args: &[String]) { + let mut args: Vec<&str> = task.args.to_vec(); args.extend(extra_args.iter().map(|s| s.as_str())); bundle_exec(&args) } diff --git a/manager/src/tasks.rs b/manager/src/tasks.rs new file mode 100644 index 000000000..bd543b73a --- /dev/null +++ b/manager/src/tasks.rs @@ -0,0 +1,60 @@ +pub struct Task { + pub name: &'static str, + pub args: &'static [&'static str], +} + +pub const RSPEC_SPEC_UNIT: Task = Task { + name: "rspec spec-unit", + args: &["rspec", "spec/unit"], +}; + +pub const MUTANT_TEST: Task = Task { + name: "mutant test", + args: &["mutant", "test", "spec/unit"], +}; + +pub const MUTANT_RUN: Task = Task { + name: "mutant run", + args: &["mutant", "run"], +}; + +pub const RSPEC_INTEGRATION_MISC: Task = Task { + name: "rspec integration-misc", + args: &[ + "rspec", + "spec/integration/mutant/null_spec.rb", + "spec/integration/mutant/isolation/fork_spec.rb", + "spec/integration/mutant/test_mutator_handles_types_spec.rb", + "spec/integration/mutant/parallel_spec.rb", + ], +}; + +pub const RSPEC_INTEGRATION_MINITEST: Task = Task { + name: "rspec integration-minitest", + args: &["rspec", "spec/integration", "-e", "minitest"], +}; + +pub const RSPEC_INTEGRATION_RSPEC: Task = Task { + name: "rspec integration-rspec", + args: &["rspec", "spec/integration", "-e", "rspec"], +}; + +pub const RSPEC_INTEGRATION_GENERATION: Task = Task { + name: "rspec integration-generation", + args: &["rspec", "spec/integration", "-e", "generation"], +}; + +pub const RUBOCOP: Task = Task { + name: "rubocop", + args: &["rubocop"], +}; + +pub const ALL: &[&Task] = &[ + &RSPEC_SPEC_UNIT, + &MUTANT_TEST, + &RSPEC_INTEGRATION_MISC, + &RSPEC_INTEGRATION_MINITEST, + &RSPEC_INTEGRATION_RSPEC, + &RSPEC_INTEGRATION_GENERATION, + &RUBOCOP, +];