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            if output.status.success() {
329                if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
330                    if let Some(target_dir) = metadata["target_directory"].as_str() {
331                        let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
332                        searched.push(format!("cargo metadata: {}", deps_dir.display()));
333                        if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
334                            debug!(path = %rlib.display(), "found serde_json via cargo metadata");
335                            return Ok(Some(rlib));
336                        }
337                    }
338                }
339            }
340        } else {
341            searched.push("cargo metadata: command failed".to_string());
342        }
343
344        // 3. Descriptive error.
345        Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
346    }
347}
348
349/// Find an rlib file matching the given crate name in a directory.
350async fn find_rlib_in_dir(dir: &Path, crate_name: &str) -> Option<PathBuf> {
351    let prefix = format!("lib{crate_name}-");
352    let mut entries = match tokio::fs::read_dir(dir).await {
353        Ok(entries) => entries,
354        Err(_) => return None,
355    };
356
357    while let Ok(Some(entry)) = entries.next_entry().await {
358        let name = entry.file_name();
359        let name_str = name.to_string_lossy();
360        if name_str.starts_with(&prefix) && name_str.ends_with(".rlib") {
361            return Some(entry.path());
362        }
363    }
364    None
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use adk_sandbox::{BackendCapabilities, EnforcedLimits, SandboxError};
371    use async_trait::async_trait;
372    use std::sync::Mutex;
373
374    /// A mock sandbox backend for unit testing.
375    struct MockBackend {
376        /// Captured requests for assertion.
377        captured: Mutex<Vec<ExecRequest>>,
378        /// Canned response to return.
379        response: Mutex<Option<Result<ExecResult, SandboxError>>>,
380    }
381
382    impl MockBackend {
383        fn new(response: Result<ExecResult, SandboxError>) -> Self {
384            Self { captured: Mutex::new(Vec::new()), response: Mutex::new(Some(response)) }
385        }
386
387        fn success(stdout: &str) -> Self {
388            Self::new(Ok(ExecResult {
389                stdout: stdout.to_string(),
390                stderr: String::new(),
391                exit_code: 0,
392                duration: Duration::from_millis(10),
393            }))
394        }
395    }
396
397    #[async_trait]
398    impl SandboxBackend for MockBackend {
399        fn name(&self) -> &str {
400            "mock"
401        }
402
403        fn capabilities(&self) -> BackendCapabilities {
404            BackendCapabilities {
405                supported_languages: vec![Language::Command],
406                isolation_class: "mock".to_string(),
407                enforced_limits: EnforcedLimits {
408                    timeout: true,
409                    memory: false,
410                    network_isolation: false,
411                    filesystem_isolation: false,
412                    environment_isolation: false,
413                },
414            }
415        }
416
417        async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
418            self.captured.lock().unwrap().push(request);
419            self.response
420                .lock()
421                .unwrap()
422                .take()
423                .unwrap_or(Err(SandboxError::ExecutionFailed("no canned response".to_string())))
424        }
425    }
426
427    #[test]
428    fn default_config() {
429        let config = RustExecutorConfig::default();
430        assert_eq!(config.rustc_path, "rustc");
431        assert!(config.serde_json_path.is_none());
432        assert!(config.rustc_flags.is_empty());
433    }
434
435    #[tokio::test]
436    async fn check_valid_code_produces_no_errors() {
437        let backend = Arc::new(MockBackend::success(r#"{"result":42}"#));
438        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
439
440        let tmp_dir = tempfile::tempdir().unwrap();
441        let source_path = tmp_dir.path().join("valid.rs");
442        let code = r#"
443fn run(input: serde_json::Value) -> serde_json::Value {
444    input
445}
446"#;
447        let harnessed = HARNESS_TEMPLATE.replace("{user_code}", code);
448        tokio::fs::write(&source_path, &harnessed).await.unwrap();
449
450        // The check step may fail if serde_json is not available, but it should
451        // NOT fail with CodeError::InvalidCode for valid source.
452        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
453
454        // If serde_json is not found, rustc will produce an error about the
455        // missing extern crate — that's a compile error, not invalid code.
456        match result {
457            Ok(diagnostics) => {
458                // No error-level diagnostics.
459                assert!(
460                    !diagnostics.iter().any(|d| d.level == "error"),
461                    "expected no error diagnostics for valid code"
462                );
463            }
464            Err(CodeError::CompileError { diagnostics, .. }) => {
465                // Acceptable: serde_json not linked, so rustc reports an error.
466                assert!(
467                    diagnostics.iter().any(|d| d.level == "error"),
468                    "expected error diagnostics when serde_json is missing"
469                );
470            }
471            Err(other) => panic!("unexpected error: {other}"),
472        }
473    }
474
475    #[tokio::test]
476    async fn check_invalid_code_returns_compile_error() {
477        let backend = Arc::new(MockBackend::success(""));
478        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
479
480        let tmp_dir = tempfile::tempdir().unwrap();
481        let source_path = tmp_dir.path().join("invalid.rs");
482        // Deliberately broken Rust code.
483        let code = "fn broken( { }";
484        tokio::fs::write(&source_path, code).await.unwrap();
485
486        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
487
488        match result {
489            Err(CodeError::CompileError { diagnostics, stderr }) => {
490                assert!(
491                    diagnostics.iter().any(|d| d.level == "error"),
492                    "expected at least one error diagnostic"
493                );
494                assert!(!stderr.is_empty(), "expected non-empty stderr");
495            }
496            other => panic!("expected CompileError, got: {other:?}"),
497        }
498    }
499
500    #[tokio::test]
501    async fn check_warnings_are_returned_without_halting() {
502        let backend = Arc::new(MockBackend::success(""));
503        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
504
505        let tmp_dir = tempfile::tempdir().unwrap();
506        let source_path = tmp_dir.path().join("warnings.rs");
507        // Code with an unused variable warning.
508        let code = "fn main() { let x = 42; }";
509        tokio::fs::write(&source_path, code).await.unwrap();
510
511        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
512
513        match result {
514            Ok(diagnostics) => {
515                // Should have at least one warning about unused variable.
516                assert!(
517                    diagnostics.iter().any(|d| d.level == "warning"),
518                    "expected at least one warning diagnostic, got: {diagnostics:?}"
519                );
520            }
521            Err(CodeError::CompileError { diagnostics, .. }) => {
522                // If there are errors too, that's unexpected for this code.
523                panic!("unexpected compile errors: {diagnostics:?}");
524            }
525            Err(other) => panic!("unexpected error: {other}"),
526        }
527    }
528
529    #[tokio::test]
530    async fn serde_json_discovery_config_path_exists() {
531        let tmp_dir = tempfile::tempdir().unwrap();
532        let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
533        tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
534
535        let config =
536            RustExecutorConfig { serde_json_path: Some(fake_rlib.clone()), ..Default::default() };
537        let backend = Arc::new(MockBackend::success(""));
538        let executor = RustExecutor::new(backend, config);
539
540        let result = executor.find_serde_json_dep().await.unwrap();
541        assert_eq!(result, Some(fake_rlib));
542    }
543
544    #[tokio::test]
545    async fn serde_json_discovery_config_path_missing() {
546        let config = RustExecutorConfig {
547            serde_json_path: Some(PathBuf::from("/nonexistent/libserde_json.rlib")),
548            ..Default::default()
549        };
550        let backend = Arc::new(MockBackend::success(""));
551        let executor = RustExecutor::new(backend, config);
552
553        // With a non-existent config path and no cargo metadata fallback,
554        // this should try cargo metadata and then fail with DependencyNotFound.
555        let result = executor.find_serde_json_dep().await;
556        match result {
557            Err(CodeError::DependencyNotFound { name, searched }) => {
558                assert_eq!(name, "serde_json");
559                assert!(
560                    searched.iter().any(|s| s.contains("/nonexistent/")),
561                    "expected searched to include the config path, got: {searched:?}"
562                );
563            }
564            // If cargo metadata happens to find it, that's also acceptable.
565            Ok(Some(_)) => {}
566            other => panic!("unexpected result: {other:?}"),
567        }
568    }
569
570    #[test]
571    fn code_error_from_sandbox_error() {
572        let sandbox_err = SandboxError::Timeout { timeout: Duration::from_secs(5) };
573        let code_err: CodeError = sandbox_err.into();
574        assert!(matches!(code_err, CodeError::Sandbox(_)));
575        assert!(code_err.to_string().contains("sandbox error"));
576    }
577
578    #[test]
579    fn validate_rejects_fn_main() {
580        let code = "fn main() { }";
581        let result = validate_rust_source(code);
582        assert!(result.is_err());
583    }
584
585    #[test]
586    fn validate_accepts_valid_run() {
587        let code = r#"
588fn run(input: serde_json::Value) -> serde_json::Value {
589    input
590}
591"#;
592        assert!(validate_rust_source(code).is_ok());
593    }
594}