Skip to main content

only_engine/
runtime.rs

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
13/// Runs a pre-built execution plan.
14///
15/// Args:
16/// plan: Dependency-expanded execution plan.
17///
18/// Returns:
19/// Success when all execution nodes complete successfully.
20pub 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}