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 crate::error::command_failed;
8use crate::interpolate::interpolate;
9use crate::process::{OutputChunk, OutputStream};
10use crate::shell::run_command;
11use crate::{EngineError, ExecutionNode, ExecutionPlan};
12
13pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode, EngineError> {
21 let total_tasks = plan.nodes.len();
22 let mut task_index = 0usize;
23
24 while task_index < total_tasks {
25 let stage = plan.nodes[task_index].stage;
26 let stage_start = task_index;
27 while task_index < total_tasks && plan.nodes[task_index].stage == stage {
28 task_index += 1;
29 }
30 let stage_nodes = &plan.nodes[stage_start..task_index];
31
32 for (offset, node) in stage_nodes.iter().enumerate() {
33 eprintln!(
34 "{}",
35 render_task_progress(stage_start + offset + 1, total_tasks, &node.name)
36 );
37 }
38
39 execute_stage(
40 stage_nodes,
41 &plan.working_dir,
42 plan.shell.as_deref(),
43 plan.echo,
44 plan.label,
45 )?;
46 }
47
48 if !plan.echo {
49 eprintln!("{}", render_status("Success", TermAnsiColor::BrightGreen));
50 }
51
52 Ok(ExitCode::SUCCESS)
53}
54
55fn execute_stage(
56 stage_nodes: &[ExecutionNode],
57 working_dir: &std::path::Path,
58 default_shell: Option<&str>,
59 echo: bool,
60 label: bool,
61) -> Result<(), EngineError> {
62 let stage_len = stage_nodes.len();
63 let (event_tx, event_rx) = mpsc::channel::<StageEvent>();
64
65 thread::scope(|scope| {
66 let mut handles = Vec::new();
67
68 for (index, node) in stage_nodes.iter().cloned().enumerate() {
69 let working_dir = working_dir.to_path_buf();
70 let shell = default_shell.map(str::to_string);
71 let event_tx = event_tx.clone();
72 handles.push(
73 scope.spawn(move || {
74 run_node(index, &node, &working_dir, shell.as_deref(), event_tx)
75 }),
76 );
77 }
78 drop(event_tx);
79
80 if echo {
81 run_echo_event_loop(&event_rx, stage_nodes, stage_len, label)?;
82 } else {
83 run_quiet_event_loop(&event_rx, stage_nodes, stage_len, label)?;
84 }
85
86 collect_thread_results(handles)
87 })
88}
89
90fn run_echo_event_loop(
91 event_rx: &mpsc::Receiver<StageEvent>,
92 stage_nodes: &[ExecutionNode],
93 stage_len: usize,
94 label: bool,
95) -> Result<(), EngineError> {
96 let mut buffers = vec![Vec::<OutputChunk>::new(); stage_len];
97 let mut finished = vec![false; stage_len];
98 let mut task_errors = (0..stage_len)
99 .map(|_| None)
100 .collect::<Vec<Option<EngineError>>>();
101 let mut current_index = 0usize;
102 let mut finished_count = 0usize;
103 let mut first_error = None;
104
105 while finished_count < stage_len {
106 match event_rx.recv() {
107 Ok(StageEvent::Output { task_index, chunk }) => {
108 if task_index == current_index {
109 print_output_chunk(&stage_nodes[task_index].name, &chunk, label)?;
110 } else {
111 buffers[task_index].push(chunk);
112 }
113 }
114 Ok(StageEvent::Finished { task_index, error }) => {
115 finished[task_index] = true;
116 task_errors[task_index] = error;
117 finished_count += 1;
118
119 while current_index < stage_len {
120 flush_task_buffer(
121 &stage_nodes[current_index].name,
122 &mut buffers[current_index],
123 label,
124 )?;
125 if !finished[current_index] {
126 break;
127 }
128 if first_error.is_none() {
129 first_error = task_errors[current_index].take();
130 }
131 current_index += 1;
132 }
133 }
134 Err(_) => break,
135 }
136 }
137
138 match first_error {
139 Some(error) => Err(error),
140 None => Ok(()),
141 }
142}
143
144fn run_quiet_event_loop(
145 event_rx: &mpsc::Receiver<StageEvent>,
146 stage_nodes: &[ExecutionNode],
147 stage_len: usize,
148 label: bool,
149) -> Result<(), EngineError> {
150 let mut stderr_buffers = vec![Vec::<OutputChunk>::new(); stage_len];
151 let mut task_errors = (0..stage_len)
152 .map(|_| None)
153 .collect::<Vec<Option<EngineError>>>();
154 let mut finished_count = 0usize;
155
156 while finished_count < stage_len {
157 match event_rx.recv() {
158 Ok(StageEvent::Output { task_index, chunk }) => {
159 if matches!(chunk.stream, OutputStream::Stderr) {
160 stderr_buffers[task_index].push(chunk);
161 }
162 }
163 Ok(StageEvent::Finished { task_index, error }) => {
164 task_errors[task_index] = error;
165 finished_count += 1;
166 }
167 Err(_) => break,
168 }
169 }
170
171 let first_error = task_errors.into_iter().flatten().next();
172 if let Some(error) = first_error {
173 for (index, buffer) in stderr_buffers.iter_mut().enumerate() {
174 flush_task_buffer(&stage_nodes[index].name, buffer, label)?;
175 }
176 eprintln!("{}", render_status("Fail", TermAnsiColor::BrightRed));
177 return Err(error);
178 }
179
180 Ok(())
181}
182
183fn collect_thread_results(
184 handles: Vec<thread::ScopedJoinHandle<'_, Result<(), EngineError>>>,
185) -> Result<(), EngineError> {
186 let mut first_error = None;
187 for handle in handles {
188 match handle.join() {
189 Ok(Ok(())) => {}
190 Ok(Err(error)) => {
191 if first_error.is_none() {
192 first_error = Some(error);
193 }
194 }
195 Err(payload) => std::panic::resume_unwind(payload),
196 }
197 }
198 match first_error {
199 Some(error) => Err(error),
200 None => Ok(()),
201 }
202}
203
204fn run_node(
205 task_index: usize,
206 node: &ExecutionNode,
207 working_dir: &std::path::Path,
208 default_shell: Option<&str>,
209 event_tx: mpsc::Sender<StageEvent>,
210) -> Result<(), EngineError> {
211 let (output_tx, output_rx) = mpsc::channel::<OutputChunk>();
212 let forwarder = thread::spawn({
213 let event_tx = event_tx.clone();
214 move || -> Result<(), EngineError> {
215 while let Ok(chunk) = output_rx.recv() {
216 event_tx
217 .send(StageEvent::Output { task_index, chunk })
218 .map_err(|_| {
219 EngineError::Runtime("failed to forward task output".to_string())
220 })?;
221 }
222 Ok(())
223 }
224 });
225
226 let total_commands = node.commands.len();
227 let mut task_error = None;
228
229 for (index, command) in node.commands.iter().enumerate() {
230 let rendered = match interpolate(command, &node.params) {
231 Ok(rendered) => rendered,
232 Err(error) => {
233 task_error = Some(error);
234 break;
235 }
236 };
237
238 let shell = node.shell.as_deref().or(default_shell).unwrap_or("deno");
239 let code = match run_command(
240 &rendered,
241 working_dir,
242 shell,
243 node.shell_fallback,
244 output_tx.clone(),
245 ) {
246 Ok(code) => code,
247 Err(error) => {
248 task_error = Some(error);
249 break;
250 }
251 };
252
253 if code != ExitCode::SUCCESS {
254 task_error = Some(command_failed(
255 &node.name,
256 index + 1,
257 total_commands,
258 &rendered,
259 code,
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 event_tx
283 .send(StageEvent::Finished {
284 task_index,
285 error: task_error,
286 })
287 .map_err(|_| EngineError::Runtime("failed to finalize task output".to_string()))?;
288 Ok(())
289}
290
291#[derive(Debug)]
292enum StageEvent {
293 Output {
294 task_index: usize,
295 chunk: OutputChunk,
296 },
297 Finished {
298 task_index: usize,
299 error: Option<EngineError>,
300 },
301}
302
303fn flush_task_buffer(
304 task_name: &str,
305 buffer: &mut Vec<OutputChunk>,
306 label: bool,
307) -> Result<(), EngineError> {
308 for chunk in buffer.drain(..) {
309 print_output_chunk(task_name, &chunk, label)?;
310 }
311 Ok(())
312}
313
314fn print_output_chunk(
315 task_name: &str,
316 chunk: &OutputChunk,
317 label: bool,
318) -> Result<(), EngineError> {
319 let prefix = if label {
320 let prefix_style = TermStyle::new()
321 .fg_color(Some(TermAnsiColor::BrightCyan.into()))
322 .bold();
323 format!(
324 "{}[{}]{} ",
325 prefix_style.render(),
326 task_name,
327 prefix_style.render_reset()
328 )
329 } else {
330 String::new()
331 };
332
333 match chunk.stream {
334 OutputStream::Stdout => write_prefixed(prefix.as_str(), &chunk.text, io::stdout()),
335 OutputStream::Stderr => write_prefixed(prefix.as_str(), &chunk.text, io::stderr()),
336 }
337}
338
339fn write_prefixed(prefix: &str, content: &str, mut writer: impl Write) -> Result<(), EngineError> {
340 for segment in content.split_inclusive('\n') {
341 if segment.is_empty() {
342 continue;
343 }
344 write!(writer, "{prefix}{segment}").map_err(|error| {
345 EngineError::Runtime(format!("failed to write task output: {error}"))
346 })?;
347 }
348
349 if !content.is_empty() && !content.ends_with('\n') {
350 writeln!(writer).map_err(|error| {
351 EngineError::Runtime(format!("failed to write task output: {error}"))
352 })?;
353 }
354
355 writer
356 .flush()
357 .map_err(|error| EngineError::Runtime(format!("failed to flush task output: {error}")))?;
358 Ok(())
359}
360
361fn render_task_progress(task_index: usize, total_tasks: usize, task_name: &str) -> String {
362 let label_style = TermStyle::new()
363 .fg_color(Some(TermAnsiColor::BrightGreen.into()))
364 .bold();
365 let task_style = TermStyle::new()
366 .fg_color(Some(TermAnsiColor::BrightCyan.into()))
367 .bold();
368
369 format!(
370 "{}[task {}/{}]{} {}{}{}",
371 label_style.render(),
372 task_index,
373 total_tasks,
374 label_style.render_reset(),
375 task_style.render(),
376 task_name,
377 task_style.render_reset()
378 )
379}
380
381fn render_status(label: &str, color: TermAnsiColor) -> String {
382 let style = TermStyle::new().fg_color(Some(color.into())).bold();
383 format!("{}{}{}", style.render(), label, style.render_reset())
384}