herolib_code/rust_builder/
mod.rs1mod cargo;
2mod error;
3
4#[cfg(feature = "rhai")]
5pub mod rhai;
6
7pub use cargo::{BinaryTarget, CargoMetadata};
8pub use error::{BuilderResult, RustBuilderError};
9
10use cargo::{find_cargo_toml, get_target_dir, parse_cargo_toml};
11use herolib_core::text::path_fix;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub enum BuildProfile {
18 #[default]
20 Debug,
21 Release,
23}
24
25impl BuildProfile {
26 pub fn cargo_flag(&self) -> &'static str {
28 match self {
29 BuildProfile::Debug => "",
30 BuildProfile::Release => "--release",
31 }
32 }
33
34 pub fn target_subdir(&self) -> &'static str {
36 match self {
37 BuildProfile::Debug => "debug",
38 BuildProfile::Release => "release",
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum BuildTarget {
46 Bin(String),
48 Lib,
50 Example(String),
52 AllBins,
54 All,
56}
57
58impl BuildTarget {
59 pub fn to_cargo_args(&self) -> Vec<&str> {
61 match self {
62 BuildTarget::Bin(name) => vec!["--bin", name],
63 BuildTarget::Lib => vec!["--lib"],
64 BuildTarget::Example(name) => vec!["--example", name],
65 BuildTarget::AllBins => vec!["--bins"],
66 BuildTarget::All => vec![],
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct BuildResult {
74 pub success: bool,
76
77 pub exit_code: i32,
79
80 pub stdout: String,
82
83 pub stderr: String,
85
86 pub artifacts: Vec<PathBuf>,
88
89 pub copied_to: Option<PathBuf>,
91}
92
93#[derive(Debug, Clone)]
99pub struct RustBuilder {
100 start_path: PathBuf,
102
103 cargo_toml_path: Option<PathBuf>,
105
106 cargo_metadata: Option<CargoMetadata>,
108
109 profile: BuildProfile,
111
112 target: Option<BuildTarget>,
114
115 features: Vec<String>,
117
118 all_features: bool,
120
121 no_default_features: bool,
123
124 copy_to_hero_bin: bool,
126
127 output_dir: Option<PathBuf>,
129
130 extra_args: Vec<String>,
132
133 verbose: bool,
135}
136
137impl Default for RustBuilder {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl RustBuilder {
144 pub fn new() -> Self {
146 Self {
147 start_path: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
148 cargo_toml_path: None,
149 cargo_metadata: None,
150 profile: BuildProfile::Debug,
151 target: None,
152 features: Vec::new(),
153 all_features: false,
154 no_default_features: false,
155 copy_to_hero_bin: false,
156 output_dir: None,
157 extra_args: Vec::new(),
158 verbose: false,
159 }
160 }
161
162 pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
165 let mut builder = Self::new();
166 builder.start_path = path.as_ref().to_path_buf();
167 builder
168 }
169
170 pub fn release(mut self) -> Self {
172 self.profile = BuildProfile::Release;
173 self
174 }
175
176 pub fn debug(mut self) -> Self {
178 self.profile = BuildProfile::Debug;
179 self
180 }
181
182 pub fn profile(mut self, profile: BuildProfile) -> Self {
184 self.profile = profile;
185 self
186 }
187
188 pub fn bin(mut self, name: impl Into<String>) -> Self {
190 self.target = Some(BuildTarget::Bin(name.into()));
191 self
192 }
193
194 pub fn lib(mut self) -> Self {
196 self.target = Some(BuildTarget::Lib);
197 self
198 }
199
200 pub fn example(mut self, name: impl Into<String>) -> Self {
202 self.target = Some(BuildTarget::Example(name.into()));
203 self
204 }
205
206 pub fn all_bins(mut self) -> Self {
208 self.target = Some(BuildTarget::AllBins);
209 self
210 }
211
212 pub fn feature(mut self, feature: impl Into<String>) -> Self {
214 self.features.push(feature.into());
215 self
216 }
217
218 pub fn features(mut self, features: Vec<String>) -> Self {
220 self.features.extend(features);
221 self
222 }
223
224 pub fn all_features(mut self) -> Self {
226 self.all_features = true;
227 self
228 }
229
230 pub fn no_default_features(mut self) -> Self {
232 self.no_default_features = true;
233 self
234 }
235
236 pub fn copy_to_hero_bin(mut self) -> Self {
239 self.copy_to_hero_bin = true;
240 self
241 }
242
243 pub fn output_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
246 let path_str = path.as_ref().to_string_lossy();
247 let expanded = path_fix(&path_str);
248 self.output_dir = Some(PathBuf::from(expanded));
249 self
250 }
251
252 pub fn arg(mut self, arg: impl Into<String>) -> Self {
254 self.extra_args.push(arg.into());
255 self
256 }
257
258 pub fn verbose(mut self) -> Self {
260 self.verbose = true;
261 self
262 }
263
264 pub fn discover(&mut self) -> BuilderResult<&CargoMetadata> {
267 let cargo_path = find_cargo_toml(&self.start_path).ok_or_else(|| {
269 RustBuilderError::CargoTomlNotFound {
270 path: self.start_path.clone(),
271 }
272 })?;
273
274 let metadata = parse_cargo_toml(&cargo_path)?;
276
277 self.cargo_toml_path = Some(cargo_path);
278 self.cargo_metadata = Some(metadata);
279
280 Ok(self.cargo_metadata.as_ref().unwrap())
281 }
282
283 pub fn cargo_toml_path(&self) -> Option<&Path> {
285 self.cargo_toml_path.as_deref()
286 }
287
288 pub fn project_root(&self) -> Option<&Path> {
290 self.cargo_toml_path.as_ref().and_then(|p| p.parent())
291 }
292
293 pub fn metadata(&self) -> Option<&CargoMetadata> {
295 self.cargo_metadata.as_ref()
296 }
297
298 pub fn list_binaries(&mut self) -> BuilderResult<Vec<BinaryTarget>> {
300 self.discover()?;
301 Ok(self
302 .cargo_metadata
303 .as_ref()
304 .map(|m| m.binaries.clone())
305 .unwrap_or_default())
306 }
307
308 pub fn build(mut self) -> BuilderResult<BuildResult> {
310 if self.cargo_metadata.is_none() {
312 self.discover()?;
313 }
314
315 let project_root = self.project_root().unwrap();
316 let metadata = self.cargo_metadata.as_ref().unwrap();
317
318 eprintln!("[rust_builder] Starting build...");
320 eprintln!("[rust_builder] Project: {}", metadata.name);
321 eprintln!("[rust_builder] Root: {}", project_root.display());
322 eprintln!("[rust_builder] Profile: {:?}", self.profile);
323 eprintln!("[rust_builder] Edition: {}", metadata.edition);
324
325 let mut cmd = Command::new("cargo");
327 cmd.current_dir(project_root);
328 cmd.arg("build");
329
330 if !self.profile.cargo_flag().is_empty() {
332 cmd.arg(self.profile.cargo_flag());
333 eprintln!("[rust_builder] Profile flag: {}", self.profile.cargo_flag());
334 }
335
336 if let Some(target) = &self.target {
338 let args = target.to_cargo_args();
339 eprintln!("[rust_builder] Target: {:?}", target);
340 for arg in args {
341 cmd.arg(arg);
342 }
343 } else {
344 eprintln!("[rust_builder] Target: all (default)");
345 }
346
347 if self.all_features {
349 cmd.arg("--all-features");
350 eprintln!("[rust_builder] Features: all");
351 } else if !self.features.is_empty() {
352 cmd.arg("--features");
353 cmd.arg(self.features.join(","));
354 eprintln!("[rust_builder] Features: {}", self.features.join(","));
355 } else if self.no_default_features {
356 eprintln!("[rust_builder] Features: none (no defaults)");
357 } else {
358 eprintln!("[rust_builder] Features: default");
359 }
360
361 if self.no_default_features {
362 cmd.arg("--no-default-features");
363 }
364
365 for arg in &self.extra_args {
367 cmd.arg(arg);
368 eprintln!("[rust_builder] Extra arg: {}", arg);
369 }
370
371 eprintln!(
372 "[rust_builder] Executing: cargo build {:?}",
373 self.profile.cargo_flag()
374 );
375
376 let output = cmd.output()?;
378
379 let success = output.status.success();
380 let exit_code = output.status.code().unwrap_or(-1);
381 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
382 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
383
384 eprintln!("[rust_builder] Build exit code: {}", exit_code);
385 eprintln!("[rust_builder] Build success: {}", success);
386
387 if self.verbose {
388 println!("STDOUT:\n{}", stdout);
389 println!("STDERR:\n{}", stderr);
390 }
391
392 let artifacts = if success {
394 eprintln!("[rust_builder] Finding artifacts...");
395 let arts = self.find_artifacts()?;
396 eprintln!("[rust_builder] Found {} artifacts", arts.len());
397 for art in &arts {
398 eprintln!("[rust_builder] - {}", art.display());
399 }
400 arts
401 } else {
402 eprintln!("[rust_builder] Build failed, not finding artifacts");
403 return Err(RustBuilderError::BuildFailed {
404 code: exit_code,
405 stderr,
406 });
407 };
408
409 let copied_to = if self.copy_to_hero_bin || self.output_dir.is_some() {
411 eprintln!("[rust_builder] Copying artifacts...");
412 let dest = self.copy_artifacts(&artifacts)?;
413 eprintln!("[rust_builder] Artifacts copied to: {}", dest.display());
414 Some(dest)
415 } else {
416 eprintln!(
417 "[rust_builder] Not copying artifacts (copy_to_hero_bin={}, output_dir={})",
418 self.copy_to_hero_bin,
419 self.output_dir.is_some()
420 );
421 None
422 };
423
424 eprintln!("[rust_builder] Build complete!");
425
426 Ok(BuildResult {
427 success,
428 exit_code,
429 stdout,
430 stderr,
431 artifacts,
432 copied_to,
433 })
434 }
435
436 fn find_artifacts(&self) -> BuilderResult<Vec<PathBuf>> {
438 let project_root = self.project_root().unwrap();
439 let target_dir = get_target_dir(project_root);
440 let profile_dir = target_dir.join(self.profile.target_subdir());
441 let metadata = self.cargo_metadata.as_ref().unwrap();
442
443 let mut artifacts = Vec::new();
444
445 match &self.target {
446 Some(BuildTarget::Bin(name)) | Some(BuildTarget::Example(name)) => {
447 let artifact = self.find_binary(&profile_dir, name)?;
448 artifacts.push(artifact);
449 }
450 Some(BuildTarget::Lib) => {
451 let lib_name = metadata
452 .lib_name
453 .clone()
454 .unwrap_or_else(|| metadata.name.replace("-", "_"));
455 let artifact = self.find_library(&profile_dir, &lib_name)?;
456 artifacts.push(artifact);
457 }
458 Some(BuildTarget::AllBins) => {
459 for bin in &metadata.binaries {
460 if let Ok(artifact) = self.find_binary(&profile_dir, &bin.name) {
461 artifacts.push(artifact);
462 }
463 }
464 }
465 Some(BuildTarget::All) | None => {
466 for bin in &metadata.binaries {
468 if let Ok(artifact) = self.find_binary(&profile_dir, &bin.name) {
469 artifacts.push(artifact);
470 }
471 }
472 if metadata.has_lib {
474 let lib_name = metadata
475 .lib_name
476 .clone()
477 .unwrap_or_else(|| metadata.name.replace("-", "_"));
478 if let Ok(artifact) = self.find_library(&profile_dir, &lib_name) {
479 artifacts.push(artifact);
480 }
481 }
482 }
483 }
484
485 if artifacts.is_empty() {
486 return Err(RustBuilderError::ArtifactNotFound { path: profile_dir });
487 }
488
489 Ok(artifacts)
490 }
491
492 fn find_binary(&self, profile_dir: &Path, name: &str) -> BuilderResult<PathBuf> {
494 let binary_name = if cfg!(windows) {
495 format!("{}.exe", name)
496 } else {
497 name.to_string()
498 };
499
500 let artifact = profile_dir.join(&binary_name);
501 if artifact.exists() {
502 Ok(artifact)
503 } else {
504 Err(RustBuilderError::BinaryNotFound {
505 name: name.to_string(),
506 })
507 }
508 }
509
510 fn find_library(&self, profile_dir: &Path, name: &str) -> BuilderResult<PathBuf> {
512 let names = if cfg!(windows) {
514 vec![format!("{}.lib", name), format!("{}.dll", name)]
515 } else if cfg!(target_os = "macos") {
516 vec![format!("lib{}.dylib", name), format!("lib{}.a", name)]
517 } else {
518 vec![format!("lib{}.so", name), format!("lib{}.a", name)]
519 };
520
521 for lib_name in names {
522 let artifact = profile_dir.join(&lib_name);
523 if artifact.exists() {
524 return Ok(artifact);
525 }
526 }
527
528 Err(RustBuilderError::ArtifactNotFound {
529 path: profile_dir.to_path_buf(),
530 })
531 }
532
533 fn copy_artifacts(&self, artifacts: &[PathBuf]) -> BuilderResult<PathBuf> {
535 let dest_dir = if let Some(custom_dir) = &self.output_dir {
537 custom_dir.clone()
538 } else {
539 let home = dirs::home_dir().ok_or_else(|| {
541 RustBuilderError::InvalidConfig("Could not determine home directory".to_string())
542 })?;
543 home.join("hero").join("bin")
544 };
545
546 std::fs::create_dir_all(&dest_dir)?;
548
549 let mut last_dest = dest_dir.clone();
550
551 for artifact in artifacts {
553 let file_name = artifact.file_name().ok_or_else(|| {
554 RustBuilderError::InvalidConfig(format!(
555 "Could not get filename for {:?}",
556 artifact
557 ))
558 })?;
559
560 let dest_path = dest_dir.join(file_name);
561
562 if dest_path.exists() {
564 std::fs::remove_file(&dest_path).map_err(|e| RustBuilderError::CopyFailed {
565 message: format!("Failed to remove existing file: {}", e),
566 })?;
567 }
568
569 std::fs::copy(artifact, &dest_path).map_err(|e| RustBuilderError::CopyFailed {
571 message: format!("Failed to copy {}: {}", file_name.to_string_lossy(), e),
572 })?;
573
574 #[cfg(unix)]
576 {
577 use std::os::unix::fs::PermissionsExt;
578 let perms = std::fs::Permissions::from_mode(0o755);
579 std::fs::set_permissions(&dest_path, perms)?;
580 }
581
582 last_dest = dest_path;
583 }
584
585 Ok(last_dest)
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use std::fs;
593 use tempfile::tempdir;
594
595 fn create_test_cargo_toml(dir: &Path) {
596 let content = r#"
597[package]
598name = "test-project"
599version = "1.0.0"
600edition = "2021"
601
602[[bin]]
603name = "test-app"
604path = "src/main.rs"
605"#;
606 fs::write(dir.join("Cargo.toml"), content).unwrap();
607 }
608
609 #[test]
610 fn test_builder_new() {
611 let builder = RustBuilder::new();
612 assert_eq!(builder.profile, BuildProfile::Debug);
613 assert_eq!(builder.target, None);
614 assert!(!builder.copy_to_hero_bin);
615 }
616
617 #[test]
618 fn test_builder_from_path() {
619 let temp_dir = tempdir().unwrap();
620 let builder = RustBuilder::from_path(temp_dir.path());
621 assert_eq!(builder.start_path, temp_dir.path());
622 }
623
624 #[test]
625 fn test_builder_profile_options() {
626 let builder = RustBuilder::new().release();
627 assert_eq!(builder.profile, BuildProfile::Release);
628
629 let builder = RustBuilder::new().debug();
630 assert_eq!(builder.profile, BuildProfile::Debug);
631 }
632
633 #[test]
634 fn test_builder_target_options() {
635 let builder = RustBuilder::new().bin("myapp");
636 assert_eq!(builder.target, Some(BuildTarget::Bin("myapp".to_string())));
637
638 let builder = RustBuilder::new().lib();
639 assert_eq!(builder.target, Some(BuildTarget::Lib));
640
641 let builder = RustBuilder::new().example("demo");
642 assert_eq!(
643 builder.target,
644 Some(BuildTarget::Example("demo".to_string()))
645 );
646 }
647
648 #[test]
649 fn test_builder_features() {
650 let builder = RustBuilder::new().feature("async").feature("tls");
651 assert_eq!(builder.features.len(), 2);
652
653 let builder = RustBuilder::new().all_features();
654 assert!(builder.all_features);
655
656 let builder = RustBuilder::new().no_default_features();
657 assert!(builder.no_default_features);
658 }
659
660 #[test]
661 fn test_builder_discover() {
662 let temp_dir = tempdir().unwrap();
663 create_test_cargo_toml(temp_dir.path());
664
665 let mut builder = RustBuilder::from_path(temp_dir.path());
666 let metadata = builder.discover().unwrap();
667
668 assert_eq!(metadata.name, "test-project");
669 assert_eq!(metadata.version, "1.0.0");
670 }
671
672 #[test]
673 fn test_builder_cargo_toml_path() {
674 let temp_dir = tempdir().unwrap();
675 create_test_cargo_toml(temp_dir.path());
676
677 let mut builder = RustBuilder::from_path(temp_dir.path());
678 builder.discover().unwrap();
679
680 let cargo_path = builder.cargo_toml_path().unwrap();
681 assert!(cargo_path.exists());
682 assert_eq!(cargo_path.file_name().unwrap(), "Cargo.toml");
683 }
684
685 #[test]
686 fn test_builder_project_root() {
687 let temp_dir = tempdir().unwrap();
688 create_test_cargo_toml(temp_dir.path());
689
690 let mut builder = RustBuilder::from_path(temp_dir.path());
691 builder.discover().unwrap();
692
693 let root = builder.project_root().unwrap();
694 assert_eq!(root, temp_dir.path());
695 }
696
697 #[test]
698 fn test_build_target_to_cargo_args() {
699 let bin_target = BuildTarget::Bin("myapp".to_string());
700 let args = bin_target.to_cargo_args();
701 assert_eq!(args, vec!["--bin", "myapp"]);
702
703 let lib_target = BuildTarget::Lib;
704 let args = lib_target.to_cargo_args();
705 assert_eq!(args, vec!["--lib"]);
706
707 let all_bins_target = BuildTarget::AllBins;
708 let args = all_bins_target.to_cargo_args();
709 assert_eq!(args, vec!["--bins"]);
710
711 let all_target = BuildTarget::All;
712 let args = all_target.to_cargo_args();
713 assert!(args.is_empty());
714 }
715
716 #[test]
717 fn test_build_profile_flags() {
718 assert_eq!(BuildProfile::Debug.cargo_flag(), "");
719 assert_eq!(BuildProfile::Release.cargo_flag(), "--release");
720 assert_eq!(BuildProfile::Debug.target_subdir(), "debug");
721 assert_eq!(BuildProfile::Release.target_subdir(), "release");
722 }
723}