blendtutor_core/runner/mod.rs
1//! The language-runner seam: a [`Runner`] trait and the normalized result of
2//! running learner code.
3//!
4//! [`Runner`] is the boundary every execution and grading slice depends on, so a
5//! second language (Python, Slice 8) or a hosted backend is a new `impl Runner`
6//! rather than an edit at each call site (ADR-0005, §3.4). This module owns the
7//! seam and the [`ExecutionResult`] shape; the R-specific subprocess mechanics
8//! live in the private `r` submodule. It does not know about lessons or LLMs
9//! (§4.1).
10
11use std::error::Error;
12use std::fmt;
13use std::future::Future;
14use std::time::Duration;
15
16mod python;
17mod r;
18mod subprocess;
19
20pub use python::PythonRunner;
21pub use r::RRunner;
22
23/// A wall-clock bound on a single execution.
24///
25/// A newtype over [`Duration`] so a timeout is never confused with an arbitrary
26/// duration argument elsewhere (§1.4).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Timeout(pub Duration);
29
30/// The normalized outcome of running learner code to completion or timeout.
31///
32/// The observable channels are kept in distinct fields rather than one merged
33/// buffer (§1.2): a later grading prompt reads `stdout` and `stderr`
34/// independently and must never see one bleed into the other. An
35/// [`ExecutionResult`] only ever represents a process that actually *ran* — a
36/// failure to launch the interpreter is a [`RunnerError`], never a value here.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ExecutionResult {
39 /// Everything the program wrote to standard output.
40 pub stdout: String,
41 /// Everything the program wrote to standard error.
42 pub stderr: String,
43 /// The process exit code, or `None` when it was terminated by a signal
44 /// (e.g. killed on timeout).
45 pub exit: Option<i32>,
46 /// The final evaluated expression's value, reserved for the grading prompt.
47 /// `None` until its producer lands with grading (Slice 9, ADR-0005).
48 pub final_value: Option<String>,
49 /// Whether the run was killed for exceeding its [`Timeout`].
50 pub timed_out: bool,
51}
52
53impl ExecutionResult {
54 /// Assemble a result from raw captured bytes — the pure normalization step,
55 /// kept separate from the effectful spawn so it is testable on bytes alone
56 /// (§2.3, §5.3). Output is decoded lossily; a runner never rejects learner
57 /// output for not being valid UTF-8.
58 pub(crate) fn from_capture(
59 stdout: &[u8],
60 stderr: &[u8],
61 exit: Option<i32>,
62 timed_out: bool,
63 ) -> Self {
64 Self {
65 stdout: String::from_utf8_lossy(stdout).into_owned(),
66 stderr: String::from_utf8_lossy(stderr).into_owned(),
67 exit,
68 final_value: None,
69 timed_out,
70 }
71 }
72}
73
74/// Runs learner code and reports what it did.
75///
76/// The seam every execution and grading slice depends on (§3.4). `execute` is
77/// fallible: an [`Err`] means the interpreter never ran (spawn or IO failure),
78/// which is categorically distinct from the program running and writing to
79/// stderr — so [`ExecutionResult`] never has to encode "we could not start".
80///
81/// Declared returning `impl Future` rather than with `async fn` so the public
82/// trait stays clear of the `async_fn_in_trait` lint under `-D warnings`.
83pub trait Runner {
84 /// Execute `code`, returning its normalized [`ExecutionResult`].
85 ///
86 /// `checks` is reserved for the grading slice (which refines its element
87 /// type); v1 execution does not yet consume it.
88 fn execute(
89 &self,
90 code: &str,
91 checks: &[String],
92 ) -> impl Future<Output = Result<ExecutionResult, RunnerError>> + Send;
93}
94
95/// A failure to *run* learner code: the interpreter could not be spawned, or its
96/// output pipes could not be read.
97///
98/// Distinct from a program that ran and failed — that is an [`ExecutionResult`]
99/// with a non-zero `exit`. Kept a typed error so `core` stays free of `anyhow`
100/// (ADR-0001).
101#[derive(Debug)]
102pub struct RunnerError {
103 context: String,
104 source: std::io::Error,
105}
106
107impl RunnerError {
108 /// Wrap an IO failure with the runner stage that produced it. `context`
109 /// accepts both a `&'static str` (a fixed stage like `"collect stdout"`) and
110 /// an owned `String` (a stage that names the interpreter, e.g.
111 /// `format!("spawn {program}")`), so the shared subprocess core can report
112 /// which interpreter failed without each stage label being a separate const.
113 pub(crate) fn new(context: impl Into<String>, source: std::io::Error) -> Self {
114 Self {
115 context: context.into(),
116 source,
117 }
118 }
119}
120
121impl fmt::Display for RunnerError {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 write!(f, "{}: {}", self.context, self.source)
124 }
125}
126
127impl Error for RunnerError {
128 fn source(&self) -> Option<&(dyn Error + 'static)> {
129 Some(&self.source)
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn from_capture_keeps_streams_distinct_and_passes_exit_through() {
139 let result = ExecutionResult::from_capture(b"OUT", b"ERR", Some(0), false);
140 assert_eq!(result.stdout, "OUT");
141 assert_eq!(result.stderr, "ERR");
142 assert!(
143 !result.stdout.contains("ERR"),
144 "stderr must not bleed into stdout"
145 );
146 assert_eq!(result.exit, Some(0));
147 assert!(!result.timed_out);
148 assert_eq!(result.final_value, None);
149 }
150
151 #[test]
152 fn runner_error_displays_its_stage_and_exposes_the_io_source() {
153 let io = std::io::Error::new(std::io::ErrorKind::NotFound, "boom");
154 let err = RunnerError::new("spawn Rscript", io);
155
156 assert_eq!(err.to_string(), "spawn Rscript: boom");
157 let source = Error::source(&err).expect("the io::Error is reachable as the source");
158 assert_eq!(source.to_string(), "boom");
159 }
160}