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