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 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
15/// Runs a pre-built execution plan.
16///
17/// Args:
18/// plan: Dependency-expanded execution plan.
19///
20/// Returns:
21/// Success when all execution nodes complete successfully.
22pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode, EngineError> {
23    run_plan_with_options(plan, RuntimeOptions::default())
24}
25
26/// Runtime options that affect only the task host, not command semantics.
27///
28/// Args:
29/// None.
30///
31/// Returns:
32/// Host display options used while executing an already-built plan.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub struct RuntimeOptions {
35    pub quiet: bool,
36}
37
38/// Runs a pre-built execution plan with host display options.
39///
40/// Args:
41/// plan: Dependency-expanded execution plan.
42/// options: Host-side display options.
43///
44/// Returns:
45/// Success when all execution nodes complete successfully.
46pub 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}