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