1use 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#[derive(Debug, Clone)]
48pub struct RustExecutorConfig {
49 pub rustc_path: String,
51 pub serde_json_path: Option<PathBuf>,
54 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#[derive(Debug, Clone)]
69pub struct CodeResult {
70 pub exec_result: ExecResult,
72 pub diagnostics: Vec<RustDiagnostic>,
74 pub output: Option<serde_json::Value>,
76 pub display_stdout: String,
78}
79
80pub struct RustExecutor {
95 backend: Arc<dyn SandboxBackend>,
96 config: RustExecutorConfig,
97}
98
99impl RustExecutor {
100 pub fn new(backend: Arc<dyn SandboxBackend>, config: RustExecutorConfig) -> Self {
102 Self { backend, config }
103 }
104
105 #[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 validate_rust_source(code).map_err(|e| CodeError::InvalidCode(e.to_string()))?;
125
126 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 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 let serde_json_path = self.find_serde_json_dep().await?;
143
144 let diagnostics = self.check(&source_path, &serde_json_path, timeout).await?;
146
147 self.build(&source_path, &binary_path, &serde_json_path, timeout).await?;
149
150 info!("compilation succeeded, delegating execution to sandbox backend");
151
152 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 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 async fn check(
183 &self,
184 source_path: &Path,
185 serde_json_path: &Option<PathBuf>,
186 timeout: Duration,
187 ) -> Result<Vec<RustDiagnostic>, CodeError> {
188 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 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 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 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 async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, CodeError> {
308 let mut searched = Vec::new();
309
310 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 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 Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
344 }
345}
346
347async 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 struct MockBackend {
374 captured: Mutex<Vec<ExecRequest>>,
376 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 let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
451
452 match result {
455 Ok(diagnostics) => {
456 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 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 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 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 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 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 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 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}