Skip to main content

adk_code/
rust_executor.rs

1//! Rust-specific executor with a check → build → execute pipeline.
2//!
3//! [`RustExecutor`] compiles Rust code through a two-step pipeline (check + build)
4//! and delegates execution to a configured [`SandboxBackend`] from `adk-sandbox`.
5//! It wraps user code in the same harness template as the legacy
6//! [`RustSandboxExecutor`](crate::RustSandboxExecutor).
7//!
8//! # Pipeline
9//!
10//! 1. **Check**: `rustc --edition 2021 --error-format=json` → parse diagnostics → halt on errors
11//! 2. **Build**: `rustc --edition 2021 -o binary` → compile to binary using harness template
12//! 3. **Execute**: delegate to [`SandboxBackend`] with [`Language::Command`] and the binary path
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use adk_code::{RustExecutor, RustExecutorConfig};
18//! use adk_sandbox::{ProcessBackend, SandboxBackend};
19//! use std::sync::Arc;
20//!
21//! let backend: Arc<dyn SandboxBackend> = Arc::new(ProcessBackend::default());
22//! let executor = RustExecutor::new(backend, RustExecutorConfig::default());
23//! ```
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::Duration;
29
30use adk_sandbox::{ExecRequest, ExecResult, Language, SandboxBackend};
31use tracing::{debug, info, instrument};
32
33use crate::diagnostics::{RustDiagnostic, parse_diagnostics};
34use crate::error::CodeError;
35use crate::harness::{HARNESS_TEMPLATE, extract_structured_output, validate_rust_source};
36
37/// Configuration for the [`RustExecutor`] pipeline.
38///
39/// # Example
40///
41/// ```rust
42/// use adk_code::RustExecutorConfig;
43///
44/// let config = RustExecutorConfig::default();
45/// assert_eq!(config.rustc_path, "rustc");
46/// ```
47#[derive(Debug, Clone)]
48pub struct RustExecutorConfig {
49    /// Path to the `rustc` compiler binary.
50    pub rustc_path: String,
51    /// Explicit path to the `serde_json` rlib. If `None`, the executor
52    /// attempts automatic discovery via `cargo metadata`.
53    pub serde_json_path: Option<PathBuf>,
54    /// Extra flags passed to `rustc` during compilation.
55    pub rustc_flags: Vec<String>,
56}
57
58impl Default for RustExecutorConfig {
59    fn default() -> Self {
60        Self { rustc_path: "rustc".to_string(), serde_json_path: None, rustc_flags: vec![] }
61    }
62}
63
64/// Result of a successful [`RustExecutor::execute`] call.
65///
66/// Contains the sandbox execution result plus any compiler diagnostics
67/// (warnings) that were emitted during the check step.
68#[derive(Debug, Clone)]
69pub struct CodeResult {
70    /// The sandbox execution result (stdout, stderr, exit_code, duration).
71    pub exec_result: ExecResult,
72    /// Compiler diagnostics from the check step (warnings only — errors halt execution).
73    pub diagnostics: Vec<RustDiagnostic>,
74    /// Structured JSON output extracted from the last stdout line, if any.
75    pub output: Option<serde_json::Value>,
76    /// Display stdout (everything before the structured output line).
77    pub display_stdout: String,
78}
79
80/// Rust-specific executor that compiles code through a check → build → execute pipeline
81/// and delegates execution to a [`SandboxBackend`].
82///
83/// # Example
84///
85/// ```rust,ignore
86/// use adk_code::{RustExecutor, RustExecutorConfig};
87/// use adk_sandbox::ProcessBackend;
88/// use std::sync::Arc;
89///
90/// let backend = Arc::new(ProcessBackend::default());
91/// let executor = RustExecutor::new(backend, RustExecutorConfig::default());
92/// let result = executor.execute("fn run(input: serde_json::Value) -> serde_json::Value { input }", None).await?;
93/// ```
94pub struct RustExecutor {
95    backend: Arc<dyn SandboxBackend>,
96    config: RustExecutorConfig,
97}
98
99impl RustExecutor {
100    /// Create a new executor with the given sandbox backend and configuration.
101    pub fn new(backend: Arc<dyn SandboxBackend>, config: RustExecutorConfig) -> Self {
102        Self { backend, config }
103    }
104
105    /// Execute Rust code through the check → build → execute pipeline.
106    ///
107    /// The `input` parameter is optional JSON that will be serialized to stdin
108    /// for the harness `run()` function.
109    ///
110    /// # Errors
111    ///
112    /// - [`CodeError::InvalidCode`] if the source fails pre-compilation validation
113    /// - [`CodeError::CompileError`] if the check step finds error-level diagnostics
114    /// - [`CodeError::DependencyNotFound`] if `serde_json` cannot be located
115    /// - [`CodeError::Sandbox`] if the backend fails during execution
116    #[instrument(skip_all, fields(backend = %self.backend.name()))]
117    pub async fn execute(
118        &self,
119        code: &str,
120        input: Option<&serde_json::Value>,
121        timeout: Duration,
122    ) -> Result<CodeResult, CodeError> {
123        // Pre-compilation validation.
124        validate_rust_source(code).map_err(|e| CodeError::InvalidCode(e.to_string()))?;
125
126        // Create temp directory for compilation artifacts.
127        let tmp_dir = tempfile::tempdir()
128            .map_err(|e| CodeError::InvalidCode(format!("failed to create temp directory: {e}")))?;
129
130        let source_path = tmp_dir.path().join("main.rs");
131        let binary_path = tmp_dir.path().join("main");
132
133        // Write harnessed source.
134        let harnessed_source = HARNESS_TEMPLATE.replace("{user_code}", code);
135        tokio::fs::write(&source_path, &harnessed_source)
136            .await
137            .map_err(|e| CodeError::InvalidCode(format!("failed to write source file: {e}")))?;
138
139        debug!(source_path = %source_path.display(), "wrote harnessed source");
140
141        // Locate serde_json dependency.
142        let serde_json_path = self.find_serde_json_dep().await?;
143
144        // Step 1: Check — compile with --error-format=json, parse diagnostics.
145        let diagnostics = self.check(&source_path, &serde_json_path, timeout).await?;
146
147        // Step 2: Build — compile to binary.
148        self.build(&source_path, &binary_path, &serde_json_path, timeout).await?;
149
150        info!("compilation succeeded, delegating execution to sandbox backend");
151
152        // Step 3: Execute — delegate to SandboxBackend.
153        let stdin = input.map(|v| serde_json::to_string(v).unwrap_or_default());
154        let exec_request = ExecRequest {
155            language: Language::Command,
156            code: binary_path.to_string_lossy().to_string(),
157            stdin,
158            timeout,
159            memory_limit_mb: None,
160            env: HashMap::new(),
161        };
162
163        let exec_result = self.backend.execute(exec_request).await?;
164
165        // Extract structured output from stdout.
166        let (output, display_stdout) = extract_structured_output(&exec_result.stdout);
167
168        debug!(
169            exit_code = exec_result.exit_code,
170            duration_ms = exec_result.duration.as_millis() as u64,
171            has_output = output.is_some(),
172            "execution completed"
173        );
174
175        Ok(CodeResult { exec_result, diagnostics, output, display_stdout })
176    }
177
178    /// Check step: compile with `--error-format=json` and `--emit=metadata`
179    /// to parse diagnostics without producing a binary.
180    ///
181    /// Returns warnings (errors halt with [`CodeError::CompileError`]).
182    async fn check(
183        &self,
184        source_path: &Path,
185        serde_json_path: &Option<PathBuf>,
186        timeout: Duration,
187    ) -> Result<Vec<RustDiagnostic>, CodeError> {
188        // Use a temp directory for metadata output to avoid /dev/null issues on macOS.
189        let check_dir = tempfile::tempdir().map_err(|e| {
190            CodeError::InvalidCode(format!("failed to create check temp directory: {e}"))
191        })?;
192        let metadata_out = check_dir.path().join("check_output");
193
194        let mut cmd = tokio::process::Command::new(&self.config.rustc_path);
195        cmd.arg(source_path)
196            .arg("--edition")
197            .arg("2021")
198            .arg("--error-format=json")
199            .arg("--color")
200            .arg("never")
201            .arg("--emit=metadata")
202            .arg("-o")
203            .arg(&metadata_out);
204
205        self.add_serde_json_flags(&mut cmd, serde_json_path);
206        self.add_extra_flags(&mut cmd);
207
208        cmd.stdout(std::process::Stdio::piped());
209        cmd.stderr(std::process::Stdio::piped());
210
211        let output = match tokio::time::timeout(timeout, cmd.output()).await {
212            Ok(Ok(output)) => output,
213            Ok(Err(e)) => {
214                return Err(CodeError::InvalidCode(format!(
215                    "failed to invoke rustc for check: {e}"
216                )));
217            }
218            Err(_) => {
219                return Err(CodeError::InvalidCode("check step timed out".to_string()));
220            }
221        };
222
223        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
224        let diagnostics = parse_diagnostics(&stderr);
225
226        let has_errors = diagnostics.iter().any(|d| d.level == "error");
227        if has_errors {
228            debug!(
229                error_count = diagnostics.iter().filter(|d| d.level == "error").count(),
230                "check step found errors"
231            );
232            return Err(CodeError::CompileError { diagnostics, stderr });
233        }
234
235        let warning_count = diagnostics.iter().filter(|d| d.level == "warning").count();
236        if warning_count > 0 {
237            debug!(warning_count, "check step found warnings");
238        }
239
240        Ok(diagnostics)
241    }
242
243    /// Build step: compile to a binary.
244    async fn build(
245        &self,
246        source_path: &Path,
247        binary_path: &Path,
248        serde_json_path: &Option<PathBuf>,
249        timeout: Duration,
250    ) -> Result<(), CodeError> {
251        let mut cmd = tokio::process::Command::new(&self.config.rustc_path);
252        cmd.arg(source_path).arg("-o").arg(binary_path).arg("--edition").arg("2021");
253
254        self.add_serde_json_flags(&mut cmd, serde_json_path);
255        self.add_extra_flags(&mut cmd);
256
257        cmd.stdout(std::process::Stdio::piped());
258        cmd.stderr(std::process::Stdio::piped());
259
260        let output = match tokio::time::timeout(timeout, cmd.output()).await {
261            Ok(Ok(output)) => output,
262            Ok(Err(e)) => {
263                return Err(CodeError::InvalidCode(format!(
264                    "failed to invoke rustc for build: {e}"
265                )));
266            }
267            Err(_) => {
268                return Err(CodeError::InvalidCode("build step timed out".to_string()));
269            }
270        };
271
272        if !output.status.success() {
273            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
274            let diagnostics = parse_diagnostics(&stderr);
275            return Err(CodeError::CompileError { diagnostics, stderr });
276        }
277
278        Ok(())
279    }
280
281    /// Add `--extern serde_json=...` and `-L dependency=...` flags to a rustc command.
282    fn add_serde_json_flags(
283        &self,
284        cmd: &mut tokio::process::Command,
285        serde_json_path: &Option<PathBuf>,
286    ) {
287        if let Some(dep_path) = serde_json_path {
288            cmd.arg("--extern").arg(format!("serde_json={}", dep_path.display()));
289
290            if let Some(parent) = dep_path.parent() {
291                cmd.arg("-L").arg(format!("dependency={}", parent.display()));
292            }
293        }
294    }
295
296    /// Add extra rustc flags from config.
297    fn add_extra_flags(&self, cmd: &mut tokio::process::Command) {
298        for flag in &self.config.rustc_flags {
299            cmd.arg(flag);
300        }
301    }
302
303    /// Locate the `serde_json` rlib using the fallback chain:
304    /// 1. Explicit config path → use if exists
305    /// 2. `cargo metadata` → find rlib in target/debug/deps
306    /// 3. Return [`CodeError::DependencyNotFound`] with instructions
307    async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, CodeError> {
308        let mut searched = Vec::new();
309
310        // 1. Explicit config path.
311        if let Some(ref path) = self.config.serde_json_path {
312            if path.exists() {
313                debug!(path = %path.display(), "using configured serde_json path");
314                return Ok(Some(path.clone()));
315            }
316            searched.push(format!("config: {}", path.display()));
317        }
318
319        // 2. cargo metadata scan.
320        let output = tokio::process::Command::new("cargo")
321            .args(["metadata", "--format-version=1", "--no-deps"])
322            .stdout(std::process::Stdio::piped())
323            .stderr(std::process::Stdio::null())
324            .output()
325            .await;
326
327        if let Ok(output) = output
328            && output.status.success()
329            && let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout)
330            && let Some(target_dir) = metadata["target_directory"].as_str()
331        {
332            let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
333            searched.push(format!("cargo metadata: {}", deps_dir.display()));
334            if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
335                debug!(path = %rlib.display(), "found serde_json via cargo metadata");
336                return Ok(Some(rlib));
337            }
338        } else {
339            searched.push("cargo metadata: command failed".to_string());
340        }
341
342        // 3. Descriptive error.
343        Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
344    }
345}
346
347/// Find an rlib file matching the given crate name in a directory.
348async fn find_rlib_in_dir(dir: &Path, crate_name: &str) -> Option<PathBuf> {
349    let prefix = format!("lib{crate_name}-");
350    let mut entries = match tokio::fs::read_dir(dir).await {
351        Ok(entries) => entries,
352        Err(_) => return None,
353    };
354
355    while let Ok(Some(entry)) = entries.next_entry().await {
356        let name = entry.file_name();
357        let name_str = name.to_string_lossy();
358        if name_str.starts_with(&prefix) && name_str.ends_with(".rlib") {
359            return Some(entry.path());
360        }
361    }
362    None
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use adk_sandbox::{BackendCapabilities, EnforcedLimits, SandboxError};
369    use async_trait::async_trait;
370    use std::sync::Mutex;
371
372    /// A mock sandbox backend for unit testing.
373    struct MockBackend {
374        /// Captured requests for assertion.
375        captured: Mutex<Vec<ExecRequest>>,
376        /// Canned response to return.
377        response: Mutex<Option<Result<ExecResult, SandboxError>>>,
378    }
379
380    impl MockBackend {
381        fn new(response: Result<ExecResult, SandboxError>) -> Self {
382            Self { captured: Mutex::new(Vec::new()), response: Mutex::new(Some(response)) }
383        }
384
385        fn success(stdout: &str) -> Self {
386            Self::new(Ok(ExecResult {
387                stdout: stdout.to_string(),
388                stderr: String::new(),
389                exit_code: 0,
390                duration: Duration::from_millis(10),
391            }))
392        }
393    }
394
395    #[async_trait]
396    impl SandboxBackend for MockBackend {
397        fn name(&self) -> &str {
398            "mock"
399        }
400
401        fn capabilities(&self) -> BackendCapabilities {
402            BackendCapabilities {
403                supported_languages: vec![Language::Command],
404                isolation_class: "mock".to_string(),
405                enforced_limits: EnforcedLimits {
406                    timeout: true,
407                    memory: false,
408                    network_isolation: false,
409                    filesystem_write_isolation: false,
410                    filesystem_read_isolation: false,
411                    environment_isolation: false,
412                },
413            }
414        }
415
416        async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
417            self.captured.lock().unwrap().push(request);
418            self.response
419                .lock()
420                .unwrap()
421                .take()
422                .unwrap_or(Err(SandboxError::ExecutionFailed("no canned response".to_string())))
423        }
424    }
425
426    #[test]
427    fn default_config() {
428        let config = RustExecutorConfig::default();
429        assert_eq!(config.rustc_path, "rustc");
430        assert!(config.serde_json_path.is_none());
431        assert!(config.rustc_flags.is_empty());
432    }
433
434    #[tokio::test]
435    async fn check_valid_code_produces_no_errors() {
436        let backend = Arc::new(MockBackend::success(r#"{"result":42}"#));
437        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
438
439        let tmp_dir = tempfile::tempdir().unwrap();
440        let source_path = tmp_dir.path().join("valid.rs");
441        let code = r#"
442fn run(input: serde_json::Value) -> serde_json::Value {
443    input
444}
445"#;
446        let harnessed = HARNESS_TEMPLATE.replace("{user_code}", code);
447        tokio::fs::write(&source_path, &harnessed).await.unwrap();
448
449        // The check step may fail if serde_json is not available, but it should
450        // NOT fail with CodeError::InvalidCode for valid source.
451        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
452
453        // If serde_json is not found, rustc will produce an error about the
454        // missing extern crate — that's a compile error, not invalid code.
455        match result {
456            Ok(diagnostics) => {
457                // No error-level diagnostics.
458                assert!(
459                    !diagnostics.iter().any(|d| d.level == "error"),
460                    "expected no error diagnostics for valid code"
461                );
462            }
463            Err(CodeError::CompileError { diagnostics, .. }) => {
464                // Acceptable: serde_json not linked, so rustc reports an error.
465                assert!(
466                    diagnostics.iter().any(|d| d.level == "error"),
467                    "expected error diagnostics when serde_json is missing"
468                );
469            }
470            Err(other) => panic!("unexpected error: {other}"),
471        }
472    }
473
474    #[tokio::test]
475    async fn check_invalid_code_returns_compile_error() {
476        let backend = Arc::new(MockBackend::success(""));
477        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
478
479        let tmp_dir = tempfile::tempdir().unwrap();
480        let source_path = tmp_dir.path().join("invalid.rs");
481        // Deliberately broken Rust code.
482        let code = "fn broken( { }";
483        tokio::fs::write(&source_path, code).await.unwrap();
484
485        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
486
487        match result {
488            Err(CodeError::CompileError { diagnostics, stderr }) => {
489                assert!(
490                    diagnostics.iter().any(|d| d.level == "error"),
491                    "expected at least one error diagnostic"
492                );
493                assert!(!stderr.is_empty(), "expected non-empty stderr");
494            }
495            other => panic!("expected CompileError, got: {other:?}"),
496        }
497    }
498
499    #[tokio::test]
500    async fn check_warnings_are_returned_without_halting() {
501        let backend = Arc::new(MockBackend::success(""));
502        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
503
504        let tmp_dir = tempfile::tempdir().unwrap();
505        let source_path = tmp_dir.path().join("warnings.rs");
506        // Code with an unused variable warning.
507        let code = "fn main() { let x = 42; }";
508        tokio::fs::write(&source_path, code).await.unwrap();
509
510        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
511
512        match result {
513            Ok(diagnostics) => {
514                // Should have at least one warning about unused variable.
515                assert!(
516                    diagnostics.iter().any(|d| d.level == "warning"),
517                    "expected at least one warning diagnostic, got: {diagnostics:?}"
518                );
519            }
520            Err(CodeError::CompileError { diagnostics, .. }) => {
521                // If there are errors too, that's unexpected for this code.
522                panic!("unexpected compile errors: {diagnostics:?}");
523            }
524            Err(other) => panic!("unexpected error: {other}"),
525        }
526    }
527
528    #[tokio::test]
529    async fn serde_json_discovery_config_path_exists() {
530        let tmp_dir = tempfile::tempdir().unwrap();
531        let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
532        tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
533
534        let config =
535            RustExecutorConfig { serde_json_path: Some(fake_rlib.clone()), ..Default::default() };
536        let backend = Arc::new(MockBackend::success(""));
537        let executor = RustExecutor::new(backend, config);
538
539        let result = executor.find_serde_json_dep().await.unwrap();
540        assert_eq!(result, Some(fake_rlib));
541    }
542
543    #[tokio::test]
544    async fn serde_json_discovery_config_path_missing() {
545        let config = RustExecutorConfig {
546            serde_json_path: Some(PathBuf::from("/nonexistent/libserde_json.rlib")),
547            ..Default::default()
548        };
549        let backend = Arc::new(MockBackend::success(""));
550        let executor = RustExecutor::new(backend, config);
551
552        // With a non-existent config path and no cargo metadata fallback,
553        // this should try cargo metadata and then fail with DependencyNotFound.
554        let result = executor.find_serde_json_dep().await;
555        match result {
556            Err(CodeError::DependencyNotFound { name, searched }) => {
557                assert_eq!(name, "serde_json");
558                assert!(
559                    searched.iter().any(|s| s.contains("/nonexistent/")),
560                    "expected searched to include the config path, got: {searched:?}"
561                );
562            }
563            // If cargo metadata happens to find it, that's also acceptable.
564            Ok(Some(_)) => {}
565            other => panic!("unexpected result: {other:?}"),
566        }
567    }
568
569    #[test]
570    fn code_error_from_sandbox_error() {
571        let sandbox_err = SandboxError::Timeout { timeout: Duration::from_secs(5) };
572        let code_err: CodeError = sandbox_err.into();
573        assert!(matches!(code_err, CodeError::Sandbox(_)));
574        assert!(code_err.to_string().contains("sandbox error"));
575    }
576
577    #[test]
578    fn validate_rejects_fn_main() {
579        let code = "fn main() { }";
580        let result = validate_rust_source(code);
581        assert!(result.is_err());
582    }
583
584    #[test]
585    fn validate_accepts_valid_run() {
586        let code = r#"
587fn run(input: serde_json::Value) -> serde_json::Value {
588    input
589}
590"#;
591        assert!(validate_rust_source(code).is_ok());
592    }
593}