mod cargo;
mod error;
#[cfg(feature = "rhai")]
pub mod rhai;
pub use cargo::{BinaryTarget, CargoMetadata};
pub use error::{BuilderResult, RustBuilderError};
use cargo::{find_cargo_toml, get_target_dir, parse_cargo_toml};
use herolib_core::text::path_fix;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BuildProfile {
#[default]
Debug,
Release,
}
impl BuildProfile {
pub fn cargo_flag(&self) -> &'static str {
match self {
BuildProfile::Debug => "",
BuildProfile::Release => "--release",
}
}
pub fn target_subdir(&self) -> &'static str {
match self {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildTarget {
Bin(String),
Lib,
Example(String),
AllBins,
All,
}
impl BuildTarget {
pub fn to_cargo_args(&self) -> Vec<&str> {
match self {
BuildTarget::Bin(name) => vec!["--bin", name],
BuildTarget::Lib => vec!["--lib"],
BuildTarget::Example(name) => vec!["--example", name],
BuildTarget::AllBins => vec!["--bins"],
BuildTarget::All => vec![],
}
}
}
#[derive(Debug, Clone)]
pub struct BuildResult {
pub success: bool,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub artifacts: Vec<PathBuf>,
pub copied_to: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct RustBuilder {
start_path: PathBuf,
cargo_toml_path: Option<PathBuf>,
cargo_metadata: Option<CargoMetadata>,
profile: BuildProfile,
target: Option<BuildTarget>,
features: Vec<String>,
all_features: bool,
no_default_features: bool,
copy_to_hero_bin: bool,
output_dir: Option<PathBuf>,
extra_args: Vec<String>,
verbose: bool,
}
impl Default for RustBuilder {
fn default() -> Self {
Self::new()
}
}
impl RustBuilder {
pub fn new() -> Self {
Self {
start_path: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
cargo_toml_path: None,
cargo_metadata: None,
profile: BuildProfile::Debug,
target: None,
features: Vec::new(),
all_features: false,
no_default_features: false,
copy_to_hero_bin: false,
output_dir: None,
extra_args: Vec::new(),
verbose: false,
}
}
pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
let mut builder = Self::new();
builder.start_path = path.as_ref().to_path_buf();
builder
}
pub fn release(mut self) -> Self {
self.profile = BuildProfile::Release;
self
}
pub fn debug(mut self) -> Self {
self.profile = BuildProfile::Debug;
self
}
pub fn profile(mut self, profile: BuildProfile) -> Self {
self.profile = profile;
self
}
pub fn bin(mut self, name: impl Into<String>) -> Self {
self.target = Some(BuildTarget::Bin(name.into()));
self
}
pub fn lib(mut self) -> Self {
self.target = Some(BuildTarget::Lib);
self
}
pub fn example(mut self, name: impl Into<String>) -> Self {
self.target = Some(BuildTarget::Example(name.into()));
self
}
pub fn all_bins(mut self) -> Self {
self.target = Some(BuildTarget::AllBins);
self
}
pub fn feature(mut self, feature: impl Into<String>) -> Self {
self.features.push(feature.into());
self
}
pub fn features(mut self, features: Vec<String>) -> Self {
self.features.extend(features);
self
}
pub fn all_features(mut self) -> Self {
self.all_features = true;
self
}
pub fn no_default_features(mut self) -> Self {
self.no_default_features = true;
self
}
pub fn copy_to_hero_bin(mut self) -> Self {
self.copy_to_hero_bin = true;
self
}
pub fn output_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
let path_str = path.as_ref().to_string_lossy();
let expanded = path_fix(&path_str);
self.output_dir = Some(PathBuf::from(expanded));
self
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.extra_args.push(arg.into());
self
}
pub fn verbose(mut self) -> Self {
self.verbose = true;
self
}
pub fn discover(&mut self) -> BuilderResult<&CargoMetadata> {
let cargo_path = find_cargo_toml(&self.start_path).ok_or_else(|| {
RustBuilderError::CargoTomlNotFound {
path: self.start_path.clone(),
}
})?;
let metadata = parse_cargo_toml(&cargo_path)?;
self.cargo_toml_path = Some(cargo_path);
self.cargo_metadata = Some(metadata);
Ok(self.cargo_metadata.as_ref().unwrap())
}
pub fn cargo_toml_path(&self) -> Option<&Path> {
self.cargo_toml_path.as_deref()
}
pub fn project_root(&self) -> Option<&Path> {
self.cargo_toml_path.as_ref().and_then(|p| p.parent())
}
pub fn metadata(&self) -> Option<&CargoMetadata> {
self.cargo_metadata.as_ref()
}
pub fn list_binaries(&mut self) -> BuilderResult<Vec<BinaryTarget>> {
self.discover()?;
Ok(self
.cargo_metadata
.as_ref()
.map(|m| m.binaries.clone())
.unwrap_or_default())
}
pub fn build(mut self) -> BuilderResult<BuildResult> {
if self.cargo_metadata.is_none() {
self.discover()?;
}
let project_root = self.project_root().unwrap();
let metadata = self.cargo_metadata.as_ref().unwrap();
eprintln!("[rust_builder] Starting build...");
eprintln!("[rust_builder] Project: {}", metadata.name);
eprintln!("[rust_builder] Root: {}", project_root.display());
eprintln!("[rust_builder] Profile: {:?}", self.profile);
eprintln!("[rust_builder] Edition: {}", metadata.edition);
let mut cmd = Command::new("cargo");
cmd.current_dir(project_root);
cmd.arg("build");
if !self.profile.cargo_flag().is_empty() {
cmd.arg(self.profile.cargo_flag());
eprintln!("[rust_builder] Profile flag: {}", self.profile.cargo_flag());
}
if let Some(target) = &self.target {
let args = target.to_cargo_args();
eprintln!("[rust_builder] Target: {:?}", target);
for arg in args {
cmd.arg(arg);
}
} else {
eprintln!("[rust_builder] Target: all (default)");
}
if self.all_features {
cmd.arg("--all-features");
eprintln!("[rust_builder] Features: all");
} else if !self.features.is_empty() {
cmd.arg("--features");
cmd.arg(self.features.join(","));
eprintln!("[rust_builder] Features: {}", self.features.join(","));
} else if self.no_default_features {
eprintln!("[rust_builder] Features: none (no defaults)");
} else {
eprintln!("[rust_builder] Features: default");
}
if self.no_default_features {
cmd.arg("--no-default-features");
}
for arg in &self.extra_args {
cmd.arg(arg);
eprintln!("[rust_builder] Extra arg: {}", arg);
}
eprintln!(
"[rust_builder] Executing: cargo build {:?}",
self.profile.cargo_flag()
);
let output = cmd.output()?;
let success = output.status.success();
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
eprintln!("[rust_builder] Build exit code: {}", exit_code);
eprintln!("[rust_builder] Build success: {}", success);
if self.verbose {
println!("STDOUT:\n{}", stdout);
println!("STDERR:\n{}", stderr);
}
let artifacts = if success {
eprintln!("[rust_builder] Finding artifacts...");
let arts = self.find_artifacts()?;
eprintln!("[rust_builder] Found {} artifacts", arts.len());
for art in &arts {
eprintln!("[rust_builder] - {}", art.display());
}
arts
} else {
eprintln!("[rust_builder] Build failed, not finding artifacts");
return Err(RustBuilderError::BuildFailed {
code: exit_code,
stderr,
});
};
let copied_to = if self.copy_to_hero_bin || self.output_dir.is_some() {
eprintln!("[rust_builder] Copying artifacts...");
let dest = self.copy_artifacts(&artifacts)?;
eprintln!("[rust_builder] Artifacts copied to: {}", dest.display());
Some(dest)
} else {
eprintln!(
"[rust_builder] Not copying artifacts (copy_to_hero_bin={}, output_dir={})",
self.copy_to_hero_bin,
self.output_dir.is_some()
);
None
};
eprintln!("[rust_builder] Build complete!");
Ok(BuildResult {
success,
exit_code,
stdout,
stderr,
artifacts,
copied_to,
})
}
fn find_artifacts(&self) -> BuilderResult<Vec<PathBuf>> {
let project_root = self.project_root().unwrap();
let target_dir = get_target_dir(project_root);
let profile_dir = target_dir.join(self.profile.target_subdir());
let metadata = self.cargo_metadata.as_ref().unwrap();
let mut artifacts = Vec::new();
match &self.target {
Some(BuildTarget::Bin(name)) | Some(BuildTarget::Example(name)) => {
let artifact = self.find_binary(&profile_dir, name)?;
artifacts.push(artifact);
}
Some(BuildTarget::Lib) => {
let lib_name = metadata
.lib_name
.clone()
.unwrap_or_else(|| metadata.name.replace("-", "_"));
let artifact = self.find_library(&profile_dir, &lib_name)?;
artifacts.push(artifact);
}
Some(BuildTarget::AllBins) => {
for bin in &metadata.binaries {
if let Ok(artifact) = self.find_binary(&profile_dir, &bin.name) {
artifacts.push(artifact);
}
}
}
Some(BuildTarget::All) | None => {
for bin in &metadata.binaries {
if let Ok(artifact) = self.find_binary(&profile_dir, &bin.name) {
artifacts.push(artifact);
}
}
if metadata.has_lib {
let lib_name = metadata
.lib_name
.clone()
.unwrap_or_else(|| metadata.name.replace("-", "_"));
if let Ok(artifact) = self.find_library(&profile_dir, &lib_name) {
artifacts.push(artifact);
}
}
}
}
if artifacts.is_empty() {
return Err(RustBuilderError::ArtifactNotFound { path: profile_dir });
}
Ok(artifacts)
}
fn find_binary(&self, profile_dir: &Path, name: &str) -> BuilderResult<PathBuf> {
let binary_name = if cfg!(windows) {
format!("{}.exe", name)
} else {
name.to_string()
};
let artifact = profile_dir.join(&binary_name);
if artifact.exists() {
Ok(artifact)
} else {
Err(RustBuilderError::BinaryNotFound {
name: name.to_string(),
})
}
}
fn find_library(&self, profile_dir: &Path, name: &str) -> BuilderResult<PathBuf> {
let names = if cfg!(windows) {
vec![format!("{}.lib", name), format!("{}.dll", name)]
} else if cfg!(target_os = "macos") {
vec![format!("lib{}.dylib", name), format!("lib{}.a", name)]
} else {
vec![format!("lib{}.so", name), format!("lib{}.a", name)]
};
for lib_name in names {
let artifact = profile_dir.join(&lib_name);
if artifact.exists() {
return Ok(artifact);
}
}
Err(RustBuilderError::ArtifactNotFound {
path: profile_dir.to_path_buf(),
})
}
fn copy_artifacts(&self, artifacts: &[PathBuf]) -> BuilderResult<PathBuf> {
let dest_dir = if let Some(custom_dir) = &self.output_dir {
custom_dir.clone()
} else {
let home = dirs::home_dir().ok_or_else(|| {
RustBuilderError::InvalidConfig("Could not determine home directory".to_string())
})?;
home.join("hero").join("bin")
};
std::fs::create_dir_all(&dest_dir)?;
let mut last_dest = dest_dir.clone();
for artifact in artifacts {
let file_name = artifact.file_name().ok_or_else(|| {
RustBuilderError::InvalidConfig(format!(
"Could not get filename for {:?}",
artifact
))
})?;
let dest_path = dest_dir.join(file_name);
if dest_path.exists() {
std::fs::remove_file(&dest_path).map_err(|e| RustBuilderError::CopyFailed {
message: format!("Failed to remove existing file: {}", e),
})?;
}
std::fs::copy(artifact, &dest_path).map_err(|e| RustBuilderError::CopyFailed {
message: format!("Failed to copy {}: {}", file_name.to_string_lossy(), e),
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&dest_path, perms)?;
}
last_dest = dest_path;
}
Ok(last_dest)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn create_test_cargo_toml(dir: &Path) {
let content = r#"
[package]
name = "test-project"
version = "1.0.0"
edition = "2021"
[[bin]]
name = "test-app"
path = "src/main.rs"
"#;
fs::write(dir.join("Cargo.toml"), content).unwrap();
}
#[test]
fn test_builder_new() {
let builder = RustBuilder::new();
assert_eq!(builder.profile, BuildProfile::Debug);
assert_eq!(builder.target, None);
assert!(!builder.copy_to_hero_bin);
}
#[test]
fn test_builder_from_path() {
let temp_dir = tempdir().unwrap();
let builder = RustBuilder::from_path(temp_dir.path());
assert_eq!(builder.start_path, temp_dir.path());
}
#[test]
fn test_builder_profile_options() {
let builder = RustBuilder::new().release();
assert_eq!(builder.profile, BuildProfile::Release);
let builder = RustBuilder::new().debug();
assert_eq!(builder.profile, BuildProfile::Debug);
}
#[test]
fn test_builder_target_options() {
let builder = RustBuilder::new().bin("myapp");
assert_eq!(builder.target, Some(BuildTarget::Bin("myapp".to_string())));
let builder = RustBuilder::new().lib();
assert_eq!(builder.target, Some(BuildTarget::Lib));
let builder = RustBuilder::new().example("demo");
assert_eq!(
builder.target,
Some(BuildTarget::Example("demo".to_string()))
);
}
#[test]
fn test_builder_features() {
let builder = RustBuilder::new().feature("async").feature("tls");
assert_eq!(builder.features.len(), 2);
let builder = RustBuilder::new().all_features();
assert!(builder.all_features);
let builder = RustBuilder::new().no_default_features();
assert!(builder.no_default_features);
}
#[test]
fn test_builder_discover() {
let temp_dir = tempdir().unwrap();
create_test_cargo_toml(temp_dir.path());
let mut builder = RustBuilder::from_path(temp_dir.path());
let metadata = builder.discover().unwrap();
assert_eq!(metadata.name, "test-project");
assert_eq!(metadata.version, "1.0.0");
}
#[test]
fn test_builder_cargo_toml_path() {
let temp_dir = tempdir().unwrap();
create_test_cargo_toml(temp_dir.path());
let mut builder = RustBuilder::from_path(temp_dir.path());
builder.discover().unwrap();
let cargo_path = builder.cargo_toml_path().unwrap();
assert!(cargo_path.exists());
assert_eq!(cargo_path.file_name().unwrap(), "Cargo.toml");
}
#[test]
fn test_builder_project_root() {
let temp_dir = tempdir().unwrap();
create_test_cargo_toml(temp_dir.path());
let mut builder = RustBuilder::from_path(temp_dir.path());
builder.discover().unwrap();
let root = builder.project_root().unwrap();
assert_eq!(root, temp_dir.path());
}
#[test]
fn test_build_target_to_cargo_args() {
let bin_target = BuildTarget::Bin("myapp".to_string());
let args = bin_target.to_cargo_args();
assert_eq!(args, vec!["--bin", "myapp"]);
let lib_target = BuildTarget::Lib;
let args = lib_target.to_cargo_args();
assert_eq!(args, vec!["--lib"]);
let all_bins_target = BuildTarget::AllBins;
let args = all_bins_target.to_cargo_args();
assert_eq!(args, vec!["--bins"]);
let all_target = BuildTarget::All;
let args = all_target.to_cargo_args();
assert!(args.is_empty());
}
#[test]
fn test_build_profile_flags() {
assert_eq!(BuildProfile::Debug.cargo_flag(), "");
assert_eq!(BuildProfile::Release.cargo_flag(), "--release");
assert_eq!(BuildProfile::Debug.target_subdir(), "debug");
assert_eq!(BuildProfile::Release.target_subdir(), "release");
}
}