Skip to main content

actr_cli/commands/
build.rs

1//! `actr build` - build source artifacts and package signed `.actr` workloads.
2
3use std::path::{Path, PathBuf};
4use std::process::{Command as StdCommand, Stdio};
5
6use actr_config::{BuildArtifact, BuildConfig, BuildProfile, ConfigParser, ManifestConfig};
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use cargo_metadata::MetadataCommand;
10use clap::Args;
11
12use crate::commands::codegen::metadata_path;
13use crate::commands::package_build::{
14    PackageBuildInput, build_package, default_dist_output_path, print_build_summary,
15    resolve_key_path,
16};
17use crate::core::{Command, CommandContext, CommandResult, ComponentType};
18use crate::project_language::DetectedProjectLanguage;
19
20#[derive(Args, Debug)]
21#[command(
22    about = "Build source artifact and package a signed .actr workload",
23    long_about = "Build source artifact and package a signed .actr workload from manifest.toml"
24)]
25pub struct BuildCommand {
26    /// manifest.toml path
27    #[arg(
28        long = "manifest-path",
29        short = 'm',
30        default_value = "manifest.toml",
31        value_name = "FILE"
32    )]
33    pub manifest_path: PathBuf,
34
35    /// Override target triple
36    #[arg(long, short = 't', value_name = "TARGET")]
37    pub target: Option<String>,
38
39    /// Output .actr file path
40    #[arg(long, short = 'o', value_name = "FILE")]
41    pub output: Option<PathBuf>,
42
43    /// Signing key file (overrides config mfr.keychain)
44    #[arg(long, short = 'k', value_name = "FILE")]
45    pub key: Option<PathBuf>,
46
47    /// Skip compilation and only package the declared binary artifact
48    #[arg(long)]
49    pub no_compile: bool,
50}
51
52#[async_trait]
53impl Command for BuildCommand {
54    async fn execute(&self, _ctx: &CommandContext) -> Result<CommandResult> {
55        execute_build(self).await?;
56        Ok(CommandResult::Success(String::new()))
57    }
58
59    fn required_components(&self) -> Vec<ComponentType> {
60        vec![]
61    }
62
63    fn name(&self) -> &str {
64        "build"
65    }
66
67    fn description(&self) -> &str {
68        "Build source artifact and package a signed .actr workload"
69    }
70}
71
72async fn execute_build(args: &BuildCommand) -> Result<()> {
73    let manifest_path = resolve_manifest_path(&args.manifest_path)?;
74    let config = ConfigParser::from_manifest_file(&manifest_path).with_context(|| {
75        format!(
76            "Failed to load manifest configuration from {}",
77            manifest_path.display()
78        )
79    })?;
80
81    let binary = config.binary.as_ref().ok_or_else(|| {
82        anyhow::anyhow!(
83            "manifest.toml is missing [binary].\nDeclare the final packaged artifact path before running `actr build`."
84        )
85    })?;
86
87    let effective_target = resolve_effective_target(args, &config)?;
88    let output_path = resolve_output_path(&manifest_path, &effective_target, args.output.as_ref())?;
89
90    if !args.no_compile {
91        let build = config.build.as_ref().ok_or_else(|| {
92            anyhow::anyhow!(
93                "manifest.toml is missing [build].\nAdd [build] or rerun with `--no-compile` to package an existing artifact."
94            )
95        })?;
96        ensure_rust_codegen_ready(build)?;
97        compile_project(
98            &manifest_path,
99            &output_path,
100            &binary.path,
101            &effective_target,
102            build,
103        )?;
104    }
105
106    if !binary.path.exists() {
107        anyhow::bail!(
108            "Configured binary artifact not found: {}\nCheck [binary].path or your post_build steps.",
109            binary.path.display()
110        );
111    }
112
113    let cli_config = crate::config::resolver::resolve_effective_cli_config()?;
114    let key_path = resolve_key_path(args.key.as_deref(), cli_config.mfr.keychain.as_deref())?;
115
116    let summary = build_package(PackageBuildInput {
117        binary_path: binary.path.clone(),
118        config_path: manifest_path,
119        key_path,
120        output_path,
121        target: effective_target,
122        resources: vec![],
123    })?;
124
125    print_build_summary(&summary);
126    Ok(())
127}
128
129fn ensure_rust_codegen_ready(build: &BuildConfig) -> Result<()> {
130    let project_root = build
131        .manifest_path
132        .parent()
133        .unwrap_or_else(|| Path::new("."))
134        .to_path_buf();
135
136    if DetectedProjectLanguage::detect(&project_root) != DetectedProjectLanguage::Rust {
137        return Ok(());
138    }
139
140    let generated_dir = project_root.join("src/generated");
141    let generated_meta = metadata_path(&generated_dir);
142    if generated_dir.exists() && generated_meta.exists() {
143        return Ok(());
144    }
145
146    anyhow::bail!(
147        "Rust generated sources are missing or stale for {}.\nRun `actr gen -l rust` before `actr build`.",
148        project_root.display()
149    );
150}
151
152fn resolve_manifest_path(path: &Path) -> Result<PathBuf> {
153    let candidate = if path.is_absolute() {
154        path.to_path_buf()
155    } else {
156        std::env::current_dir()?.join(path)
157    };
158
159    if !candidate.exists() {
160        anyhow::bail!(
161            "manifest.toml not found: {}\nBy default `actr build` looks for ./manifest.toml. Use `-m, --manifest-path` to specify a different path.",
162            candidate.display()
163        );
164    }
165
166    Ok(candidate)
167}
168
169fn resolve_effective_target(args: &BuildCommand, config: &ManifestConfig) -> Result<String> {
170    if let Some(target) = &args.target {
171        return Ok(target.clone());
172    }
173
174    if let Some(target) = config
175        .binary
176        .as_ref()
177        .and_then(|binary| binary.target.clone())
178    {
179        return Ok(target);
180    }
181
182    if let Some(target) = config.build.as_ref().and_then(|build| build.target.clone()) {
183        return Ok(target);
184    }
185
186    resolve_host_target()
187}
188
189fn resolve_output_path(
190    manifest_path: &Path,
191    effective_target: &str,
192    output: Option<&PathBuf>,
193) -> Result<PathBuf> {
194    let manifest_dir = manifest_path
195        .parent()
196        .unwrap_or_else(|| Path::new("."))
197        .to_path_buf();
198
199    match output {
200        Some(path) if path.is_absolute() => Ok(path.clone()),
201        Some(path) => Ok(manifest_dir.join(path)),
202        None => default_dist_output_path(manifest_path, effective_target),
203    }
204}
205
206fn compile_project(
207    manifest_path: &Path,
208    output_path: &Path,
209    binary_path: &Path,
210    effective_target: &str,
211    build: &BuildConfig,
212) -> Result<()> {
213    if !build.manifest_path.exists() {
214        anyhow::bail!(
215            "Cargo manifest not found: {}",
216            build.manifest_path.display()
217        );
218    }
219
220    let cargo_target_dir = resolve_cargo_target_dir(&build.manifest_path)?;
221
222    ensure_target_installed(effective_target)?;
223    run_cargo_build(build, effective_target)?;
224    run_post_build_steps(
225        manifest_path,
226        output_path,
227        binary_path,
228        effective_target,
229        &cargo_target_dir,
230        build,
231    )?;
232
233    if !binary_path.exists() {
234        anyhow::bail!(
235            "Binary artifact was not produced after build/post_build: {}",
236            binary_path.display()
237        );
238    }
239
240    Ok(())
241}
242
243fn ensure_target_installed(target: &str) -> Result<()> {
244    let host_target = resolve_host_target()?;
245    if target == host_target {
246        return Ok(());
247    }
248
249    let status = StdCommand::new("rustup")
250        .arg("target")
251        .arg("add")
252        .arg(target)
253        .stdin(Stdio::null())
254        .stdout(Stdio::inherit())
255        .stderr(Stdio::inherit())
256        .status()
257        .with_context(|| format!("Failed to run `rustup target add {target}`"))?;
258
259    if !status.success() {
260        anyhow::bail!("`rustup target add {target}` failed with status {status}");
261    }
262
263    Ok(())
264}
265
266fn run_cargo_build(build: &BuildConfig, effective_target: &str) -> Result<()> {
267    let mut command = StdCommand::new("cargo");
268    command.arg("build");
269    command.arg("--manifest-path").arg(&build.manifest_path);
270
271    match build.artifact {
272        BuildArtifact::Lib => {
273            command.arg("--lib");
274        }
275        BuildArtifact::Bin => {
276            command
277                .arg("--bin")
278                .arg(resolve_cargo_bin_name(&build.manifest_path)?);
279        }
280    }
281
282    if build.profile == BuildProfile::Release {
283        command.arg("--release");
284    }
285
286    command.arg("--target").arg(effective_target);
287
288    if !build.features.is_empty() {
289        command.arg("--features").arg(build.features.join(","));
290    }
291
292    if build.no_default_features {
293        command.arg("--no-default-features");
294    }
295
296    // Component Model guests (`wasm32-wasip2`) must be linked by
297    // `wasm-component-ld` so the emitted artifact is a Component rather
298    // than a core module. Rust 1.91 ships `wasm-component-ld 0.5.17`,
299    // which rejects the async custom sections wit-bindgen 0.57 emits.
300    // Use Cargo's target-specific linker variable so host build scripts
301    // still link with the native linker.
302    if effective_target == "wasm32-wasip2" {
303        let linker = resolve_wasm_component_linker()?;
304        command.env("CARGO_TARGET_WASM32_WASIP2_LINKER", linker);
305    }
306
307    let status = command
308        .stdin(Stdio::null())
309        .stdout(Stdio::inherit())
310        .stderr(Stdio::inherit())
311        .status()
312        .with_context(|| {
313            format!(
314                "Failed to run cargo build for manifest {}",
315                build.manifest_path.display()
316            )
317        })?;
318
319    if !status.success() {
320        anyhow::bail!("cargo build failed with status {status}");
321    }
322
323    Ok(())
324}
325
326/// Locate a `wasm-component-ld` binary suitable for linking Component
327/// Model guests and validate its version.
328///
329/// Lookup order:
330/// 1. `WASM_COMPONENT_LD` environment variable (explicit override)
331/// 2. `wasm-component-ld` on `PATH`
332/// 3. `~/.cargo/bin/wasm-component-ld`
333///
334/// Returns an actionable `cargo install` hint when none are found.
335fn resolve_wasm_component_linker() -> Result<PathBuf> {
336    const REQUIRED: &str = "0.5.22";
337
338    let candidate = if let Some(p) = std::env::var_os("WASM_COMPONENT_LD") {
339        PathBuf::from(p)
340    } else if let Some(p) = find_on_path("wasm-component-ld") {
341        p
342    } else if let Some(home) = std::env::var_os("HOME") {
343        let p = PathBuf::from(home).join(".cargo/bin/wasm-component-ld");
344        if p.is_file() {
345            p
346        } else {
347            anyhow::bail!(
348                "`wasm-component-ld` (>= {REQUIRED}) is required to link wasm32-wasip2 Components.\n\
349                 Install it with: cargo install wasm-component-ld --version {REQUIRED}\n\
350                 Or set WASM_COMPONENT_LD to an existing binary."
351            );
352        }
353    } else {
354        anyhow::bail!(
355            "`wasm-component-ld` (>= {REQUIRED}) is required to link wasm32-wasip2 Components.\n\
356             Install it with: cargo install wasm-component-ld --version {REQUIRED}\n\
357             Or set WASM_COMPONENT_LD to an existing binary."
358        );
359    };
360
361    if !candidate.is_file() {
362        anyhow::bail!(
363            "wasm-component-ld path `{}` is not a file.\n\
364             Install it with: cargo install wasm-component-ld --version {REQUIRED}",
365            candidate.display()
366        );
367    }
368
369    validate_wasm_component_linker_version(&candidate, REQUIRED)?;
370
371    Ok(candidate)
372}
373
374fn validate_wasm_component_linker_version(linker: &Path, required: &str) -> Result<()> {
375    let output = StdCommand::new(linker)
376        .arg("--version")
377        .stdin(Stdio::null())
378        .output()
379        .with_context(|| {
380            format!(
381                "Failed to run `{}` --version for wasm-component-ld validation",
382                linker.display()
383            )
384        })?;
385
386    if !output.status.success() {
387        anyhow::bail!(
388            "`{}` --version failed with status {}.\n\
389             Install it with: cargo install wasm-component-ld --version {required}",
390            linker.display(),
391            output.status
392        );
393    }
394
395    let stdout = String::from_utf8_lossy(&output.stdout);
396    let stderr = String::from_utf8_lossy(&output.stderr);
397    let version_text = if stdout.trim().is_empty() {
398        stderr.trim()
399    } else {
400        stdout.trim()
401    };
402
403    let actual = extract_semver(version_text).with_context(|| {
404        format!(
405            "Failed to parse wasm-component-ld version from `{version_text}`.\n\
406             Install it with: cargo install wasm-component-ld --version {required}"
407        )
408    })?;
409    let required = parse_semver(required).expect("REQUIRED wasm-component-ld version is valid");
410
411    if actual < required {
412        anyhow::bail!(
413            "`{}` reports version {}, but wasm32-wasip2 Component linking requires >= {}.\n\
414             Install it with: cargo install wasm-component-ld --version {}",
415            linker.display(),
416            format_semver(actual),
417            format_semver(required),
418            format_semver(required)
419        );
420    }
421
422    Ok(())
423}
424
425fn extract_semver(text: &str) -> Option<(u64, u64, u64)> {
426    text.split_whitespace().find_map(parse_semver)
427}
428
429fn parse_semver(text: &str) -> Option<(u64, u64, u64)> {
430    let mut parts = text.split('.');
431    let major = parts.next()?.parse().ok()?;
432    let minor = parts.next()?.parse().ok()?;
433    let patch_text = parts.next()?;
434    let patch_len = patch_text
435        .bytes()
436        .take_while(|byte| byte.is_ascii_digit())
437        .count();
438    if patch_len == 0 {
439        return None;
440    }
441    let patch = patch_text[..patch_len].parse().ok()?;
442    Some((major, minor, patch))
443}
444
445fn format_semver(version: (u64, u64, u64)) -> String {
446    format!("{}.{}.{}", version.0, version.1, version.2)
447}
448
449/// Walk `PATH` looking for `binary`. Returns the first hit that exists
450/// as a file. Mirrors the shell `which` semantics closely enough for
451/// the CLI's purposes — no PATHEXT handling because `wasm-component-ld`
452/// is the only target today and Windows builds are not on the Phase 1
453/// migration path.
454fn find_on_path(binary: &str) -> Option<PathBuf> {
455    let path_var = std::env::var_os("PATH")?;
456    for dir in std::env::split_paths(&path_var) {
457        let candidate = dir.join(binary);
458        if candidate.is_file() {
459            return Some(candidate);
460        }
461    }
462    None
463}
464
465fn run_post_build_steps(
466    manifest_path: &Path,
467    output_path: &Path,
468    binary_path: &Path,
469    effective_target: &str,
470    cargo_target_dir: &Path,
471    build: &BuildConfig,
472) -> Result<()> {
473    if build.post_build.is_empty() {
474        return Ok(());
475    }
476
477    let manifest_dir = manifest_path
478        .parent()
479        .unwrap_or_else(|| Path::new("."))
480        .to_path_buf();
481
482    for command_text in &build.post_build {
483        let output = StdCommand::new("sh")
484            .arg("-c")
485            .arg(command_text)
486            .current_dir(&manifest_dir)
487            .env("ACTR_BUILD_MANIFEST_PATH", manifest_path)
488            .env("ACTR_BUILD_PROJECT_DIR", &manifest_dir)
489            .env("ACTR_BUILD_BINARY_PATH", binary_path)
490            .env("ACTR_BUILD_TARGET", effective_target)
491            .env("ACTR_BUILD_PROFILE", build.profile.as_str())
492            .env("ACTR_BUILD_OUTPUT_PATH", output_path)
493            .env("ACTR_BUILD_CARGO_TARGET_DIR", cargo_target_dir)
494            .env("CARGO_TARGET_DIR", cargo_target_dir)
495            .output()
496            .with_context(|| format!("Failed to run post_build command: {command_text}"))?;
497
498        if !output.stdout.is_empty() {
499            print!("{}", String::from_utf8_lossy(&output.stdout));
500        }
501        if !output.stderr.is_empty() {
502            eprint!("{}", String::from_utf8_lossy(&output.stderr));
503        }
504
505        if !output.status.success() {
506            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
507            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
508            anyhow::bail!(
509                "post_build command failed: {command_text}\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
510                output.status,
511                stdout,
512                stderr,
513            );
514        }
515    }
516
517    Ok(())
518}
519
520fn resolve_cargo_bin_name(manifest_path: &Path) -> Result<String> {
521    let metadata = MetadataCommand::new()
522        .manifest_path(manifest_path)
523        .no_deps()
524        .exec()
525        .with_context(|| {
526            format!(
527                "Failed to read Cargo metadata from {}",
528                manifest_path.display()
529            )
530        })?;
531
532    let manifest_path =
533        std::fs::canonicalize(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf());
534
535    let package = metadata
536        .packages
537        .iter()
538        .find(|package| {
539            std::fs::canonicalize(package.manifest_path.as_std_path())
540                .map(|path| path == manifest_path)
541                .unwrap_or(false)
542        })
543        .or_else(|| metadata.root_package())
544        .ok_or_else(|| {
545            anyhow::anyhow!(
546                "Unable to resolve Cargo package for {}",
547                manifest_path.display()
548            )
549        })?;
550
551    Ok(package.name.clone())
552}
553
554fn resolve_cargo_target_dir(manifest_path: &Path) -> Result<PathBuf> {
555    let metadata = MetadataCommand::new()
556        .manifest_path(manifest_path)
557        .no_deps()
558        .exec()
559        .with_context(|| {
560            format!(
561                "Failed to read Cargo metadata from {}",
562                manifest_path.display()
563            )
564        })?;
565
566    Ok(metadata.target_directory.into_std_path_buf())
567}
568
569fn resolve_host_target() -> Result<String> {
570    let output = StdCommand::new("rustc")
571        .arg("-vV")
572        .output()
573        .context("Failed to run `rustc -vV` to resolve host target")?;
574
575    if !output.status.success() {
576        anyhow::bail!("`rustc -vV` failed with status {}", output.status);
577    }
578
579    let stdout = String::from_utf8_lossy(&output.stdout);
580    let host = stdout
581        .lines()
582        .find_map(|line| line.strip_prefix("host: "))
583        .ok_or_else(|| anyhow::anyhow!("Unable to resolve host target from `rustc -vV`"))?;
584
585    Ok(host.trim().to_string())
586}