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//! See [`RustExecutor`] for where each step runs and what environment it sees.
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use adk_code::{RustExecutor, RustExecutorConfig};
20//! use adk_sandbox::{ProcessBackend, SandboxBackend};
21//! use std::sync::Arc;
22//!
23//! let backend: Arc<dyn SandboxBackend> = Arc::new(ProcessBackend::default());
24//! let executor = RustExecutor::new(backend, RustExecutorConfig::default());
25//! ```
26
27use std::collections::HashMap;
28use std::ffi::OsString;
29use std::path::{Path, PathBuf};
30use std::process::Stdio;
31use std::sync::Arc;
32use std::time::Duration;
33
34use adk_sandbox::{
35    ExecRequest, ExecResult, Language, ProcessBackend, SandboxBackend, SandboxError,
36};
37use tracing::{debug, info, instrument};
38
39use crate::diagnostics::{RustDiagnostic, parse_diagnostics};
40use crate::error::CodeError;
41use crate::harness::{HARNESS_TEMPLATE, extract_structured_output, validate_rust_source};
42use crate::host_process;
43
44/// Configuration for the [`RustExecutor`] pipeline.
45///
46/// # Example
47///
48/// ```rust
49/// use adk_code::RustExecutorConfig;
50///
51/// let config = RustExecutorConfig::default();
52/// assert_eq!(config.rustc_path, "rustc");
53/// ```
54#[derive(Debug, Clone)]
55pub struct RustExecutorConfig {
56    /// Path to the `rustc` compiler binary.
57    pub rustc_path: String,
58    /// Explicit path to the `serde_json` rlib. If `None`, the executor
59    /// attempts automatic discovery via `cargo metadata`.
60    pub serde_json_path: Option<PathBuf>,
61    /// Extra flags passed to `rustc` during compilation.
62    pub rustc_flags: Vec<String>,
63}
64
65impl Default for RustExecutorConfig {
66    fn default() -> Self {
67        Self { rustc_path: "rustc".to_string(), serde_json_path: None, rustc_flags: vec![] }
68    }
69}
70
71/// Output of one `rustc` invocation.
72struct RustcOutput {
73    success: bool,
74    stderr: String,
75}
76
77/// Result of a successful [`RustExecutor::execute`] call.
78///
79/// Contains the sandbox execution result plus any compiler diagnostics
80/// (warnings) that were emitted during the check step.
81#[derive(Debug, Clone)]
82pub struct CodeResult {
83    /// The sandbox execution result (stdout, stderr, exit_code, duration).
84    pub exec_result: ExecResult,
85    /// Compiler diagnostics from the check step (warnings only — errors halt execution).
86    pub diagnostics: Vec<RustDiagnostic>,
87    /// Structured JSON output extracted from the last stdout line, if any.
88    pub output: Option<serde_json::Value>,
89    /// Display stdout (everything before the structured output line).
90    pub display_stdout: String,
91}
92
93/// Rust-specific executor that compiles code through a check → build → execute pipeline
94/// and delegates execution to a [`SandboxBackend`].
95///
96/// # Isolation
97///
98/// | Step | Where it runs | Environment |
99/// |---|---|---|
100/// | Check, build | Inside the backend when it confines filesystem reads and accepts [`Language::Command`] (Unix only); otherwise on the host | Cleared, plus the toolchain allowlist from [`ProcessBackend::toolchain_env`] |
101/// | Execute | Inside the backend | Whatever the backend exposes; [`ProcessBackend`] clears it |
102///
103/// With the environment cleared, `env!` and `option_env!` in user code cannot
104/// embed host credentials into the binary. Compile-time file access such as
105/// `include_str!` reads with the permissions of wherever `rustc` runs: inside a
106/// read-confining backend it is confined like the binary, and on the host it
107/// reaches no file the binary could not also read at run time, because `rustc`
108/// runs on the host only when the backend does not confine reads. On Windows,
109/// `rustc` always runs on the host.
110///
111/// # Example
112///
113/// ```rust,ignore
114/// use adk_code::{RustExecutor, RustExecutorConfig};
115/// use adk_sandbox::ProcessBackend;
116/// use std::sync::Arc;
117///
118/// let backend = Arc::new(ProcessBackend::default());
119/// let executor = RustExecutor::new(backend, RustExecutorConfig::default());
120/// let result = executor.execute("fn run(input: serde_json::Value) -> serde_json::Value { input }", None).await?;
121/// ```
122pub struct RustExecutor {
123    backend: Arc<dyn SandboxBackend>,
124    config: RustExecutorConfig,
125}
126
127impl RustExecutor {
128    /// Create a new executor with the given sandbox backend and configuration.
129    pub fn new(backend: Arc<dyn SandboxBackend>, config: RustExecutorConfig) -> Self {
130        Self { backend, config }
131    }
132
133    /// Execute Rust code through the check → build → execute pipeline.
134    ///
135    /// The `input` parameter is optional JSON that will be serialized to stdin
136    /// for the harness `run()` function.
137    ///
138    /// # Errors
139    ///
140    /// - [`CodeError::InvalidCode`] if the source fails pre-compilation validation
141    /// - [`CodeError::CompileError`] if the check step finds error-level diagnostics
142    /// - [`CodeError::DependencyNotFound`] if `serde_json` cannot be located
143    /// - [`CodeError::Sandbox`] if the backend fails during execution
144    #[instrument(skip_all, fields(backend = %self.backend.name()))]
145    pub async fn execute(
146        &self,
147        code: &str,
148        input: Option<&serde_json::Value>,
149        timeout: Duration,
150    ) -> Result<CodeResult, CodeError> {
151        // Pre-compilation validation.
152        validate_rust_source(code).map_err(|e| CodeError::InvalidCode(e.to_string()))?;
153
154        // Create temp directory for compilation artifacts.
155        let tmp_dir = tempfile::tempdir()
156            .map_err(|e| CodeError::InvalidCode(format!("failed to create temp directory: {e}")))?;
157
158        let source_path = tmp_dir.path().join("main.rs");
159        // The backend runs the binary as a shell command, and `cmd.exe` does not
160        // try `.exe` for a path that already names a directory, so the output file
161        // carries the platform suffix rustc would otherwise add on its own.
162        let binary_path = tmp_dir.path().join(format!("main{}", std::env::consts::EXE_SUFFIX));
163
164        // Write harnessed source.
165        let harnessed_source = HARNESS_TEMPLATE.replace("{user_code}", code);
166        tokio::fs::write(&source_path, &harnessed_source)
167            .await
168            .map_err(|e| CodeError::InvalidCode(format!("failed to write source file: {e}")))?;
169
170        debug!(source_path = %source_path.display(), "wrote harnessed source");
171
172        // Locate serde_json dependency.
173        let serde_json_path = self.find_serde_json_dep().await?;
174
175        // Step 1: Check — compile with --error-format=json, parse diagnostics.
176        let diagnostics = self.check(&source_path, &serde_json_path, timeout).await?;
177
178        // Step 2: Build — compile to binary.
179        self.build(&source_path, &binary_path, &serde_json_path, timeout).await?;
180
181        info!("compilation succeeded, delegating execution to sandbox backend");
182
183        // Step 3: Execute — delegate to SandboxBackend.
184        let stdin = input.map(|v| serde_json::to_string(v).unwrap_or_default());
185        let exec_request = ExecRequest {
186            language: Language::Command,
187            code: binary_path.to_string_lossy().to_string(),
188            stdin,
189            timeout,
190            memory_limit_mb: None,
191            env: HashMap::new(),
192        };
193
194        let exec_result = self.backend.execute(exec_request).await?;
195
196        // Extract structured output from stdout.
197        let (output, display_stdout) = extract_structured_output(&exec_result.stdout);
198
199        debug!(
200            exit_code = exec_result.exit_code,
201            duration_ms = exec_result.duration.as_millis() as u64,
202            has_output = output.is_some(),
203            "execution completed"
204        );
205
206        Ok(CodeResult { exec_result, diagnostics, output, display_stdout })
207    }
208
209    /// Check step: compile with `--error-format=json` and `--emit=metadata`
210    /// to parse diagnostics without producing a binary.
211    ///
212    /// Returns warnings (errors halt with [`CodeError::CompileError`]).
213    async fn check(
214        &self,
215        source_path: &Path,
216        serde_json_path: &Option<PathBuf>,
217        timeout: Duration,
218    ) -> Result<Vec<RustDiagnostic>, CodeError> {
219        // Use a temp directory for metadata output to avoid /dev/null issues on macOS.
220        let check_dir = tempfile::tempdir().map_err(|e| {
221            CodeError::InvalidCode(format!("failed to create check temp directory: {e}"))
222        })?;
223        let metadata_out = check_dir.path().join("check_output");
224
225        let mut args: Vec<OsString> = vec![
226            source_path.into(),
227            "--edition".into(),
228            "2021".into(),
229            "--error-format=json".into(),
230            "--color".into(),
231            "never".into(),
232            "--emit=metadata".into(),
233            "-o".into(),
234            metadata_out.into(),
235        ];
236        args.extend(self.dependency_args(serde_json_path));
237
238        let RustcOutput { stderr, .. } = self.run_rustc(args, timeout, "check").await?;
239        let diagnostics = parse_diagnostics(&stderr);
240
241        let has_errors = diagnostics.iter().any(|d| d.level == "error");
242        if has_errors {
243            debug!(
244                error_count = diagnostics.iter().filter(|d| d.level == "error").count(),
245                "check step found errors"
246            );
247            return Err(CodeError::CompileError { diagnostics, stderr });
248        }
249
250        let warning_count = diagnostics.iter().filter(|d| d.level == "warning").count();
251        if warning_count > 0 {
252            debug!(warning_count, "check step found warnings");
253        }
254
255        Ok(diagnostics)
256    }
257
258    /// Build step: compile to a binary.
259    async fn build(
260        &self,
261        source_path: &Path,
262        binary_path: &Path,
263        serde_json_path: &Option<PathBuf>,
264        timeout: Duration,
265    ) -> Result<(), CodeError> {
266        let mut args: Vec<OsString> = vec![
267            source_path.into(),
268            "-o".into(),
269            binary_path.into(),
270            "--edition".into(),
271            "2021".into(),
272        ];
273        args.extend(self.dependency_args(serde_json_path));
274
275        let output = self.run_rustc(args, timeout, "build").await?;
276        if !output.success {
277            let diagnostics = parse_diagnostics(&output.stderr);
278            return Err(CodeError::CompileError { diagnostics, stderr: output.stderr });
279        }
280
281        Ok(())
282    }
283
284    /// The `--extern serde_json=...` and `-L dependency=...` flags, followed by
285    /// the extra flags from config.
286    fn dependency_args(&self, serde_json_path: &Option<PathBuf>) -> Vec<OsString> {
287        let mut args = Vec::new();
288        if let Some(dep_path) = serde_json_path {
289            args.push("--extern".into());
290            args.push(format!("serde_json={}", dep_path.display()).into());
291
292            if let Some(parent) = dep_path.parent() {
293                args.push("-L".into());
294                args.push(format!("dependency={}", parent.display()).into());
295            }
296        }
297        args.extend(self.config.rustc_flags.iter().map(OsString::from));
298        args
299    }
300
301    /// Runs `rustc` with `args` under `timeout`, from a cleared environment plus
302    /// the toolchain allowlist.
303    ///
304    /// When the backend confines filesystem reads, `rustc` runs through it as a
305    /// shell command, so compile-time file access (`include_str!`,
306    /// `include_bytes!`) is confined like the binary. Otherwise it runs on the
307    /// host, where it can read nothing the unconfined binary could not.
308    async fn run_rustc(
309        &self,
310        args: Vec<OsString>,
311        timeout: Duration,
312        step: &str,
313    ) -> Result<RustcOutput, CodeError> {
314        let capabilities = self.backend.capabilities();
315        // The command is quoted for a POSIX shell, so only Unix backends receive it.
316        let compile_in_backend = cfg!(unix)
317            && capabilities.enforced_limits.filesystem_read_isolation
318            && capabilities.supported_languages.contains(&Language::Command);
319
320        if compile_in_backend {
321            let code = std::iter::once(OsString::from(&self.config.rustc_path))
322                .chain(args)
323                .map(|arg| format!("'{}'", arg.to_string_lossy().replace('\'', r"'\''")))
324                .collect::<Vec<_>>()
325                .join(" ");
326            let env = ProcessBackend::toolchain_env()
327                .into_iter()
328                .filter_map(|(key, value)| value.into_string().ok().map(|value| (key, value)))
329                .collect();
330            debug!(step, "running rustc inside the sandbox backend");
331
332            let request = ExecRequest {
333                language: Language::Command,
334                code,
335                stdin: None,
336                timeout,
337                memory_limit_mb: None,
338                env,
339            };
340            return match self.backend.execute(request).await {
341                Ok(result) => {
342                    Ok(RustcOutput { success: result.exit_code == 0, stderr: result.stderr })
343                }
344                Err(SandboxError::Timeout { .. }) => {
345                    Err(CodeError::InvalidCode(format!("{step} step timed out")))
346                }
347                Err(error) => Err(error.into()),
348            };
349        }
350
351        let mut cmd = host_process::rustc_command(&self.config.rustc_path);
352        cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
353
354        match tokio::time::timeout(timeout, cmd.output()).await {
355            Ok(Ok(output)) => Ok(RustcOutput {
356                success: output.status.success(),
357                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
358            }),
359            Ok(Err(e)) => {
360                Err(CodeError::InvalidCode(format!("failed to invoke rustc for {step}: {e}")))
361            }
362            // `kill_on_drop` terminated rustc when the timed-out future was dropped.
363            Err(_) => Err(CodeError::InvalidCode(format!("{step} step timed out"))),
364        }
365    }
366
367    /// Locate the `serde_json` rlib using the fallback chain:
368    /// 1. Explicit config path → use if exists
369    /// 2. `cargo metadata` → find rlib in target/debug/deps
370    /// 3. Return [`CodeError::DependencyNotFound`] with instructions
371    async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, CodeError> {
372        let mut searched = Vec::new();
373
374        // 1. Explicit config path.
375        if let Some(ref path) = self.config.serde_json_path {
376            if path.exists() {
377                debug!(path = %path.display(), "using configured serde_json path");
378                return Ok(Some(path.clone()));
379            }
380            searched.push(format!("config: {}", path.display()));
381        }
382
383        // 2. cargo metadata scan.
384        let output = tokio::process::Command::new("cargo")
385            .args(["metadata", "--format-version=1", "--no-deps"])
386            .stdout(std::process::Stdio::piped())
387            .stderr(std::process::Stdio::null())
388            .output()
389            .await;
390
391        if let Ok(output) = output
392            && output.status.success()
393            && let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout)
394            && let Some(target_dir) = metadata["target_directory"].as_str()
395        {
396            let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
397            searched.push(format!("cargo metadata: {}", deps_dir.display()));
398            if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
399                debug!(path = %rlib.display(), "found serde_json via cargo metadata");
400                return Ok(Some(rlib));
401            }
402        } else {
403            searched.push("cargo metadata: command failed".to_string());
404        }
405
406        // 3. Descriptive error.
407        Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
408    }
409}
410
411/// Find an rlib file matching the given crate name in a directory.
412async fn find_rlib_in_dir(dir: &Path, crate_name: &str) -> Option<PathBuf> {
413    let prefix = format!("lib{crate_name}-");
414    let mut entries = match tokio::fs::read_dir(dir).await {
415        Ok(entries) => entries,
416        Err(_) => return None,
417    };
418
419    while let Ok(Some(entry)) = entries.next_entry().await {
420        let name = entry.file_name();
421        let name_str = name.to_string_lossy();
422        if name_str.starts_with(&prefix) && name_str.ends_with(".rlib") {
423            return Some(entry.path());
424        }
425    }
426    None
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use adk_sandbox::{BackendCapabilities, EnforcedLimits, SandboxError};
433    use async_trait::async_trait;
434    use std::sync::Mutex;
435
436    /// A mock sandbox backend for unit testing.
437    struct MockBackend {
438        /// Captured requests for assertion.
439        captured: Mutex<Vec<ExecRequest>>,
440        /// Canned response to return.
441        response: Mutex<Option<Result<ExecResult, SandboxError>>>,
442    }
443
444    impl MockBackend {
445        fn new(response: Result<ExecResult, SandboxError>) -> Self {
446            Self { captured: Mutex::new(Vec::new()), response: Mutex::new(Some(response)) }
447        }
448
449        fn success(stdout: &str) -> Self {
450            Self::new(Ok(ExecResult {
451                stdout: stdout.to_string(),
452                stderr: String::new(),
453                exit_code: 0,
454                duration: Duration::from_millis(10),
455            }))
456        }
457    }
458
459    #[async_trait]
460    impl SandboxBackend for MockBackend {
461        fn name(&self) -> &str {
462            "mock"
463        }
464
465        fn capabilities(&self) -> BackendCapabilities {
466            BackendCapabilities {
467                supported_languages: vec![Language::Command],
468                isolation_class: "mock".to_string(),
469                enforced_limits: EnforcedLimits {
470                    timeout: true,
471                    memory: false,
472                    network_isolation: false,
473                    filesystem_write_isolation: false,
474                    filesystem_read_isolation: false,
475                    environment_isolation: false,
476                },
477            }
478        }
479
480        async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
481            self.captured.lock().unwrap().push(request);
482            self.response
483                .lock()
484                .unwrap()
485                .take()
486                .unwrap_or(Err(SandboxError::ExecutionFailed("no canned response".to_string())))
487        }
488    }
489
490    /// A backend that confines filesystem reads and records every request.
491    struct ReadConfiningBackend {
492        captured: Mutex<Vec<ExecRequest>>,
493    }
494
495    #[async_trait]
496    impl SandboxBackend for ReadConfiningBackend {
497        fn name(&self) -> &str {
498            "read-confining"
499        }
500
501        fn capabilities(&self) -> BackendCapabilities {
502            BackendCapabilities {
503                supported_languages: vec![Language::Command],
504                isolation_class: "mock".to_string(),
505                enforced_limits: EnforcedLimits {
506                    timeout: true,
507                    memory: false,
508                    network_isolation: true,
509                    filesystem_write_isolation: true,
510                    filesystem_read_isolation: true,
511                    environment_isolation: true,
512                },
513            }
514        }
515
516        async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
517            self.captured.lock().unwrap().push(request);
518            Ok(ExecResult {
519                stdout: r#"{"ok":true}"#.to_string(),
520                stderr: String::new(),
521                exit_code: 0,
522                duration: Duration::from_millis(1),
523            })
524        }
525    }
526
527    /// Compilation must not escape a backend that confines filesystem reads,
528    /// otherwise `include_str!` reads host files the binary itself cannot.
529    #[cfg(unix)]
530    #[tokio::test]
531    async fn compilation_runs_inside_a_read_confining_backend() {
532        let tmp_dir = tempfile::tempdir().unwrap();
533        let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
534        tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
535
536        let backend = Arc::new(ReadConfiningBackend { captured: Mutex::new(Vec::new()) });
537        let config = RustExecutorConfig {
538            rustc_path: "/nonexistent/rustc-must-not-run-on-host".to_string(),
539            serde_json_path: Some(fake_rlib),
540            ..Default::default()
541        };
542        let executor = RustExecutor::new(backend.clone(), config);
543
544        let code = "fn run(input: serde_json::Value) -> serde_json::Value { input }";
545        let result = executor.execute(code, None, Duration::from_secs(5)).await.unwrap();
546        assert_eq!(result.output, Some(serde_json::json!({ "ok": true })));
547
548        let captured = backend.captured.lock().unwrap();
549        assert_eq!(captured.len(), 3, "check, build, and execute all go through the backend");
550
551        let toolchain: Vec<String> =
552            ProcessBackend::toolchain_env().into_iter().map(|(key, _)| key).collect();
553        for (request, step_flag) in captured[..2].iter().zip(["'--error-format=json'", "'-o'"]) {
554            assert_eq!(request.language, Language::Command);
555            assert!(
556                request.code.starts_with("'/nonexistent/rustc-must-not-run-on-host' "),
557                "{}",
558                request.code
559            );
560            assert!(request.code.contains(step_flag), "{}", request.code);
561            assert!(
562                request.env.keys().all(|key| toolchain.contains(key)),
563                "compile environment must be the toolchain allowlist: {:?}",
564                request.env.keys()
565            );
566        }
567        assert!(captured[2].env.is_empty());
568    }
569
570    #[test]
571    fn default_config() {
572        let config = RustExecutorConfig::default();
573        assert_eq!(config.rustc_path, "rustc");
574        assert!(config.serde_json_path.is_none());
575        assert!(config.rustc_flags.is_empty());
576    }
577
578    #[tokio::test]
579    async fn check_valid_code_produces_no_errors() {
580        let backend = Arc::new(MockBackend::success(r#"{"result":42}"#));
581        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
582
583        let tmp_dir = tempfile::tempdir().unwrap();
584        let source_path = tmp_dir.path().join("valid.rs");
585        let code = r#"
586fn run(input: serde_json::Value) -> serde_json::Value {
587    input
588}
589"#;
590        let harnessed = HARNESS_TEMPLATE.replace("{user_code}", code);
591        tokio::fs::write(&source_path, &harnessed).await.unwrap();
592
593        // The check step may fail if serde_json is not available, but it should
594        // NOT fail with CodeError::InvalidCode for valid source.
595        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
596
597        // If serde_json is not found, rustc will produce an error about the
598        // missing extern crate — that's a compile error, not invalid code.
599        match result {
600            Ok(diagnostics) => {
601                // No error-level diagnostics.
602                assert!(
603                    !diagnostics.iter().any(|d| d.level == "error"),
604                    "expected no error diagnostics for valid code"
605                );
606            }
607            Err(CodeError::CompileError { diagnostics, .. }) => {
608                // Acceptable: serde_json not linked, so rustc reports an error.
609                assert!(
610                    diagnostics.iter().any(|d| d.level == "error"),
611                    "expected error diagnostics when serde_json is missing"
612                );
613            }
614            Err(other) => panic!("unexpected error: {other}"),
615        }
616    }
617
618    #[tokio::test]
619    async fn check_invalid_code_returns_compile_error() {
620        let backend = Arc::new(MockBackend::success(""));
621        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
622
623        let tmp_dir = tempfile::tempdir().unwrap();
624        let source_path = tmp_dir.path().join("invalid.rs");
625        // Deliberately broken Rust code.
626        let code = "fn broken( { }";
627        tokio::fs::write(&source_path, code).await.unwrap();
628
629        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
630
631        match result {
632            Err(CodeError::CompileError { diagnostics, stderr }) => {
633                assert!(
634                    diagnostics.iter().any(|d| d.level == "error"),
635                    "expected at least one error diagnostic"
636                );
637                assert!(!stderr.is_empty(), "expected non-empty stderr");
638            }
639            other => panic!("expected CompileError, got: {other:?}"),
640        }
641    }
642
643    #[tokio::test]
644    async fn check_warnings_are_returned_without_halting() {
645        let backend = Arc::new(MockBackend::success(""));
646        let executor = RustExecutor::new(backend, RustExecutorConfig::default());
647
648        let tmp_dir = tempfile::tempdir().unwrap();
649        let source_path = tmp_dir.path().join("warnings.rs");
650        // Code with an unused variable warning.
651        let code = "fn main() { let x = 42; }";
652        tokio::fs::write(&source_path, code).await.unwrap();
653
654        let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
655
656        match result {
657            Ok(diagnostics) => {
658                // Should have at least one warning about unused variable.
659                assert!(
660                    diagnostics.iter().any(|d| d.level == "warning"),
661                    "expected at least one warning diagnostic, got: {diagnostics:?}"
662                );
663            }
664            Err(CodeError::CompileError { diagnostics, .. }) => {
665                // If there are errors too, that's unexpected for this code.
666                panic!("unexpected compile errors: {diagnostics:?}");
667            }
668            Err(other) => panic!("unexpected error: {other}"),
669        }
670    }
671
672    #[tokio::test]
673    async fn serde_json_discovery_config_path_exists() {
674        let tmp_dir = tempfile::tempdir().unwrap();
675        let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
676        tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
677
678        let config =
679            RustExecutorConfig { serde_json_path: Some(fake_rlib.clone()), ..Default::default() };
680        let backend = Arc::new(MockBackend::success(""));
681        let executor = RustExecutor::new(backend, config);
682
683        let result = executor.find_serde_json_dep().await.unwrap();
684        assert_eq!(result, Some(fake_rlib));
685    }
686
687    #[tokio::test]
688    async fn serde_json_discovery_config_path_missing() {
689        let config = RustExecutorConfig {
690            serde_json_path: Some(PathBuf::from("/nonexistent/libserde_json.rlib")),
691            ..Default::default()
692        };
693        let backend = Arc::new(MockBackend::success(""));
694        let executor = RustExecutor::new(backend, config);
695
696        // With a non-existent config path and no cargo metadata fallback,
697        // this should try cargo metadata and then fail with DependencyNotFound.
698        let result = executor.find_serde_json_dep().await;
699        match result {
700            Err(CodeError::DependencyNotFound { name, searched }) => {
701                assert_eq!(name, "serde_json");
702                assert!(
703                    searched.iter().any(|s| s.contains("/nonexistent/")),
704                    "expected searched to include the config path, got: {searched:?}"
705                );
706            }
707            // If cargo metadata happens to find it, that's also acceptable.
708            Ok(Some(_)) => {}
709            other => panic!("unexpected result: {other:?}"),
710        }
711    }
712
713    #[test]
714    fn code_error_from_sandbox_error() {
715        let sandbox_err = SandboxError::Timeout { timeout: Duration::from_secs(5) };
716        let code_err: CodeError = sandbox_err.into();
717        assert!(matches!(code_err, CodeError::Sandbox(_)));
718        assert!(code_err.to_string().contains("sandbox error"));
719    }
720
721    #[test]
722    fn validate_rejects_fn_main() {
723        let code = "fn main() { }";
724        let result = validate_rust_source(code);
725        assert!(result.is_err());
726    }
727
728    #[test]
729    fn validate_accepts_valid_run() {
730        let code = r#"
731fn run(input: serde_json::Value) -> serde_json::Value {
732    input
733}
734"#;
735        assert!(validate_rust_source(code).is_ok());
736    }
737}