pub mod cache;
pub mod error;
pub mod template;
pub use cache::{CacheConfig, CacheStats, CodeHash, CompilationCache};
pub use error::{CompilationError, CompilationErrorKind};
pub use template::CodeTemplate;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct CompiledBinary {
bytes: Vec<u8>,
hash: CodeHash,
}
impl CompiledBinary {
#[must_use]
pub fn new(bytes: Vec<u8>, hash: CodeHash) -> Self {
Self { bytes, hash }
}
#[must_use]
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
pub fn into_bytes(self) -> Vec<u8> {
self.bytes
}
#[must_use]
pub fn hash(&self) -> CodeHash {
self.hash
}
#[must_use]
pub fn size(&self) -> usize {
self.bytes.len()
}
}
#[derive(Debug)]
pub struct RustCompiler {
cache: CompilationCache,
temp_dir: PathBuf,
template: CodeTemplate,
}
impl RustCompiler {
pub fn new() -> Result<Self, CompilationError> {
Self::with_config(CacheConfig::default())
}
pub fn with_config(cache_config: CacheConfig) -> Result<Self, CompilationError> {
let temp_dir = std::env::temp_dir().join("acton-ai-compiler");
std::fs::create_dir_all(&temp_dir)?;
Self::verify_toolchain()?;
Ok(Self {
cache: CompilationCache::new(cache_config),
temp_dir,
template: CodeTemplate::default(),
})
}
pub fn compile(&self, code: &str) -> Result<CompiledBinary, CompilationError> {
let wrapped = self.template.wrap(code)?;
let hash = CodeHash::from_code(&wrapped);
if let Some(cached) = self.cache.get(hash) {
tracing::debug!(hash = %hash, "compilation cache hit");
return Ok(CompiledBinary::new(cached, hash));
}
tracing::debug!(hash = %hash, "compilation cache miss, compiling");
let project_dir = self.temp_dir.join(format!("rust_code_{}", hash));
self.setup_project(&project_dir, &wrapped)?;
self.run_clippy(&project_dir)?;
let binary = self.compile_to_binary(&project_dir)?;
self.cache.insert(hash, binary.clone());
self.cleanup_project(&project_dir);
Ok(CompiledBinary::new(binary, hash))
}
#[must_use]
pub fn cache_stats(&self) -> CacheStats {
self.cache.stats()
}
pub fn clear_cache(&self) {
self.cache.clear();
}
fn verify_toolchain() -> Result<(), CompilationError> {
let cargo_check = Command::new("cargo").arg("--version").output();
if cargo_check.is_err() {
return Err(CompilationError::toolchain_error(
"cargo",
"install Rust via rustup: https://rustup.rs",
));
}
let target_check = Command::new("rustup")
.args(["target", "list", "--installed"])
.output();
match target_check {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("x86_64-unknown-none") {
return Err(CompilationError::toolchain_error(
"x86_64-unknown-none target",
"rustup target add x86_64-unknown-none",
));
}
}
Err(_) => {
return Err(CompilationError::toolchain_error(
"rustup",
"install Rust via rustup: https://rustup.rs",
));
}
}
Ok(())
}
fn setup_project(&self, dir: &Path, code: &str) -> Result<(), CompilationError> {
std::fs::create_dir_all(dir.join("src"))?;
let cargo_toml = self.template.cargo_toml();
std::fs::write(dir.join("Cargo.toml"), cargo_toml)?;
std::fs::write(dir.join("src/lib.rs"), code)?;
Ok(())
}
fn run_clippy(&self, dir: &Path) -> Result<(), CompilationError> {
let output = Command::new("cargo")
.current_dir(dir)
.args([
"clippy",
"--target",
"x86_64-unknown-none",
"--",
"-D",
"warnings",
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let error_count = count_errors(&stderr);
return Err(CompilationError::clippy_failed(stderr, error_count));
}
Ok(())
}
fn compile_to_binary(&self, dir: &Path) -> Result<Vec<u8>, CompilationError> {
let output = Command::new("cargo")
.current_dir(dir)
.args(["build", "--release", "--target", "x86_64-unknown-none"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CompilationError::compilation_failed(
stderr,
output.status.code(),
));
}
let binary_path = dir
.join("target")
.join("x86_64-unknown-none")
.join("release")
.join("librust_code_guest.a");
std::fs::read(&binary_path).map_err(|e| {
CompilationError::io_error(
"reading compiled binary",
format!("path: {:?}, error: {}", binary_path, e),
)
})
}
fn cleanup_project(&self, dir: &Path) {
let _ = std::fs::remove_dir_all(dir);
}
}
fn count_errors(output: &str) -> usize {
output.matches("error[E").count() + output.matches("error:").count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compiled_binary_new() {
let hash = CodeHash::from_code("test");
let binary = CompiledBinary::new(vec![1, 2, 3], hash);
assert_eq!(binary.bytes(), &[1, 2, 3]);
assert_eq!(binary.hash(), hash);
assert_eq!(binary.size(), 3);
}
#[test]
fn compiled_binary_into_bytes() {
let hash = CodeHash::from_code("test");
let binary = CompiledBinary::new(vec![1, 2, 3], hash);
let bytes = binary.into_bytes();
assert_eq!(bytes, vec![1, 2, 3]);
}
#[test]
fn compiled_binary_is_clone() {
let hash = CodeHash::from_code("test");
let binary1 = CompiledBinary::new(vec![1, 2, 3], hash);
let binary2 = binary1.clone();
assert_eq!(binary1.bytes(), binary2.bytes());
assert_eq!(binary1.hash(), binary2.hash());
}
#[test]
fn count_errors_finds_error_codes() {
let output = "error[E0001]: something\nerror[E0002]: another";
assert_eq!(count_errors(output), 2);
}
#[test]
fn count_errors_finds_plain_errors() {
let output = "error: aborting due to 3 previous errors";
assert_eq!(count_errors(output), 1);
}
#[test]
fn count_errors_counts_both() {
let output = "error[E0001]: something\nerror: aborting";
assert_eq!(count_errors(output), 2);
}
#[test]
fn count_errors_empty() {
assert_eq!(count_errors(""), 0);
}
#[test]
fn count_errors_no_errors() {
let output = "warning: unused variable";
assert_eq!(count_errors(output), 0);
}
#[test]
#[ignore = "requires rust toolchain"]
fn compiler_new_succeeds_with_toolchain() {
let result = RustCompiler::new();
if std::env::var("CI").is_err() {
assert!(result.is_ok() || result.is_err());
}
}
#[test]
fn compiler_temp_dir_path() {
let temp_dir = std::env::temp_dir().join("acton-ai-compiler");
assert!(temp_dir.to_string_lossy().contains("acton-ai-compiler"));
}
}