Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/.bundle
/Cargo.lock
/target
/tmp
/ruby/tmp
/ruby/LICENSE
/ruby/VERSION
2 changes: 1 addition & 1 deletion manager/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ path = "src/main.rs"

[dependencies]
clap = { version = "4.5", features = ["derive"] }
toml = "0.8"
ratatui = "0.29"
284 changes: 284 additions & 0 deletions manager/src/check.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

impl Default for TaskState {
fn default() -> Self {
Self {
status: TaskStatus::Pending,
last_line: String::new(),
full_output: Vec::new(),
}
}
}

struct AppState {
tasks: Vec<TaskState>,
}

pub fn run() {
crate::prepare();

let state = Arc::new(Mutex::new(AppState {
tasks: vec![TaskState::default(); tasks::ALL.len()],
}));

let mut handles: Vec<thread::JoinHandle<()>> = Vec::new();
let mut children: Vec<Arc<Mutex<Option<Child>>>> = Vec::new();

for (index, task) in tasks::ALL.iter().enumerate() {
let state = Arc::clone(&state);
let child_holder: Arc<Mutex<Option<Child>>> = 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<Mutex<AppState>>,
child_holder: Arc<Mutex<Option<Child>>>,
) {
{
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<Mutex<AppState>>) -> 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<Row> = 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()
}
}
58 changes: 24 additions & 34 deletions manager/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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)]
Expand Down Expand Up @@ -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);
});
Expand All @@ -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)
}
Expand Down
Loading
Loading