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}