Skip to main content

outl_exec/runtimes/
rust.rs

1//! `rust` runtime — compile the snippet to `wasm32-wasip1` via
2//! `rustc`, cache the resulting `.wasm`, run via wasmtime.
3//!
4//! There is no in-process Rust interpreter (the language is statically
5//! typed and ahead-of-time compiled). The pragmatic path is:
6//!
7//! 1. Wrap the snippet in `fn main()` if the user didn't.
8//! 2. Hash the wrapped source. The hash names the cached `.wasm`.
9//! 3. On cache miss, invoke `rustc --target wasm32-wasip1 -O`. On
10//!    cache hit, read the bytes back from disk and skip the compile
11//!    (Rust compiles are slow; 50–500ms typical).
12//! 4. Hand the bytes to [`WasmModule`] and run.
13//!
14//! Requires the host toolchain to have the `wasm32-wasip1` target:
15//!
16//! ```text
17//! rustup target add wasm32-wasip1
18//! ```
19//!
20//! When the target is missing the runtime returns an
21//! [`ExecError::Sandbox`] with the exact `rustup` command the user
22//! needs — surfaces as a friendly status-line message.
23//!
24//! Gated behind the `lang-rust` (which requires `wasm`) feature.
25
26use std::path::PathBuf;
27use std::process::Command;
28use std::time::Instant;
29
30use crate::runtime::{ExecContext, ExecError, ExecOutput, Runtime};
31use crate::wasm::{cache_path_for_source, engine::make_engine, WasmModule};
32
33/// Rust → WASM runtime.
34///
35/// One engine per instance is cheap (sub-ms construction). Each
36/// `execute` builds (or reads from cache) a fresh `WasmModule`.
37pub struct RustRuntime {
38    engine: wasmtime::Engine,
39}
40
41impl Default for RustRuntime {
42    fn default() -> Self {
43        Self {
44            engine: make_engine(),
45        }
46    }
47}
48
49impl RustRuntime {
50    /// Construct with the shared engine. The engine is cloned, so
51    /// callers can build many runtimes from one engine.
52    pub fn new(engine: wasmtime::Engine) -> Self {
53        Self { engine }
54    }
55}
56
57impl Runtime for RustRuntime {
58    fn language(&self) -> &'static str {
59        "rust"
60    }
61
62    fn execute(&self, source: &str, ctx: &ExecContext) -> Result<ExecOutput, ExecError> {
63        let start = Instant::now();
64        let wrapped = wrap_in_main(source);
65
66        // Try the cache first.
67        let cache_target = cache_path_for_source("rust", &wrapped);
68        let wasm_bytes = match &cache_target {
69            Some(path)
70                if path.exists()
71                    && std::fs::metadata(path)
72                        .map(|m| m.len() > 0)
73                        .unwrap_or(false) =>
74            {
75                std::fs::read(path).map_err(ExecError::Io)?
76            }
77            Some(path) => {
78                let bytes = compile_rust_to_wasm(&wrapped, ctx)?;
79                // Best-effort write — if the cache dir vanished mid-run
80                // we still got the bytes, no need to fail the user's
81                // execution over it.
82                let _ = std::fs::write(path, &bytes);
83                bytes
84            }
85            None => compile_rust_to_wasm(&wrapped, ctx)?,
86        };
87
88        // Hand off to the generic WASM adapter. The source we pipe in
89        // doesn't matter — the compiled program already has its logic;
90        // we keep stdin empty so `std::io::stdin()` returns EOF
91        // immediately.
92        let module = WasmModule::from_bytes("rust", &self.engine, &wasm_bytes)?;
93        let mut out = module.execute("", ctx)?;
94        out.duration = start.elapsed();
95        Ok(out)
96    }
97}
98
99/// If the snippet doesn't already declare `fn main`, wrap it so it
100/// becomes a valid program. Lets users write `(+ 1 2)`-style one-liners
101/// in Rust too: `println!("{}", 1 + 2);` works on its own.
102fn wrap_in_main(source: &str) -> String {
103    if source.contains("fn main") {
104        source.to_string()
105    } else {
106        format!("fn main() {{\n{source}\n}}\n")
107    }
108}
109
110/// Invoke `rustc` to produce a `wasm32-wasip1` binary from `source`.
111///
112/// Uses a temp directory under the runtime cache so the .rs file is
113/// reachable for diagnostics ("`error[E0425]: ... at /var/.../snippet.rs`")
114/// and gets cleaned up by the OS in due course.
115fn compile_rust_to_wasm(source: &str, ctx: &ExecContext) -> Result<Vec<u8>, ExecError> {
116    let tmp_root = std::env::temp_dir().join("outl-rustc");
117    std::fs::create_dir_all(&tmp_root).map_err(ExecError::Io)?;
118    let src_path = tmp_root.join(format!("snippet-{}.rs", std::process::id()));
119    std::fs::write(&src_path, source).map_err(ExecError::Io)?;
120
121    let wasm_out = src_path.with_extension("wasm");
122    let output = Command::new("rustc")
123        .arg("--target")
124        .arg("wasm32-wasip1")
125        .arg("-O")
126        .arg("-o")
127        .arg(&wasm_out)
128        .arg(&src_path)
129        .current_dir(&ctx.workspace_root)
130        .output()
131        .map_err(|e| {
132            if e.kind() == std::io::ErrorKind::NotFound {
133                ExecError::Sandbox(
134                    "`rustc` not found on PATH. Install via `rustup` and \
135                     add the wasm32-wasip1 target: `rustup target add wasm32-wasip1`."
136                        .into(),
137                )
138            } else {
139                ExecError::Sandbox(format!("spawn rustc: {e}"))
140            }
141        })?;
142
143    if !output.status.success() {
144        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
145        // Common case: target not installed.
146        if stderr.contains("the `wasm32-wasip1` target") || stderr.contains("toolchain") {
147            return Err(ExecError::Sandbox(format!(
148                "{stderr}\n\nhint: `rustup target add wasm32-wasip1`"
149            )));
150        }
151        // Treat compile errors as language errors so the result subblock
152        // shows them inline instead of crashing the run.
153        return Err(ExecError::Language(stderr));
154    }
155
156    let bytes = std::fs::read(&wasm_out).map_err(ExecError::Io)?;
157    // Clean up the .rs / .wasm scratch files. Failures are silent.
158    let _ = std::fs::remove_file(&src_path);
159    let _ = std::fs::remove_file(&wasm_out);
160    Ok(bytes)
161}
162
163/// Re-export helpers for hosts that want to pre-warm the cache (e.g.
164/// CI). Not part of the public surface of the trait.
165pub fn cache_dir_for_rust() -> Option<PathBuf> {
166    let mut p = crate::wasm::cache_dir()?;
167    p.push("rust");
168    std::fs::create_dir_all(&p).ok()?;
169    Some(p)
170}