Skip to main content

herolib_code/rust_builder/
mod.rs

1mod 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/// Build profile selection.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub enum BuildProfile {
18    /// Development build (faster compilation, no optimizations)
19    #[default]
20    Debug,
21    /// Production build (optimized, slower compilation)
22    Release,
23}
24
25impl BuildProfile {
26    /// Returns the cargo flag for this profile
27    pub fn cargo_flag(&self) -> &'static str {
28        match self {
29            BuildProfile::Debug => "",
30            BuildProfile::Release => "--release",
31        }
32    }
33
34    /// Returns the target subdirectory name
35    pub fn target_subdir(&self) -> &'static str {
36        match self {
37            BuildProfile::Debug => "debug",
38            BuildProfile::Release => "release",
39        }
40    }
41}
42
43/// Specifies what to build.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum BuildTarget {
46    /// Build a specific binary by name
47    Bin(String),
48    /// Build the library
49    Lib,
50    /// Build a specific example
51    Example(String),
52    /// Build all binaries
53    AllBins,
54    /// Build all (default cargo behavior)
55    All,
56}
57
58impl BuildTarget {
59    /// Converts this target to cargo command-line arguments
60    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/// Result of a build operation.
72#[derive(Debug, Clone)]
73pub struct BuildResult {
74    /// Whether the build succeeded
75    pub success: bool,
76
77    /// Exit code from cargo
78    pub exit_code: i32,
79
80    /// Stdout from cargo
81    pub stdout: String,
82
83    /// Stderr from cargo
84    pub stderr: String,
85
86    /// Path to the built artifact(s)
87    pub artifacts: Vec<PathBuf>,
88
89    /// Path where artifact was copied (if copy_to_hero_bin was set)
90    pub copied_to: Option<PathBuf>,
91}
92
93/// Builder for Rust project compilation with smart defaults.
94///
95/// Discovers Cargo.toml by walking up from the starting path,
96/// parses project metadata, and provides methods to build and
97/// copy binaries to ~/hero/bin.
98#[derive(Debug, Clone)]
99pub struct RustBuilder {
100    /// Starting path (file or directory) - walks up to find Cargo.toml
101    start_path: PathBuf,
102
103    /// Resolved path to Cargo.toml (found after discovery)
104    cargo_toml_path: Option<PathBuf>,
105
106    /// Parsed cargo metadata
107    cargo_metadata: Option<CargoMetadata>,
108
109    /// Build profile: Release or Debug
110    profile: BuildProfile,
111
112    /// Specific target to build (binary name, lib, example, etc.)
113    target: Option<BuildTarget>,
114
115    /// Additional cargo features to enable
116    features: Vec<String>,
117
118    /// Whether to use --all-features
119    all_features: bool,
120
121    /// Whether to use --no-default-features
122    no_default_features: bool,
123
124    /// Copy output to ~/hero/bin after build
125    copy_to_hero_bin: bool,
126
127    /// Custom output directory (overrides ~/hero/bin)
128    output_dir: Option<PathBuf>,
129
130    /// Additional cargo arguments
131    extra_args: Vec<String>,
132
133    /// Verbosity level
134    verbose: bool,
135}
136
137impl Default for RustBuilder {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl RustBuilder {
144    /// Creates a new RustBuilder starting from the current directory.
145    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    /// Creates a new RustBuilder starting from the given path.
163    /// The path can be a file or directory - will walk up to find Cargo.toml.
164    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    /// Sets the build profile to Release (production).
171    pub fn release(mut self) -> Self {
172        self.profile = BuildProfile::Release;
173        self
174    }
175
176    /// Sets the build profile to Debug (development).
177    pub fn debug(mut self) -> Self {
178        self.profile = BuildProfile::Debug;
179        self
180    }
181
182    /// Sets the build profile.
183    pub fn profile(mut self, profile: BuildProfile) -> Self {
184        self.profile = profile;
185        self
186    }
187
188    /// Build a specific binary by name.
189    pub fn bin(mut self, name: impl Into<String>) -> Self {
190        self.target = Some(BuildTarget::Bin(name.into()));
191        self
192    }
193
194    /// Build the library.
195    pub fn lib(mut self) -> Self {
196        self.target = Some(BuildTarget::Lib);
197        self
198    }
199
200    /// Build a specific example.
201    pub fn example(mut self, name: impl Into<String>) -> Self {
202        self.target = Some(BuildTarget::Example(name.into()));
203        self
204    }
205
206    /// Build all binaries.
207    pub fn all_bins(mut self) -> Self {
208        self.target = Some(BuildTarget::AllBins);
209        self
210    }
211
212    /// Enable a specific feature.
213    pub fn feature(mut self, feature: impl Into<String>) -> Self {
214        self.features.push(feature.into());
215        self
216    }
217
218    /// Enable multiple features.
219    pub fn features(mut self, features: Vec<String>) -> Self {
220        self.features.extend(features);
221        self
222    }
223
224    /// Enable all features.
225    pub fn all_features(mut self) -> Self {
226        self.all_features = true;
227        self
228    }
229
230    /// Disable default features.
231    pub fn no_default_features(mut self) -> Self {
232        self.no_default_features = true;
233        self
234    }
235
236    /// Copy the built binary to ~/hero/bin after successful build.
237    /// Removes existing file at destination before copying.
238    pub fn copy_to_hero_bin(mut self) -> Self {
239        self.copy_to_hero_bin = true;
240        self
241    }
242
243    /// Set a custom output directory for copying (instead of ~/hero/bin).
244    /// Tilde (~) in paths will be expanded to home directory.
245    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    /// Add extra arguments to pass to cargo.
253    pub fn arg(mut self, arg: impl Into<String>) -> Self {
254        self.extra_args.push(arg.into());
255        self
256    }
257
258    /// Enable verbose output.
259    pub fn verbose(mut self) -> Self {
260        self.verbose = true;
261        self
262    }
263
264    /// Discovers Cargo.toml and parses metadata.
265    /// Called automatically by build() if not called explicitly.
266    pub fn discover(&mut self) -> BuilderResult<&CargoMetadata> {
267        // Find Cargo.toml
268        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        // Parse metadata
275        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    /// Returns the path to Cargo.toml (after discovery).
284    pub fn cargo_toml_path(&self) -> Option<&Path> {
285        self.cargo_toml_path.as_deref()
286    }
287
288    /// Returns the project root directory (parent of Cargo.toml).
289    pub fn project_root(&self) -> Option<&Path> {
290        self.cargo_toml_path.as_ref().and_then(|p| p.parent())
291    }
292
293    /// Returns parsed cargo metadata (after discovery).
294    pub fn metadata(&self) -> Option<&CargoMetadata> {
295        self.cargo_metadata.as_ref()
296    }
297
298    /// Lists all available binaries in the project.
299    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    /// Executes the build with configured options.
309    pub fn build(mut self) -> BuilderResult<BuildResult> {
310        // Discover if not already done
311        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        // Debug: Print build information
319        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        // Construct cargo command
326        let mut cmd = Command::new("cargo");
327        cmd.current_dir(project_root);
328        cmd.arg("build");
329
330        // Add profile flag
331        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        // Add target
337        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        // Add features
348        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        // Add extra args
366        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        // Execute build
377        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        // Determine artifacts if build succeeded
393        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        // Copy artifacts if requested
410        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    /// Finds the built artifacts
437    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                // Try to find all binaries
467                for bin in &metadata.binaries {
468                    if let Ok(artifact) = self.find_binary(&profile_dir, &bin.name) {
469                        artifacts.push(artifact);
470                    }
471                }
472                // Try to find library
473                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    /// Finds a binary artifact
493    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    /// Finds a library artifact
511    fn find_library(&self, profile_dir: &Path, name: &str) -> BuilderResult<PathBuf> {
512        // Try different library naming conventions
513        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    /// Copies artifacts to the output directory
534    fn copy_artifacts(&self, artifacts: &[PathBuf]) -> BuilderResult<PathBuf> {
535        // Determine destination directory
536        let dest_dir = if let Some(custom_dir) = &self.output_dir {
537            custom_dir.clone()
538        } else {
539            // Expand ~/hero/bin
540            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        // Create destination directory if it doesn't exist
547        std::fs::create_dir_all(&dest_dir)?;
548
549        let mut last_dest = dest_dir.clone();
550
551        // Copy each artifact
552        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            // Remove existing file if it exists
563            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            // Copy the file
570            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            // Set executable permissions on Unix
575            #[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}