Skip to main content

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    /// Skip the post-build self-contained verification gate (emit the built
26    /// wasm without asserting its import surface). Debugging only.
27    #[arg(long, default_value = "false")]
28    pub no_verify: bool,
29}
30
31/// Execute the build command asynchronously (modernized)
32pub async fn execute_async(args: &BuildArgs, ctx: &CommandContext) -> Result<(), CliError> {
33    let project_dir = if args.project_dir.is_absolute() {
34        args.project_dir.clone()
35    } else {
36        std::env::current_dir()
37            .map_err(|e| CliError::file_operation_failed("get current directory", ".", e))?
38            .join(&args.project_dir)
39    };
40
41    debug!("Building actor in directory: {}", project_dir.display());
42    debug!("Release mode: {}", args.release);
43    debug!("Clean build: {}", args.clean);
44
45    // Check if the directory contains a Cargo.toml file
46    let cargo_toml_path = project_dir.join("Cargo.toml");
47    if !cargo_toml_path.exists() {
48        return Err(CliError::invalid_manifest(format!(
49            "Not a Rust project directory (Cargo.toml not found): {}",
50            project_dir.display()
51        )));
52    }
53
54    // Get the package name from Cargo.toml
55    let package_name = get_package_name(&cargo_toml_path)
56        .map_err(|e| CliError::invalid_manifest(format!("Failed to parse Cargo.toml: {}", e)))?;
57
58    // Check for manifest.toml
59    let manifest_path = project_dir.join("manifest.toml");
60    let manifest_exists = manifest_path.exists();
61
62    // Perform cleaning if requested
63    if args.clean {
64        debug!("Cleaning build artifacts...");
65        let mut clean_cmd = Command::new("cargo");
66        clean_cmd.arg("clean").current_dir(&project_dir);
67
68        if let Err(e) = run_command_with_output(&mut clean_cmd, ctx.is_verbose()) {
69            error!("Failed to clean cargo artifacts: {}", e);
70            // Continue anyway, as this is not fatal
71        }
72    }
73
74    // Build the WebAssembly module
75    debug!(
76        "Building WebAssembly module for actor in {}...",
77        project_dir.display()
78    );
79
80    // Execute cargo build with WASM target
81    let mut build_cmd = Command::new("cargo");
82    build_cmd.args(["build", "--target", "wasm32-unknown-unknown"]);
83
84    if args.release {
85        build_cmd.arg("--release");
86    }
87
88    build_cmd.current_dir(&project_dir);
89
90    // Run the build command and capture output
91    let (status, stdout, stderr) = run_command_with_output(&mut build_cmd, ctx.is_verbose())
92        .map_err(|e| CliError::build_failed(format!("Failed to execute cargo build: {}", e)))?;
93
94    // Handle build failures
95    if !status.success() {
96        let error_details = if stderr.is_empty() { stdout } else { stderr };
97        return Err(CliError::build_failed(format!(
98            "Cargo build failed:\\n{}",
99            error_details
100        )));
101    }
102
103    // Construct the path to the built wasm file
104    let build_type = if args.release { "release" } else { "debug" };
105    let wasm_file_name = format!("{}.wasm", package_name.replace('-', "_"));
106    let wasm_path = project_dir
107        .join("target/wasm32-unknown-unknown")
108        .join(build_type)
109        .join(&wasm_file_name);
110
111    // Validate the wasm file exists
112    if !wasm_path.exists() {
113        return Err(CliError::build_failed(format!(
114            "Built WASM file not found at expected path: {}",
115            wasm_path.display()
116        )));
117    }
118
119    // --- Self-contained verification (packr 0.11.0 plain-build model) ---
120    // There is no composition step anymore: packr_guest::setup_guest!() links
121    // the allocator into the cdylib, so the plain cargo-built `.wasm` already
122    // exports its own (growable) memory + __pack_alloc/__pack_free + lifecycle
123    // and imports only host theater:simple/*. That bare wasm IS the loadable
124    // artifact. We still gate it: assert the import surface is host-only so a
125    // mis-built actor (e.g. one built with the retired --import-memory recipe)
126    // fails the build instead of failing at boot.
127    let artifact_path = wasm_path.clone();
128    if args.no_verify {
129        info!(
130            "--no-verify: skipping the self-contained verification gate for {}",
131            wasm_path.display()
132        );
133    } else {
134        super::compose::verify_self_contained(&wasm_path).map_err(|e| {
135            CliError::build_failed(format!(
136                "Self-contained verification failed for {}: {e}",
137                wasm_path.display()
138            ))
139        })?;
140        info!("Verified self-contained actor: {}", wasm_path.display());
141    }
142
143    // Update the manifest.toml with the new component path if it exists
144    if manifest_exists {
145        let manifest_content = fs::read_to_string(&manifest_path).map_err(|e| {
146            CliError::file_operation_failed(
147                "read manifest.toml",
148                manifest_path.display().to_string(),
149                e,
150            )
151        })?;
152
153        let mut manifest: ManifestConfig = toml::from_str(&manifest_content).map_err(|e| {
154            CliError::invalid_manifest(format!("Failed to parse manifest.toml: {}", e))
155        })?;
156
157        // Update the package path - point at the self-contained composite
158        // (the deployable artifact), using an absolute path.
159        manifest.package = artifact_path.to_string_lossy().to_string();
160
161        // Write the updated manifest
162        let updated_manifest = toml::to_string(&manifest).map_err(|e| {
163            CliError::invalid_manifest(format!("Failed to serialize manifest.toml: {}", e))
164        })?;
165
166        fs::write(&manifest_path, updated_manifest).map_err(|e| {
167            CliError::file_operation_failed(
168                "write manifest.toml",
169                manifest_path.display().to_string(),
170                e,
171            )
172        })?;
173
174        info!(
175            "Updated manifest with component path: {}",
176            artifact_path.display()
177        );
178    }
179
180    // Create build result and output
181    let result = BuildResult {
182        success: true,
183        project_dir,
184        wasm_path: Some(artifact_path),
185        manifest_exists,
186        manifest_path: Some(manifest_path),
187        build_type: build_type.to_string(),
188        package_name,
189        stdout,
190        stderr,
191    };
192
193    ctx.output.output(&result, None)?;
194    Ok(())
195}
196
197/// Run a command and return the status, stdout, and stderr
198fn run_command_with_output(
199    cmd: &mut Command,
200    verbose: bool,
201) -> Result<(std::process::ExitStatus, String, String)> {
202    debug!("Running command: {:?}", cmd);
203    cmd.env("RUST_BACKTRACE", "1");
204    cmd.env("RUST_COLOR", "always");
205    cmd.env("CARGO_TERM_COLOR", "always");
206
207    if verbose {
208        // For verbose mode, we'll just let the command output directly to the console
209        // with all its colors, and then capture the output separately for the result
210        let status = cmd
211            .status()
212            .map_err(|e| anyhow!("Failed to execute command: {}", e))?;
213
214        // If we're in verbose mode and directly showing output, return empty strings for stdout/stderr
215        // since they were already displayed
216        Ok((status, String::new(), String::new()))
217    } else {
218        // For non-verbose mode, capture the output but preserve ANSI color codes
219        // Capture the output
220        let output = cmd
221            .output()
222            .map_err(|e| anyhow!("Failed to execute command: {}", e))?;
223
224        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
225        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
226
227        Ok((output.status, stdout, stderr))
228    }
229}
230
231/// Extract the package name from Cargo.toml
232fn get_package_name(cargo_toml_path: &Path) -> Result<String> {
233    let cargo_toml = std::fs::read_to_string(cargo_toml_path)?;
234
235    // Simple parse to extract package name
236    for line in cargo_toml.lines() {
237        let line = line.trim();
238        if line.starts_with("name") {
239            let parts: Vec<&str> = line.split('=').collect();
240            if parts.len() >= 2 {
241                let name = parts[1].trim().trim_matches('"').trim_matches('\'');
242                return Ok(name.to_string());
243            }
244        }
245    }
246
247    Err(anyhow!("Could not find package name in Cargo.toml"))
248}