1use 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 #[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 #[arg(long, short = 't', value_name = "TARGET")]
37 pub target: Option<String>,
38
39 #[arg(long, short = 'o', value_name = "FILE")]
41 pub output: Option<PathBuf>,
42
43 #[arg(long, short = 'k', value_name = "FILE")]
45 pub key: Option<PathBuf>,
46
47 #[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 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
326fn 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
449fn 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}