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::{
10    command_block_failed, command_block_start_failed, command_failed, task_failure,
11};
12use crate::interpolate::{interpolate, interpolate_with_parts};
13use crate::process::{OutputChunk, OutputStream};
14use crate::shell::{run_command, run_command_inherit};
15use crate::{EngineError, ExecutionNode, ExecutionPlan, ExecutionStep, PlanParam};
16
17/// Runs a pre-built execution plan.
18///
19/// Args:
20/// plan: Dependency-expanded execution plan.
21///
22/// Returns:
23/// Success when all execution nodes complete successfully.
24pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode, EngineError> {
25    run_plan_with_options(plan, RuntimeOptions::default())
26}
27
28/// Runtime options that affect only the task host, not command semantics.
29///
30/// Args:
31/// None.
32///
33/// Returns:
34/// Host display options used while executing an already-built plan.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub struct RuntimeOptions {
37    pub quiet: bool,
38}
39
40/// Runs a pre-built execution plan with host display options.
41///
42/// Args:
43/// plan: Dependency-expanded execution plan.
44/// options: Host-side display options.
45///
46/// Returns:
47/// Success when all execution nodes complete successfully.
48pub fn run_plan_with_options(
49    plan: &ExecutionPlan,
50    options: RuntimeOptions,
51) -> Result<ExitCode, EngineError> {
52    let total_tasks = plan.nodes.len();
53    let mut task_index = 0usize;
54
55    while task_index < total_tasks {
56        let stage = plan.nodes[task_index].stage;
57        let stage_start = task_index;
58        while task_index < total_tasks && plan.nodes[task_index].stage == stage {
59            task_index += 1;
60        }
61        let stage_nodes = &plan.nodes[stage_start..task_index];
62
63        if !options.quiet {
64            for (offset, node) in stage_nodes.iter().enumerate() {
65                eprintln!(
66                    "{}",
67                    render_task_progress(stage_start + offset + 1, total_tasks, &node.name)
68                );
69            }
70        }
71
72        if stage_nodes.len() == 1 {
73            run_node_inherit(
74                &stage_nodes[0],
75                &plan.working_dir,
76                plan.shell.as_ref(),
77                options.quiet,
78            )?;
79        } else {
80            execute_stage(
81                stage_nodes,
82                &plan.working_dir,
83                plan.shell.as_ref(),
84                options.quiet,
85            )?;
86        }
87    }
88
89    Ok(ExitCode::SUCCESS)
90}
91
92fn execute_stage(
93    stage_nodes: &[ExecutionNode],
94    working_dir: &std::path::Path,
95    default_shell: Option<&ShellKind>,
96    quiet: bool,
97) -> Result<(), EngineError> {
98    let stage_len = stage_nodes.len();
99    let (event_tx, event_rx) = mpsc::channel::<StageEvent>();
100
101    thread::scope(|scope| {
102        let mut handles = Vec::new();
103
104        for (index, node) in stage_nodes.iter().cloned().enumerate() {
105            let working_dir = working_dir.to_path_buf();
106            let shell = default_shell.cloned();
107            let event_tx = event_tx.clone();
108            handles.push(
109                scope.spawn(move || run_node(index, &node, &working_dir, shell.as_ref(), event_tx)),
110            );
111        }
112        drop(event_tx);
113
114        run_ordered_output_event_loop(&event_rx, stage_len, quiet)?;
115
116        collect_thread_results(handles)
117    })
118}
119
120fn run_ordered_output_event_loop(
121    event_rx: &mpsc::Receiver<StageEvent>,
122    stage_len: usize,
123    quiet: bool,
124) -> Result<(), EngineError> {
125    let mut buffers = vec![Vec::<OutputChunk>::new(); stage_len];
126    let mut finished = vec![false; stage_len];
127    let mut results = (0..stage_len)
128        .map(|_| None)
129        .collect::<Vec<Option<TaskResult>>>();
130    let mut task_errors = (0..stage_len)
131        .map(|_| None)
132        .collect::<Vec<Option<EngineError>>>();
133    let mut current_index = 0usize;
134    let mut finished_count = 0usize;
135    let mut first_error = None;
136
137    while finished_count < stage_len {
138        match event_rx.recv() {
139            Ok(StageEvent::Output { task_index, chunk }) => {
140                if task_index == current_index {
141                    print_output_chunk(&chunk)?;
142                } else {
143                    buffers[task_index].push(chunk);
144                }
145            }
146            Ok(StageEvent::Finished {
147                task_index,
148                error,
149                result,
150            }) => {
151                finished[task_index] = true;
152                task_errors[task_index] = error;
153                results[task_index] = result;
154                finished_count += 1;
155
156                while current_index < stage_len {
157                    flush_task_buffer(&mut buffers[current_index])?;
158                    if !finished[current_index] {
159                        break;
160                    }
161                    let mut task_error = task_errors[current_index].take();
162                    match results[current_index].take() {
163                        Some(TaskResult::Pass(message)) if !quiet => print_task_message(&message)?,
164                        Some(TaskResult::Fail(message)) => {
165                            if let Some(error) = task_error.take() {
166                                task_error = Some(task_failure(error, message));
167                            }
168                        }
169                        Some(TaskResult::Pass(_)) | None => {}
170                    }
171                    if first_error.is_none() {
172                        first_error = task_error;
173                    }
174                    current_index += 1;
175                }
176            }
177            Err(_) => break,
178        }
179    }
180
181    match first_error {
182        Some(error) => Err(error),
183        None => Ok(()),
184    }
185}
186
187fn collect_thread_results(
188    handles: Vec<thread::ScopedJoinHandle<'_, Result<(), EngineError>>>,
189) -> Result<(), EngineError> {
190    let mut first_error = None;
191    for handle in handles {
192        match handle.join() {
193            Ok(Ok(())) => {}
194            Ok(Err(error)) => {
195                if first_error.is_none() {
196                    first_error = Some(error);
197                }
198            }
199            Err(payload) => std::panic::resume_unwind(payload),
200        }
201    }
202    match first_error {
203        Some(error) => Err(error),
204        None => Ok(()),
205    }
206}
207
208fn run_node(
209    task_index: usize,
210    node: &ExecutionNode,
211    working_dir: &std::path::Path,
212    default_shell: Option<&ShellKind>,
213    event_tx: mpsc::Sender<StageEvent>,
214) -> Result<(), EngineError> {
215    let (output_tx, output_rx) = mpsc::channel::<OutputChunk>();
216    let forwarder = thread::spawn({
217        let event_tx = event_tx.clone();
218        move || -> Result<(), EngineError> {
219            while let Ok(chunk) = output_rx.recv() {
220                event_tx
221                    .send(StageEvent::Output { task_index, chunk })
222                    .map_err(|_| {
223                        EngineError::Runtime("failed to forward task output".to_string())
224                    })?;
225            }
226            Ok(())
227        }
228    });
229
230    let total_steps = node.steps.len();
231    let mut task_error = None;
232
233    for (index, step) in node.steps.iter().enumerate() {
234        let rendered = match interpolate_step(step, &node.params) {
235            Ok(rendered) => rendered,
236            Err(error) => {
237                task_error = Some(error);
238                break;
239            }
240        };
241
242        let shell = select_shell(node, default_shell);
243        let status = match run_command(&rendered, working_dir, &shell, output_tx.clone()) {
244            Ok(status) => status,
245            Err(error) => {
246                task_error = Some(if step.is_block() {
247                    command_block_start_failed(shell.kind.as_str(), error)
248                } else {
249                    error
250                });
251                break;
252            }
253        };
254
255        if let Some(reason) = status.failure_reason() {
256            task_error = Some(if step.is_block() {
257                command_block_failed(&node.name, index + 1, total_steps, reason)
258            } else {
259                command_failed(&node.name, index + 1, total_steps, &rendered, reason)
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    let result = match task_result_message(node, task_error.is_none()) {
283        Ok(message) => message.map(|message| {
284            if task_error.is_none() {
285                TaskResult::Pass(message)
286            } else {
287                TaskResult::Fail(message)
288            }
289        }),
290        Err(error) => {
291            if task_error.is_none() {
292                task_error = Some(error);
293            }
294            None
295        }
296    };
297
298    event_tx
299        .send(StageEvent::Finished {
300            task_index,
301            error: task_error,
302            result,
303        })
304        .map_err(|_| EngineError::Runtime("failed to finalize task output".to_string()))?;
305    Ok(())
306}
307
308fn run_node_inherit(
309    node: &ExecutionNode,
310    working_dir: &std::path::Path,
311    default_shell: Option<&ShellKind>,
312    quiet: bool,
313) -> Result<(), EngineError> {
314    let total_steps = node.steps.len();
315    let execution = (|| {
316        for (index, step) in node.steps.iter().enumerate() {
317            let rendered = interpolate_step(step, &node.params)?;
318            let shell = select_shell(node, default_shell);
319            let status = run_command_inherit(&rendered, working_dir, &shell).map_err(|error| {
320                if step.is_block() {
321                    command_block_start_failed(shell.kind.as_str(), error)
322                } else {
323                    error
324                }
325            })?;
326
327            if let Some(reason) = status.failure_reason() {
328                return Err(if step.is_block() {
329                    command_block_failed(&node.name, index + 1, total_steps, reason)
330                } else {
331                    command_failed(&node.name, index + 1, total_steps, &rendered, reason)
332                });
333            }
334        }
335        Ok::<(), EngineError>(())
336    })();
337
338    match execution {
339        Ok(()) => {
340            if !quiet && let Some(message) = task_result_message(node, true)? {
341                print_task_message(&message)?;
342            }
343            Ok(())
344        }
345        Err(error) => {
346            if let Some(message) = task_result_message(node, false)? {
347                Err(task_failure(error, message))
348            } else {
349                Err(error)
350            }
351        }
352    }
353}
354
355fn interpolate_step(step: &ExecutionStep, params: &[PlanParam]) -> Result<String, EngineError> {
356    match step {
357        ExecutionStep::Command {
358            source,
359            interpolations,
360        }
361        | ExecutionStep::CommandBlock {
362            source,
363            interpolations,
364            ..
365        } => interpolate_with_parts(source, interpolations, params),
366    }
367}
368
369fn select_shell(node: &ExecutionNode, default_shell: Option<&ShellKind>) -> ShellSelection {
370    node.shell.clone().unwrap_or_else(|| {
371        ShellSelection::required(default_shell.cloned().unwrap_or(ShellKind::Deno))
372    })
373}
374
375#[derive(Debug)]
376enum StageEvent {
377    Output {
378        task_index: usize,
379        chunk: OutputChunk,
380    },
381    Finished {
382        task_index: usize,
383        error: Option<EngineError>,
384        result: Option<TaskResult>,
385    },
386}
387
388#[derive(Debug)]
389enum TaskResult {
390    Pass(String),
391    Fail(String),
392}
393
394fn task_result_message(node: &ExecutionNode, success: bool) -> Result<Option<String>, EngineError> {
395    let source = if success {
396        node.pass.as_deref()
397    } else {
398        node.fail.as_deref()
399    };
400    source
401        .map(|text| interpolate(text, &node.result_params))
402        .transpose()
403}
404
405fn print_task_message(message: &str) -> Result<(), EngineError> {
406    let style = TermStyle::new()
407        .fg_color(Some(TermAnsiColor::BrightGreen.into()))
408        .bold();
409    let rendered = format!("{}{}{}\n", style.render(), message, style.render_reset());
410    write_output(&rendered, io::stderr())
411}
412
413fn flush_task_buffer(buffer: &mut Vec<OutputChunk>) -> Result<(), EngineError> {
414    for chunk in buffer.drain(..) {
415        print_output_chunk(&chunk)?;
416    }
417    Ok(())
418}
419
420fn print_output_chunk(chunk: &OutputChunk) -> Result<(), EngineError> {
421    match chunk.stream {
422        OutputStream::Stdout => write_output(&chunk.text, io::stdout()),
423        OutputStream::Stderr => write_output(&chunk.text, io::stderr()),
424    }
425}
426
427fn write_output(content: &str, mut writer: impl Write) -> Result<(), EngineError> {
428    write!(writer, "{content}")
429        .map_err(|error| EngineError::Runtime(format!("failed to write task output: {error}")))?;
430
431    writer
432        .flush()
433        .map_err(|error| EngineError::Runtime(format!("failed to flush task output: {error}")))?;
434    Ok(())
435}
436
437fn render_task_progress(task_index: usize, total_tasks: usize, task_name: &str) -> String {
438    let label_style = TermStyle::new()
439        .fg_color(Some(TermAnsiColor::BrightGreen.into()))
440        .bold();
441    let task_style = TermStyle::new()
442        .fg_color(Some(TermAnsiColor::BrightCyan.into()))
443        .bold();
444
445    format!(
446        "{}[{}/{}]{} {}{}{}",
447        label_style.render(),
448        task_index,
449        total_tasks,
450        label_style.render_reset(),
451        task_style.render(),
452        task_name,
453        task_style.render_reset()
454    )
455}