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        )?;
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}