1use anstyle::{AnsiColor as TermAnsiColor, Style as TermStyle};
2use std::collections::VecDeque;
3use std::fs::{File, OpenOptions, remove_file};
4use std::io::{self, Read, Seek, SeekFrom, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::mpsc;
9use std::thread;
10
11use only_semantic::{ShellKind, ShellSelection};
12
13use crate::error::{
14 command_block_failed, command_block_start_failed, command_failed, task_failure,
15};
16use crate::interpolate::{interpolate, interpolate_with_parts};
17use crate::process::{OutputChunk, OutputStream, TerminalContext, begin_terminal_invocation};
18use crate::shell::{run_command, run_command_inherit};
19use crate::{EngineError, ExecutionNode, ExecutionPlan, ExecutionStep, PlanParam};
20
21#[cfg(unix)]
22mod unix;
23#[cfg(windows)]
24mod windows;
25
26#[cfg(unix)]
27use unix as platform;
28#[cfg(windows)]
29use windows as platform;
30
31pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode, EngineError> {
39 run_plan_with_options(plan, RuntimeOptions::default())
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub struct RuntimeOptions {
51 pub quiet: bool,
52 pub max_parallelism: Option<usize>,
53}
54
55pub fn run_plan_with_options(
64 plan: &ExecutionPlan,
65 options: RuntimeOptions,
66) -> Result<ExitCode, EngineError> {
67 let total_tasks = plan.nodes.len();
68 if total_tasks == 0 {
69 return Ok(ExitCode::SUCCESS);
70 }
71 if plan.successors.len() != total_tasks {
72 return Err(EngineError::Runtime(
73 "execution plan has invalid dependency edges".to_string(),
74 ));
75 }
76
77 let mut indegree = vec![0usize; total_tasks];
78 for successors in &plan.successors {
79 for &successor in successors {
80 if successor >= total_tasks {
81 return Err(EngineError::Runtime(
82 "execution plan has invalid dependency edge".to_string(),
83 ));
84 }
85 indegree[successor] += 1;
86 }
87 }
88 let mut ready = (0..total_tasks)
89 .filter(|&index| indegree[index] == 0)
90 .collect::<VecDeque<_>>();
91 let limit = options.max_parallelism.unwrap_or(usize::MAX).max(1);
92 let (event_tx, event_rx) = mpsc::channel::<ExecutionEvent>();
93 let mut buffers = (0..total_tasks)
94 .map(|_| TaskOutputBuffer::default())
95 .collect::<Vec<_>>();
96 let mut progress_printed = vec![false; total_tasks];
97 let mut finished = vec![false; total_tasks];
98 let mut results = (0..total_tasks)
99 .map(|_| None)
100 .collect::<Vec<Option<TaskResult>>>();
101 let mut task_errors = (0..total_tasks)
102 .map(|_| None)
103 .collect::<Vec<Option<EngineError>>>();
104 let mut current_index = 0usize;
105 let mut finished_count = 0usize;
106 let mut active_count = 0usize;
107 let mut failure_seen = false;
108 let terminal = begin_terminal_invocation()?;
109
110 thread::scope(|scope| {
111 let mut handles = Vec::new();
112 while finished_count < total_tasks && (!failure_seen || active_count > 0) {
113 while !failure_seen && active_count < limit {
114 let Some(index) = ready.pop_front() else {
115 break;
116 };
117 let node = plan.nodes[index].clone();
118 let working_dir = plan.working_dir.clone();
119 let shell = plan.shell.clone();
120 let event_tx = event_tx.clone();
121 let terminal = terminal.clone();
122 let live_output = index == current_index;
123 let direct = plan.successors[index].is_empty()
126 && active_count == 0
127 && (limit == 1 || ready.is_empty());
128 handles.push(scope.spawn(move || {
129 event_tx
130 .send(ExecutionEvent::Started { task_index: index })
131 .map_err(|_| {
132 EngineError::Runtime("failed to start task presentation".to_string())
133 })?;
134 if direct {
135 let (error, result) = match run_node_inherit(
136 &node,
137 &working_dir,
138 shell.as_ref(),
139 &terminal,
140 ) {
141 Ok(result) => result,
142 Err(error) => (Some(error), None),
143 };
144 event_tx
145 .send(ExecutionEvent::Finished {
146 task_index: index,
147 error,
148 result,
149 })
150 .map_err(|_| {
151 EngineError::Runtime("failed to finalize task output".to_string())
152 })
153 } else {
154 run_node(
155 index,
156 &node,
157 &working_dir,
158 shell.as_ref(),
159 terminal,
160 live_output,
161 event_tx,
162 )
163 }
164 }));
165 active_count += 1;
166 }
167 let event = event_rx.recv().map_err(|_| {
168 EngineError::Runtime("execution event channel closed unexpectedly".to_string())
169 })?;
170 match event {
171 ExecutionEvent::Started { task_index } => {
172 if task_index == current_index {
173 print_task_progress(
174 task_index,
175 total_tasks,
176 plan,
177 &mut progress_printed,
178 options.quiet,
179 )?;
180 }
181 }
182 ExecutionEvent::Output { task_index, chunk } => {
183 if task_index == current_index {
184 print_task_progress(
185 task_index,
186 total_tasks,
187 plan,
188 &mut progress_printed,
189 options.quiet,
190 )?;
191 print_output_chunk(&chunk)?;
192 } else {
193 buffers[task_index].push(chunk)?;
194 }
195 }
196 ExecutionEvent::Finished {
197 task_index,
198 error,
199 result,
200 } => {
201 active_count -= 1;
202 finished[task_index] = true;
203 task_errors[task_index] = error;
204 results[task_index] = result;
205 finished_count += 1;
206 if task_errors[task_index].is_some() {
207 failure_seen = true;
208 } else if !failure_seen {
209 for &successor in &plan.successors[task_index] {
210 indegree[successor] -= 1;
211 if indegree[successor] == 0 {
212 ready.push_back(successor);
213 }
214 }
215 }
216 }
217 }
218
219 while current_index < total_tasks && finished[current_index] {
220 print_task_progress(
221 current_index,
222 total_tasks,
223 plan,
224 &mut progress_printed,
225 options.quiet,
226 )?;
227 buffers[current_index].flush()?;
228 let mut task_error = task_errors[current_index].take();
229 match results[current_index].take() {
230 Some(TaskResult::Pass(message)) if !options.quiet => {
231 print_task_message(&message)?
232 }
233 Some(TaskResult::Fail(message)) => {
234 if let Some(error) = task_error.take() {
235 task_error = Some(task_failure(error, message));
236 }
237 }
238 Some(TaskResult::Pass(_)) | None => {}
239 }
240 task_errors[current_index] = task_error;
241 current_index += 1;
242 }
243
244 if current_index < total_tasks && !buffers[current_index].is_empty() {
245 print_task_progress(
246 current_index,
247 total_tasks,
248 plan,
249 &mut progress_printed,
250 options.quiet,
251 )?;
252 buffers[current_index].flush()?;
253 }
254 }
255 drop(event_tx);
256 for handle in handles {
257 if let Err(payload) = handle.join() {
258 std::panic::resume_unwind(payload);
259 }
260 }
261 Ok::<(), EngineError>(())
262 })?;
263
264 task_errors
265 .into_iter()
266 .find_map(|error| error)
267 .map_or(Ok(ExitCode::SUCCESS), Err)
268}
269
270fn run_node(
271 task_index: usize,
272 node: &ExecutionNode,
273 working_dir: &std::path::Path,
274 default_shell: Option<&ShellKind>,
275 terminal: TerminalContext,
276 live_output: bool,
277 event_tx: mpsc::Sender<ExecutionEvent>,
278) -> Result<(), EngineError> {
279 let terminal = if live_output {
280 terminal
281 } else {
282 terminal.for_captured_output()
283 };
284 let (output_tx, output_rx) = mpsc::channel::<OutputChunk>();
285 let forwarder = thread::spawn({
286 let event_tx = event_tx.clone();
287 move || -> Result<(), EngineError> {
288 while let Ok(chunk) = output_rx.recv() {
289 event_tx
290 .send(ExecutionEvent::Output { task_index, chunk })
291 .map_err(|_| {
292 EngineError::Runtime("failed to forward task output".to_string())
293 })?;
294 }
295 Ok(())
296 }
297 });
298
299 let total_steps = node.steps.len();
300 let mut task_error = None;
301
302 for (index, step) in node.steps.iter().enumerate() {
303 let rendered = match interpolate_step(step, &node.params) {
304 Ok(rendered) => rendered,
305 Err(error) => {
306 task_error = Some(error);
307 break;
308 }
309 };
310
311 let shell = select_shell(node, default_shell);
312 let status = match run_command(&rendered, working_dir, &shell, output_tx.clone(), &terminal)
313 {
314 Ok(status) => status,
315 Err(error) => {
316 task_error = Some(if step.is_block() {
317 command_block_start_failed(shell.kind.as_str(), error)
318 } else {
319 error
320 });
321 break;
322 }
323 };
324
325 if let Some(reason) = status.failure_reason() {
326 task_error = Some(if step.is_block() {
327 command_block_failed(&node.name, index + 1, total_steps, reason)
328 } else {
329 command_failed(&node.name, index + 1, total_steps, &rendered, reason)
330 });
331 break;
332 }
333 }
334
335 drop(output_tx);
336 match forwarder.join() {
337 Ok(Ok(())) => {}
338 Ok(Err(error)) => {
339 if task_error.is_none() {
340 task_error = Some(error);
341 }
342 }
343 Err(_) => {
344 if task_error.is_none() {
345 task_error = Some(EngineError::Runtime(
346 "task output forwarder thread panicked".to_string(),
347 ));
348 }
349 }
350 }
351
352 let result = match task_result_message(node, task_error.is_none()) {
353 Ok(message) => message.map(|message| {
354 if task_error.is_none() {
355 TaskResult::Pass(message)
356 } else {
357 TaskResult::Fail(message)
358 }
359 }),
360 Err(error) => {
361 if task_error.is_none() {
362 task_error = Some(error);
363 }
364 None
365 }
366 };
367
368 event_tx
369 .send(ExecutionEvent::Finished {
370 task_index,
371 error: task_error,
372 result,
373 })
374 .map_err(|_| EngineError::Runtime("failed to finalize task output".to_string()))?;
375 Ok(())
376}
377
378fn run_node_inherit(
379 node: &ExecutionNode,
380 working_dir: &std::path::Path,
381 default_shell: Option<&ShellKind>,
382 terminal: &TerminalContext,
383) -> Result<(Option<EngineError>, Option<TaskResult>), EngineError> {
384 let total_steps = node.steps.len();
385 let execution = (|| {
386 for (index, step) in node.steps.iter().enumerate() {
387 let rendered = interpolate_step(step, &node.params)?;
388 let shell = select_shell(node, default_shell);
389 let status =
390 run_command_inherit(&rendered, working_dir, &shell, terminal).map_err(|error| {
391 if step.is_block() {
392 command_block_start_failed(shell.kind.as_str(), error)
393 } else {
394 error
395 }
396 })?;
397
398 if let Some(reason) = status.failure_reason() {
399 return Err(if step.is_block() {
400 command_block_failed(&node.name, index + 1, total_steps, reason)
401 } else {
402 command_failed(&node.name, index + 1, total_steps, &rendered, reason)
403 });
404 }
405 }
406 Ok::<(), EngineError>(())
407 })();
408
409 match execution {
410 Ok(()) => Ok((None, task_result_message(node, true)?.map(TaskResult::Pass))),
411 Err(error) => Ok((
412 Some(error),
413 task_result_message(node, false)?.map(TaskResult::Fail),
414 )),
415 }
416}
417
418fn interpolate_step(step: &ExecutionStep, params: &[PlanParam]) -> Result<String, EngineError> {
419 match step {
420 ExecutionStep::Command {
421 source,
422 interpolations,
423 }
424 | ExecutionStep::CommandBlock {
425 source,
426 interpolations,
427 ..
428 } => interpolate_with_parts(source, interpolations, params),
429 }
430}
431
432fn select_shell(node: &ExecutionNode, default_shell: Option<&ShellKind>) -> ShellSelection {
433 node.shell.clone().unwrap_or_else(|| {
434 ShellSelection::required(default_shell.cloned().unwrap_or(ShellKind::Deno))
435 })
436}
437
438#[derive(Debug)]
439enum ExecutionEvent {
440 Started {
441 task_index: usize,
442 },
443 Output {
444 task_index: usize,
445 chunk: OutputChunk,
446 },
447 Finished {
448 task_index: usize,
449 error: Option<EngineError>,
450 result: Option<TaskResult>,
451 },
452}
453
454fn print_task_progress(
455 task_index: usize,
456 total_tasks: usize,
457 plan: &ExecutionPlan,
458 progress_printed: &mut [bool],
459 quiet: bool,
460) -> Result<(), EngineError> {
461 if quiet || progress_printed[task_index] {
462 return Ok(());
463 }
464
465 let rendered = format!(
466 "{}\n",
467 render_task_progress(task_index + 1, total_tasks, &plan.nodes[task_index].name)
468 );
469 write_stderr(rendered.as_bytes())?;
470 progress_printed[task_index] = true;
471 Ok(())
472}
473
474#[derive(Debug)]
475enum TaskResult {
476 Pass(String),
477 Fail(String),
478}
479
480fn task_result_message(node: &ExecutionNode, success: bool) -> Result<Option<String>, EngineError> {
481 let source = if success {
482 node.pass.as_deref()
483 } else {
484 node.fail.as_deref()
485 };
486 source
487 .map(|text| interpolate(text, &node.result_params))
488 .transpose()
489}
490
491fn print_task_message(message: &str) -> Result<(), EngineError> {
492 let style = TermStyle::new()
493 .fg_color(Some(TermAnsiColor::BrightGreen.into()))
494 .bold();
495 let rendered = format!("{}{}{}\n", style.render(), message, style.render_reset());
496 write_stderr(rendered.as_bytes())
497}
498
499const OUTPUT_MEMORY_LIMIT: usize = 1024 * 1024;
500static NEXT_BUFFER_ID: AtomicU64 = AtomicU64::new(0);
501
502#[derive(Default)]
503struct TaskOutputBuffer {
504 chunks: Vec<OutputChunk>,
505 memory_bytes: usize,
506 spill: Option<SpillFile>,
507}
508
509impl TaskOutputBuffer {
510 fn is_empty(&self) -> bool {
511 self.chunks.is_empty() && self.spill.is_none()
512 }
513
514 fn push(&mut self, chunk: OutputChunk) -> Result<(), EngineError> {
515 if let Some(spill) = &mut self.spill {
516 return spill.write_chunk(&chunk);
517 }
518
519 if self.memory_bytes + chunk.bytes.len() <= OUTPUT_MEMORY_LIMIT {
520 self.memory_bytes += chunk.bytes.len();
521 self.chunks.push(chunk);
522 return Ok(());
523 }
524
525 let mut spill = SpillFile::create()?;
526 for buffered in self.chunks.drain(..) {
527 spill.write_chunk(&buffered)?;
528 }
529 self.memory_bytes = 0;
530 spill.write_chunk(&chunk)?;
531 self.spill = Some(spill);
532 Ok(())
533 }
534
535 fn flush(&mut self) -> Result<(), EngineError> {
536 if let Some(spill) = self.spill.take() {
537 spill.replay()?;
538 } else {
539 for chunk in self.chunks.drain(..) {
540 print_output_chunk(&chunk)?;
541 }
542 }
543 self.memory_bytes = 0;
544 Ok(())
545 }
546}
547
548struct SpillFile {
549 file: File,
550 path: PathBuf,
551}
552
553impl SpillFile {
554 fn create() -> Result<Self, EngineError> {
555 let id = NEXT_BUFFER_ID.fetch_add(1, Ordering::Relaxed);
556 let path =
557 std::env::temp_dir().join(format!("only-output-{}-{id}.tmp", std::process::id()));
558 let file = OpenOptions::new()
559 .create_new(true)
560 .read(true)
561 .write(true)
562 .open(&path)
563 .map_err(|error| {
564 EngineError::Runtime(format!("failed to create output buffer: {error}"))
565 })?;
566 Ok(Self { file, path })
567 }
568
569 fn write_chunk(&mut self, chunk: &OutputChunk) -> Result<(), EngineError> {
570 self.file
571 .seek(SeekFrom::End(0))
572 .and_then(|_| {
573 self.file.write_all(&[match chunk.stream {
574 OutputStream::Stdout => 0,
575 OutputStream::Stderr => 1,
576 }])
577 })
578 .and_then(|_| {
579 self.file
580 .write_all(&(chunk.bytes.len() as u64).to_le_bytes())
581 })
582 .and_then(|_| self.file.write_all(&chunk.bytes))
583 .map_err(|error| EngineError::Runtime(format!("failed to buffer task output: {error}")))
584 }
585
586 fn replay(mut self) -> Result<(), EngineError> {
587 self.file.seek(SeekFrom::Start(0)).map_err(|error| {
588 EngineError::Runtime(format!("failed to rewind output buffer: {error}"))
589 })?;
590 loop {
591 let mut stream = [0u8; 1];
592 match self.file.read_exact(&mut stream) {
593 Ok(()) => {}
594 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
595 Err(error) => {
596 return Err(EngineError::Runtime(format!(
597 "failed to read output buffer: {error}"
598 )));
599 }
600 }
601 let mut length = [0u8; 8];
602 self.file.read_exact(&mut length).map_err(|error| {
603 EngineError::Runtime(format!("failed to read output buffer: {error}"))
604 })?;
605 let mut bytes = vec![0u8; u64::from_le_bytes(length) as usize];
606 self.file.read_exact(&mut bytes).map_err(|error| {
607 EngineError::Runtime(format!("failed to read output buffer: {error}"))
608 })?;
609 print_output_chunk(&OutputChunk {
610 stream: if stream[0] == 0 {
611 OutputStream::Stdout
612 } else {
613 OutputStream::Stderr
614 },
615 bytes,
616 })?;
617 }
618 Ok(())
619 }
620}
621
622impl Drop for SpillFile {
623 fn drop(&mut self) {
624 let _ = remove_file(&self.path);
625 }
626}
627
628fn print_output_chunk(chunk: &OutputChunk) -> Result<(), EngineError> {
629 match chunk.stream {
630 OutputStream::Stdout => write_stdout(&chunk.bytes),
631 OutputStream::Stderr => write_stderr(&chunk.bytes),
632 }
633}
634
635fn write_stdout(content: &[u8]) -> Result<(), EngineError> {
636 platform::write_stdout(content)
637}
638
639fn write_stderr(content: &[u8]) -> Result<(), EngineError> {
640 platform::write_stderr(content)
641}
642
643fn render_task_progress(task_index: usize, total_tasks: usize, task_name: &str) -> String {
644 let label_style = TermStyle::new()
645 .fg_color(Some(TermAnsiColor::BrightGreen.into()))
646 .bold();
647 let task_style = TermStyle::new()
648 .fg_color(Some(TermAnsiColor::BrightCyan.into()))
649 .bold();
650
651 format!(
652 "{}[{}/{}]{} {}{}{}",
653 label_style.render(),
654 task_index,
655 total_tasks,
656 label_style.render_reset(),
657 task_style.render(),
658 task_name,
659 task_style.render_reset()
660 )
661}