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