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_isolation: false,
410                    environment_isolation: false,
411                },
412            }
413        }
414
415        async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
416            self.captured.lock().unwrap().push(request);
417            self.response
418                .lock()
419                .unwrap()
420                .take()
421                .unwrap_or(Err(SandboxError::ExecutionFailed("no canned response".to_string())))
422        }
423    }
424
425    #[test]
426    fn default_config() {
427        let config = RustExecutorConfig::default();
428        assert_eq!(config.rustc_path, "rustc");
429        assert!(config.serde_json_path.is_none());
430        assert!(config.rustc_flags.is_empty());
431    }
432
433    #[tokio::test]
434    async fn check_valid_code_produces_no_errors() {
435        let backend = Arc::new(MockBackend::success(r#"{"result":42}"#));
436        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
437
438        let tmp_dir = tempfile::tempdir().unwrap();
439        let source_path = tmp_dir.path().join("valid.rs");
440        let code = r#"
441fn run(input: serde_json::Value) -> serde_json::Value {
442    input
443}
444"#;
445        let harnessed = HARNESS_TEMPLATE.replace("{user_code}", code);
446        tokio::fs::write(&source_path, &harnessed).await.unwrap();
447
448        // The check step may fail if serde_json is not available, but it should
449        // NOT fail with CodeError::InvalidCode for valid source.
450        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
451
452        // If serde_json is not found, rustc will produce an error about the
453        // missing extern crate — that's a compile error, not invalid code.
454        match result {
455            Ok(diagnostics) => {
456                // No error-level diagnostics.
457                assert!(
458                    !diagnostics.iter().any(|d| d.level == "error"),
459                    "expected no error diagnostics for valid code"
460                );
461            }
462            Err(CodeError::CompileError { diagnostics, .. }) => {
463                // Acceptable: serde_json not linked, so rustc reports an error.
464                assert!(
465                    diagnostics.iter().any(|d| d.level == "error"),
466                    "expected error diagnostics when serde_json is missing"
467                );
468            }
469            Err(other) => panic!("unexpected error: {other}"),
470        }
471    }
472
473    #[tokio::test]
474    async fn check_invalid_code_returns_compile_error() {
475        let backend = Arc::new(MockBackend::success(""));
476        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
477
478        let tmp_dir = tempfile::tempdir().unwrap();
479        let source_path = tmp_dir.path().join("invalid.rs");
480        // Deliberately broken Rust code.
481        let code = "fn broken( { }";
482        tokio::fs::write(&source_path, code).await.unwrap();
483
484        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
485
486        match result {
487            Err(CodeError::CompileError { diagnostics, stderr }) => {
488                assert!(
489                    diagnostics.iter().any(|d| d.level == "error"),
490                    "expected at least one error diagnostic"
491                );
492                assert!(!stderr.is_empty(), "expected non-empty stderr");
493            }
494            other => panic!("expected CompileError, got: {other:?}"),
495        }
496    }
497
498    #[tokio::test]
499    async fn check_warnings_are_returned_without_halting() {
500        let backend = Arc::new(MockBackend::success(""));
501        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
502
503        let tmp_dir = tempfile::tempdir().unwrap();
504        let source_path = tmp_dir.path().join("warnings.rs");
505        // Code with an unused variable warning.
506        let code = "fn main() { let x = 42; }";
507        tokio::fs::write(&source_path, code).await.unwrap();
508
509        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
510
511        match result {
512            Ok(diagnostics) => {
513                // Should have at least one warning about unused variable.
514                assert!(
515                    diagnostics.iter().any(|d| d.level == "warning"),
516                    "expected at least one warning diagnostic, got: {diagnostics:?}"
517                );
518            }
519            Err(CodeError::CompileError { diagnostics, .. }) => {
520                // If there are errors too, that's unexpected for this code.
521                panic!("unexpected compile errors: {diagnostics:?}");
522            }
523            Err(other) => panic!("unexpected error: {other}"),
524        }
525    }
526
527    #[tokio::test]
528    async fn serde_json_discovery_config_path_exists() {
529        let tmp_dir = tempfile::tempdir().unwrap();
530        let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
531        tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
532
533        let config =
534            RustExecutorConfig { serde_json_path: Some(fake_rlib.clone()), ..Default::default() };
535        let backend = Arc::new(MockBackend::success(""));
536        let executor = RustExecutor::new(backend, config);
537
538        let result = executor.find_serde_json_dep().await.unwrap();
539        assert_eq!(result, Some(fake_rlib));
540    }
541
542    #[tokio::test]
543    async fn serde_json_discovery_config_path_missing() {
544        let config = RustExecutorConfig {
545            serde_json_path: Some(PathBuf::from("/nonexistent/libserde_json.rlib")),
546            ..Default::default()
547        };
548        let backend = Arc::new(MockBackend::success(""));
549        let executor = RustExecutor::new(backend, config);
550
551        // With a non-existent config path and no cargo metadata fallback,
552        // this should try cargo metadata and then fail with DependencyNotFound.
553        let result = executor.find_serde_json_dep().await;
554        match result {
555            Err(CodeError::DependencyNotFound { name, searched }) => {
556                assert_eq!(name, "serde_json");
557                assert!(
558                    searched.iter().any(|s| s.contains("/nonexistent/")),
559                    "expected searched to include the config path, got: {searched:?}"
560                );
561            }
562            // If cargo metadata happens to find it, that's also acceptable.
563            Ok(Some(_)) => {}
564            other => panic!("unexpected result: {other:?}"),
565        }
566    }
567
568    #[test]
569    fn code_error_from_sandbox_error() {
570        let sandbox_err = SandboxError::Timeout { timeout: Duration::from_secs(5) };
571        let code_err: CodeError = sandbox_err.into();
572        assert!(matches!(code_err, CodeError::Sandbox(_)));
573        assert!(code_err.to_string().contains("sandbox error"));
574    }
575
576    #[test]
577    fn validate_rejects_fn_main() {
578        let code = "fn main() { }";
579        let result = validate_rust_source(code);
580        assert!(result.is_err());
581    }
582
583    #[test]
584    fn validate_accepts_valid_run() {
585        let code = r#"
586fn run(input: serde_json::Value) -> serde_json::Value {
587    input
588}
589"#;
590        assert!(validate_rust_source(code).is_ok());
591    }
592}