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}