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 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 Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
346 }
347}
348
349async 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 struct MockBackend {
376 captured: Mutex<Vec<ExecRequest>>,
378 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 let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
453
454 match result {
457 Ok(diagnostics) => {
458 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 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 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 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 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 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 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 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}