theater_cli/commands/
build.rs

1use anyhow::{anyhow, Result};
2use clap::Parser;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use tracing::{debug, error, info};
7
8use crate::{error::CliError, output::formatters::BuildResult, CommandContext};
9use theater::config::actor_manifest::ManifestConfig;
10
11#[derive(Debug, Parser)]
12pub struct BuildArgs {
13    /// Directory containing the actor project
14    #[arg(default_value = ".")]
15    pub project_dir: PathBuf,
16
17    /// Build in release mode
18    #[arg(short, long, default_value = "true")]
19    pub release: bool,
20
21    /// Clean the target directory before building
22    #[arg(short, long, default_value = "false")]
23    pub clean: bool,
24}
25
26/// Execute the build command asynchronously (modernized)
27pub async fn execute_async(args: &BuildArgs, ctx: &CommandContext) -> Result<(), CliError> {
28    let project_dir = if args.project_dir.is_absolute() {
29        args.project_dir.clone()
30    } else {
31        std::env::current_dir()
32            .map_err(|e| CliError::file_operation_failed("get current directory", ".", e))?
33            .join(&args.project_dir)
34    };
35
36    debug!("Building actor in directory: {}", project_dir.display());
37    debug!("Release mode: {}", args.release);
38    debug!("Clean build: {}", args.clean);
39
40    // Check if the directory contains a Cargo.toml file
41    let cargo_toml_path = project_dir.join("Cargo.toml");
42    if !cargo_toml_path.exists() {
43        return Err(CliError::invalid_manifest(format!(
44            "Not a Rust project directory (Cargo.toml not found): {}",
45            project_dir.display()
46        )));
47    }
48
49    // Get the package name from Cargo.toml
50    let package_name = get_package_name(&cargo_toml_path)
51        .map_err(|e| CliError::invalid_manifest(format!("Failed to parse Cargo.toml: {}", e)))?;
52
53    // Check for manifest.toml
54    let manifest_path = project_dir.join("manifest.toml");
55    let manifest_exists = manifest_path.exists();
56
57    // Check if cargo-component is installed
58    if !is_cargo_component_installed() {
59        return Err(CliError::build_failed(
60            "cargo-component is not installed. Please install it with 'cargo install cargo-component'."
61        ));
62    }
63
64    // Perform cleaning if requested
65    if args.clean {
66        debug!("Cleaning build artifacts...");
67        let mut clean_cmd = Command::new("cargo");
68        clean_cmd.arg("clean").current_dir(&project_dir);
69
70        if let Err(e) = run_command_with_output(&mut clean_cmd, ctx.verbose) {
71            error!("Failed to clean cargo artifacts: {}", e);
72            // Continue anyway, as this is not fatal
73        }
74    }
75
76    // Build the WebAssembly component using cargo-component
77    debug!(
78        "Building WebAssembly component for actor in {}...",
79        project_dir.display()
80    );
81
82    // Execute cargo component build
83    let mut build_cmd = Command::new("cargo");
84    build_cmd.args(["component", "build", "--target", "wasm32-unknown-unknown"]);
85
86    if args.release {
87        build_cmd.arg("--release");
88    }
89
90    build_cmd.current_dir(&project_dir);
91
92    // Run the build command and capture output
93    let (status, stdout, stderr) =
94        run_command_with_output(&mut build_cmd, ctx.verbose).map_err(|e| {
95            CliError::build_failed(format!("Failed to execute cargo component build: {}", e))
96        })?;
97
98    // Handle build failures
99    if !status.success() {
100        let error_details = if stderr.is_empty() { stdout } else { stderr };
101        return Err(CliError::build_failed(format!(
102            "Cargo component build failed:\\n{}",
103            error_details
104        )));
105    }
106
107    // Construct the path to the built wasm file
108    let build_type = if args.release { "release" } else { "debug" };
109    let wasm_file_name = format!("{}.wasm", package_name.replace('-', "_"));
110    let wasm_path = project_dir
111        .join("target/wasm32-unknown-unknown")
112        .join(build_type)
113        .join(&wasm_file_name);
114
115    // Validate the wasm file exists
116    if !wasm_path.exists() {
117        return Err(CliError::build_failed(format!(
118            "Built WASM file not found at expected path: {}",
119            wasm_path.display()
120        )));
121    }
122
123    // Update the manifest.toml with the new component path if it exists
124    if manifest_exists {
125        let manifest_content = fs::read_to_string(&manifest_path).map_err(|e| {
126            CliError::file_operation_failed(
127                "read manifest.toml",
128                manifest_path.display().to_string(),
129                e,
130            )
131        })?;
132
133        let mut manifest: ManifestConfig = toml::from_str(&manifest_content).map_err(|e| {
134            CliError::invalid_manifest(format!("Failed to parse manifest.toml: {}", e))
135        })?;
136
137        // Update the component path - use absolute path to the wasm file
138        manifest.component = wasm_path.to_string_lossy().to_string();
139
140        // Write the updated manifest
141        let updated_manifest = toml::to_string(&manifest).map_err(|e| {
142            CliError::invalid_manifest(format!("Failed to serialize manifest.toml: {}", e))
143        })?;
144
145        fs::write(&manifest_path, updated_manifest).map_err(|e| {
146            CliError::file_operation_failed(
147                "write manifest.toml",
148                manifest_path.display().to_string(),
149                e,
150            )
151        })?;
152
153        info!(
154            "Updated manifest with component path: {}",
155            wasm_path.display()
156        );
157    }
158
159    // Create build result and output
160    let result = BuildResult {
161        success: true,
162        project_dir,
163        wasm_path: Some(wasm_path),
164        manifest_exists,
165        manifest_path: Some(manifest_path),
166        build_type: build_type.to_string(),
167        package_name,
168        stdout,
169        stderr,
170    };
171
172    ctx.output.output(&result, None)?;
173    Ok(())
174}
175
176/// Run a command and return the status, stdout, and stderr
177fn run_command_with_output(
178    cmd: &mut Command,
179    verbose: bool,
180) -> Result<(std::process::ExitStatus, String, String)> {
181    debug!("Running command: {:?}", cmd);
182    cmd.env("RUST_BACKTRACE", "1");
183    cmd.env("RUST_COLOR", "always");
184    cmd.env("CARGO_TERM_COLOR", "always");
185
186    if verbose {
187        // For verbose mode, we'll just let the command output directly to the console
188        // with all its colors, and then capture the output separately for the result
189        let status = cmd
190            .status()
191            .map_err(|e| anyhow!("Failed to execute command: {}", e))?;
192
193        // If we're in verbose mode and directly showing output, return empty strings for stdout/stderr
194        // since they were already displayed
195        Ok((status, String::new(), String::new()))
196    } else {
197        // For non-verbose mode, capture the output but preserve ANSI color codes
198        // Capture the output
199        let output = cmd
200            .output()
201            .map_err(|e| anyhow!("Failed to execute command: {}", e))?;
202
203        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
204        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
205
206        Ok((output.status, stdout, stderr))
207    }
208}
209
210/// Check if cargo-component is installed
211fn is_cargo_component_installed() -> bool {
212    let output = Command::new("cargo").args(["--list"]).output();
213
214    match output {
215        Ok(output) => {
216            let stdout = String::from_utf8_lossy(&output.stdout);
217            stdout.contains("component")
218        }
219        Err(_) => false,
220    }
221}
222
223/// Extract the package name from Cargo.toml
224fn get_package_name(cargo_toml_path: &Path) -> Result<String> {
225    let cargo_toml = std::fs::read_to_string(cargo_toml_path)?;
226
227    // Simple parse to extract package name
228    for line in cargo_toml.lines() {
229        let line = line.trim();
230        if line.starts_with("name") {
231            let parts: Vec<&str> = line.split('=').collect();
232            if parts.len() >= 2 {
233                let name = parts[1].trim().trim_matches('"').trim_matches('\'');
234                return Ok(name.to_string());
235            }
236        }
237    }
238
239    Err(anyhow!("Could not find package name in Cargo.toml"))
240}