1use anstyle::{AnsiColor as TermAnsiColor, Style as TermStyle};
2use std::io::{self, Write};
3use std::process::ExitCode;
4use std::sync::mpsc;
5use std::thread;
6
7use only_semantic::{ShellKind, ShellSelection};
8
9use crate::error::{
10 command_block_failed, command_block_start_failed, command_failed, task_failure,
11};
12use crate::interpolate::{interpolate, interpolate_with_parts};
13use crate::process::{OutputChunk, OutputStream};
14use crate::shell::{run_command, run_command_inherit};
15use crate::{EngineError, ExecutionNode, ExecutionPlan, ExecutionStep, PlanParam};
16
17pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode, EngineError> {
25 run_plan_with_options(plan, RuntimeOptions::default())
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub struct RuntimeOptions {
37 pub quiet: bool,
38}
39
40pub fn run_plan_with_options(
49 plan: &ExecutionPlan,
50 options: RuntimeOptions,
51) -> Result<ExitCode, EngineError> {
52 let total_tasks = plan.nodes.len();
53 let mut task_index = 0usize;
54
55 while task_index < total_tasks {
56 let stage = plan.nodes[task_index].stage;
57 let stage_start = task_index;
58 while task_index < total_tasks && plan.nodes[task_index].stage == stage {
59 task_index += 1;
60 }
61 let stage_nodes = &plan.nodes[stage_start..task_index];
62
63 if !options.quiet {
64 for (offset, node) in stage_nodes.iter().enumerate() {
65 eprintln!(
66 "{}",
67 render_task_progress(stage_start + offset + 1, total_tasks, &node.name)
68 );
69 }
70 }
71
72 if stage_nodes.len() == 1 {
73 run_node_inherit(
74 &stage_nodes[0],
75 &plan.working_dir,
76 plan.shell.as_ref(),
77 options.quiet,
78 )?;
79 } else {
80 execute_stage(
81 stage_nodes,
82 &plan.working_dir,
83 plan.shell.as_ref(),
84 options.quiet,
85 )?;
86 }
87 }
88
89 Ok(ExitCode::SUCCESS)
90}
91
92fn execute_stage(
93 stage_nodes: &[ExecutionNode],
94 working_dir: &std::path::Path,
95 default_shell: Option<&ShellKind>,
96 quiet: bool,
97) -> Result<(), EngineError> {
98 let stage_len = stage_nodes.len();
99 let (event_tx, event_rx) = mpsc::channel::<StageEvent>();
100
101 thread::scope(|scope| {
102 let mut handles = Vec::new();
103
104 for (index, node) in stage_nodes.iter().cloned().enumerate() {
105 let working_dir = working_dir.to_path_buf();
106 let shell = default_shell.cloned();
107 let event_tx = event_tx.clone();
108 handles.push(
109 scope.spawn(move || run_node(index, &node, &working_dir, shell.as_ref(), event_tx)),
110 );
111 }
112 drop(event_tx);
113
114 run_ordered_output_event_loop(&event_rx, stage_len, quiet)?;
115
116 collect_thread_results(handles)
117 })
118}
119
120fn run_ordered_output_event_loop(
121 event_rx: &mpsc::Receiver<StageEvent>,
122 stage_len: usize,
123 quiet: bool,
124) -> Result<(), EngineError> {
125 let mut buffers = vec![Vec::<OutputChunk>::new(); stage_len];
126 let mut finished = vec![false; stage_len];
127 let mut results = (0..stage_len)
128 .map(|_| None)
129 .collect::<Vec<Option<TaskResult>>>();
130 let mut task_errors = (0..stage_len)
131 .map(|_| None)
132 .collect::<Vec<Option<EngineError>>>();
133 let mut current_index = 0usize;
134 let mut finished_count = 0usize;
135 let mut first_error = None;
136
137 while finished_count < stage_len {
138 match event_rx.recv() {
139 Ok(StageEvent::Output { task_index, chunk }) => {
140 if task_index == current_index {
141 print_output_chunk(&chunk)?;
142 } else {
143 buffers[task_index].push(chunk);
144 }
145 }
146 Ok(StageEvent::Finished {
147 task_index,
148 error,
149 result,
150 }) => {
151 finished[task_index] = true;
152 task_errors[task_index] = error;
153 results[task_index] = result;
154 finished_count += 1;
155
156 while current_index < stage_len {
157 flush_task_buffer(&mut buffers[current_index])?;
158 if !finished[current_index] {
159 break;
160 }
161 let mut task_error = task_errors[current_index].take();
162 match results[current_index].take() {
163 Some(TaskResult::Pass(message)) if !quiet => print_task_message(&message)?,
164 Some(TaskResult::Fail(message)) => {
165 if let Some(error) = task_error.take() {
166 task_error = Some(task_failure(error, message));
167 }
168 }
169 Some(TaskResult::Pass(_)) | None => {}
170 }
171 if first_error.is_none() {
172 first_error = task_error;
173 }
174 current_index += 1;
175 }
176 }
177 Err(_) => break,
178 }
179 }
180
181 match first_error {
182 Some(error) => Err(error),
183 None => Ok(()),
184 }
185}
186
187fn collect_thread_results(
188 handles: Vec<thread::ScopedJoinHandle<'_, Result<(), EngineError>>>,
189) -> Result<(), EngineError> {
190 let mut first_error = None;
191 for handle in handles {
192 match handle.join() {
193 Ok(Ok(())) => {}
194 Ok(Err(error)) => {
195 if first_error.is_none() {
196 first_error = Some(error);
197 }
198 }
199 Err(payload) => std::panic::resume_unwind(payload),
200 }
201 }
202 match first_error {
203 Some(error) => Err(error),
204 None => Ok(()),
205 }
206}
207
208fn run_node(
209 task_index: usize,
210 node: &ExecutionNode,
211 working_dir: &std::path::Path,
212 default_shell: Option<&ShellKind>,
213 event_tx: mpsc::Sender<StageEvent>,
214) -> Result<(), EngineError> {
215 let (output_tx, output_rx) = mpsc::channel::<OutputChunk>();
216 let forwarder = thread::spawn({
217 let event_tx = event_tx.clone();
218 move || -> Result<(), EngineError> {
219 while let Ok(chunk) = output_rx.recv() {
220 event_tx
221 .send(StageEvent::Output { task_index, chunk })
222 .map_err(|_| {
223 EngineError::Runtime("failed to forward task output".to_string())
224 })?;
225 }
226 Ok(())
227 }
228 });
229
230 let total_steps = node.steps.len();
231 let mut task_error = None;
232
233 for (index, step) in node.steps.iter().enumerate() {
234 let rendered = match interpolate_step(step, &node.params) {
235 Ok(rendered) => rendered,
236 Err(error) => {
237 task_error = Some(error);
238 break;
239 }
240 };
241
242 let shell = select_shell(node, default_shell);
243 let status = match run_command(&rendered, working_dir, &shell, output_tx.clone()) {
244 Ok(status) => status,
245 Err(error) => {
246 task_error = Some(if step.is_block() {
247 command_block_start_failed(shell.kind.as_str(), error)
248 } else {
249 error
250 });
251 break;
252 }
253 };
254
255 if let Some(reason) = status.failure_reason() {
256 task_error = Some(if step.is_block() {
257 command_block_failed(&node.name, index + 1, total_steps, reason)
258 } else {
259 command_failed(&node.name, index + 1, total_steps, &rendered, reason)
260 });
261 break;
262 }
263 }
264
265 drop(output_tx);
266 match forwarder.join() {
267 Ok(Ok(())) => {}
268 Ok(Err(error)) => {
269 if task_error.is_none() {
270 task_error = Some(error);
271 }
272 }
273 Err(_) => {
274 if task_error.is_none() {
275 task_error = Some(EngineError::Runtime(
276 "task output forwarder thread panicked".to_string(),
277 ));
278 }
279 }
280 }
281
282 let result = match task_result_message(node, task_error.is_none()) {
283 Ok(message) => message.map(|message| {
284 if task_error.is_none() {
285 TaskResult::Pass(message)
286 } else {
287 TaskResult::Fail(message)
288 }
289 }),
290 Err(error) => {
291 if task_error.is_none() {
292 task_error = Some(error);
293 }
294 None
295 }
296 };
297
298 event_tx
299 .send(StageEvent::Finished {
300 task_index,
301 error: task_error,
302 result,
303 })
304 .map_err(|_| EngineError::Runtime("failed to finalize task output".to_string()))?;
305 Ok(())
306}
307
308fn run_node_inherit(
309 node: &ExecutionNode,
310 working_dir: &std::path::Path,
311 default_shell: Option<&ShellKind>,
312 quiet: bool,
313) -> Result<(), EngineError> {
314 let total_steps = node.steps.len();
315 let execution = (|| {
316 for (index, step) in node.steps.iter().enumerate() {
317 let rendered = interpolate_step(step, &node.params)?;
318 let shell = select_shell(node, default_shell);
319 let status = run_command_inherit(&rendered, working_dir, &shell).map_err(|error| {
320 if step.is_block() {
321 command_block_start_failed(shell.kind.as_str(), error)
322 } else {
323 error
324 }
325 })?;
326
327 if let Some(reason) = status.failure_reason() {
328 return Err(if step.is_block() {
329 command_block_failed(&node.name, index + 1, total_steps, reason)
330 } else {
331 command_failed(&node.name, index + 1, total_steps, &rendered, reason)
332 });
333 }
334 }
335 Ok::<(), EngineError>(())
336 })();
337
338 match execution {
339 Ok(()) => {
340 if !quiet && let Some(message) = task_result_message(node, true)? {
341 print_task_message(&message)?;
342 }
343 Ok(())
344 }
345 Err(error) => {
346 if let Some(message) = task_result_message(node, false)? {
347 Err(task_failure(error, message))
348 } else {
349 Err(error)
350 }
351 }
352 }
353}
354
355fn interpolate_step(step: &ExecutionStep, params: &[PlanParam]) -> Result<String, EngineError> {
356 match step {
357 ExecutionStep::Command {
358 source,
359 interpolations,
360 }
361 | ExecutionStep::CommandBlock {
362 source,
363 interpolations,
364 ..
365 } => interpolate_with_parts(source, interpolations, params),
366 }
367}
368
369fn select_shell(node: &ExecutionNode, default_shell: Option<&ShellKind>) -> ShellSelection {
370 node.shell.clone().unwrap_or_else(|| {
371 ShellSelection::required(default_shell.cloned().unwrap_or(ShellKind::Deno))
372 })
373}
374
375#[derive(Debug)]
376enum StageEvent {
377 Output {
378 task_index: usize,
379 chunk: OutputChunk,
380 },
381 Finished {
382 task_index: usize,
383 error: Option<EngineError>,
384 result: Option<TaskResult>,
385 },
386}
387
388#[derive(Debug)]
389enum TaskResult {
390 Pass(String),
391 Fail(String),
392}
393
394fn task_result_message(node: &ExecutionNode, success: bool) -> Result<Option<String>, EngineError> {
395 let source = if success {
396 node.pass.as_deref()
397 } else {
398 node.fail.as_deref()
399 };
400 source
401 .map(|text| interpolate(text, &node.result_params))
402 .transpose()
403}
404
405fn print_task_message(message: &str) -> Result<(), EngineError> {
406 let style = TermStyle::new()
407 .fg_color(Some(TermAnsiColor::BrightGreen.into()))
408 .bold();
409 let rendered = format!("{}{}{}\n", style.render(), message, style.render_reset());
410 write_output(&rendered, io::stderr())
411}
412
413fn flush_task_buffer(buffer: &mut Vec<OutputChunk>) -> Result<(), EngineError> {
414 for chunk in buffer.drain(..) {
415 print_output_chunk(&chunk)?;
416 }
417 Ok(())
418}
419
420fn print_output_chunk(chunk: &OutputChunk) -> Result<(), EngineError> {
421 match chunk.stream {
422 OutputStream::Stdout => write_output(&chunk.text, io::stdout()),
423 OutputStream::Stderr => write_output(&chunk.text, io::stderr()),
424 }
425}
426
427fn write_output(content: &str, mut writer: impl Write) -> Result<(), EngineError> {
428 write!(writer, "{content}")
429 .map_err(|error| EngineError::Runtime(format!("failed to write task output: {error}")))?;
430
431 writer
432 .flush()
433 .map_err(|error| EngineError::Runtime(format!("failed to flush task output: {error}")))?;
434 Ok(())
435}
436
437fn render_task_progress(task_index: usize, total_tasks: usize, task_name: &str) -> String {
438 let label_style = TermStyle::new()
439 .fg_color(Some(TermAnsiColor::BrightGreen.into()))
440 .bold();
441 let task_style = TermStyle::new()
442 .fg_color(Some(TermAnsiColor::BrightCyan.into()))
443 .bold();
444
445 format!(
446 "{}[{}/{}]{} {}{}{}",
447 label_style.render(),
448 task_index,
449 total_tasks,
450 label_style.render_reset(),
451 task_style.render(),
452 task_name,
453 task_style.render_reset()
454 )
455}