Skip to main content

greentic_component/cmd/
build.rs

1#![cfg(feature = "cli")]
2
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::Args;
10use serde_json::Value as JsonValue;
11
12use crate::abi::{self, AbiError};
13use crate::cmd::component_world::{canonical_component_world, is_fallback_world};
14use crate::cmd::flow::{
15    FlowUpdateResult, manifest_component_id, resolve_operation, update_with_manifest,
16};
17use crate::config::{
18    ConfigInferenceOptions, ConfigSchemaSource, load_manifest_with_schema, resolve_manifest_path,
19};
20use crate::path_safety::normalize_under_root;
21
22const DEFAULT_MANIFEST: &str = "component.manifest.json";
23
24#[derive(Args, Debug, Clone)]
25pub struct BuildArgs {
26    /// Path to component.manifest.json (or directory containing it)
27    #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
28    pub manifest: PathBuf,
29    /// Path to the cargo binary (fallback: $CARGO, then `cargo` on PATH)
30    #[arg(long = "cargo", value_name = "PATH")]
31    pub cargo_bin: Option<PathBuf>,
32    /// Skip flow regeneration
33    #[arg(long = "no-flow")]
34    pub no_flow: bool,
35    /// Skip config inference; fail if config_schema is missing
36    #[arg(long = "no-infer-config")]
37    pub no_infer_config: bool,
38    /// Do not write inferred config_schema back to the manifest
39    #[arg(long = "no-write-schema")]
40    pub no_write_schema: bool,
41    /// Overwrite existing config_schema with inferred schema
42    #[arg(long = "force-write-schema")]
43    pub force_write_schema: bool,
44    /// Skip schema validation
45    #[arg(long = "no-validate")]
46    pub no_validate: bool,
47    /// Emit machine-readable JSON summary
48    #[arg(long = "json")]
49    pub json: bool,
50}
51
52#[derive(Debug, serde::Serialize)]
53struct BuildSummary {
54    manifest: PathBuf,
55    wasm_path: PathBuf,
56    wasm_hash: String,
57    config_source: ConfigSchemaSource,
58    schema_written: bool,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    flows: Option<FlowUpdateResult>,
61}
62
63pub fn run(args: BuildArgs) -> Result<()> {
64    let manifest_path = resolve_manifest_path(&args.manifest);
65    let cwd = env::current_dir().context("failed to read current directory")?;
66    let manifest_path = if manifest_path.is_absolute() {
67        manifest_path
68    } else {
69        cwd.join(manifest_path)
70    };
71    if !manifest_path.exists() {
72        bail!("manifest not found at {}", manifest_path.display());
73    }
74    let cargo_bin = args
75        .cargo_bin
76        .clone()
77        .or_else(|| env::var_os("CARGO").map(PathBuf::from))
78        .unwrap_or_else(|| PathBuf::from("cargo"));
79    let inference_opts = ConfigInferenceOptions {
80        allow_infer: !args.no_infer_config,
81        write_schema: !args.no_write_schema,
82        force_write_schema: args.force_write_schema,
83        validate: !args.no_validate,
84    };
85    println!(
86        "Using manifest at {} (cargo: {})",
87        manifest_path.display(),
88        cargo_bin.display()
89    );
90
91    let config = load_manifest_with_schema(&manifest_path, &inference_opts)?;
92    let component_id = manifest_component_id(&config.manifest)?;
93    let _operation = resolve_operation(&config.manifest, component_id)?;
94    let flow_outcome = if args.no_flow {
95        None
96    } else {
97        Some(update_with_manifest(&config)?)
98    };
99
100    let mut manifest_to_write = flow_outcome
101        .as_ref()
102        .map(|outcome| outcome.manifest.clone())
103        .unwrap_or_else(|| config.manifest.clone());
104
105    let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
106    build_wasm(manifest_dir, &cargo_bin)?;
107    check_canonical_world_export(manifest_dir, &manifest_to_write)?;
108
109    if !config.persist_schema {
110        manifest_to_write
111            .as_object_mut()
112            .map(|obj| obj.remove("config_schema"));
113    }
114    let (wasm_path, wasm_hash) = update_manifest_hashes(manifest_dir, &mut manifest_to_write)?;
115    write_manifest(&manifest_path, &manifest_to_write)?;
116
117    if args.json {
118        let payload = BuildSummary {
119            manifest: manifest_path.clone(),
120            wasm_path,
121            wasm_hash,
122            config_source: config.source,
123            schema_written: config.schema_written && config.persist_schema,
124            flows: flow_outcome.as_ref().map(|outcome| outcome.result),
125        };
126        serde_json::to_writer_pretty(std::io::stdout(), &payload)?;
127        println!();
128    } else {
129        println!("Built wasm artifact at {}", wasm_path.display());
130        println!("Updated {} hashes (blake3)", manifest_path.display());
131        if config.schema_written && config.persist_schema {
132            println!(
133                "Updated {} with inferred config_schema ({:?})",
134                manifest_path.display(),
135                config.source
136            );
137        }
138        if let Some(outcome) = flow_outcome {
139            let flows = outcome.result;
140            println!(
141                "Flows updated (default: {}, custom: {})",
142                flows.default_updated, flows.custom_updated
143            );
144        } else {
145            println!("Flow regeneration skipped (--no-flow)");
146        }
147    }
148
149    Ok(())
150}
151
152fn build_wasm(manifest_dir: &Path, cargo_bin: &Path) -> Result<()> {
153    println!(
154        "Running cargo build via {} in {}",
155        cargo_bin.display(),
156        manifest_dir.display()
157    );
158    let mut cmd = Command::new(cargo_bin);
159    if let Some(flags) = resolved_wasm_rustflags() {
160        cmd.env("RUSTFLAGS", sanitize_wasm_rustflags(&flags));
161    }
162    let status = cmd
163        .arg("build")
164        .arg("--target")
165        .arg("wasm32-wasip2")
166        .arg("--release")
167        .current_dir(manifest_dir)
168        .status()
169        .with_context(|| format!("failed to run cargo build via {}", cargo_bin.display()))?;
170
171    if !status.success() {
172        bail!(
173            "cargo build --target wasm32-wasip2 --release failed with status {}",
174            status
175        );
176    }
177    Ok(())
178}
179
180/// Reads the wasm-specific rustflags that CI exports for wasm builds.
181fn resolved_wasm_rustflags() -> Option<String> {
182    env::var("WASM_RUSTFLAGS")
183        .ok()
184        .or_else(|| env::var("RUSTFLAGS").ok())
185}
186
187/// Drops linker arguments that `wasm-component-ld` rejects and normalizes whitespace.
188fn sanitize_wasm_rustflags(flags: &str) -> String {
189    flags
190        .replace("-Wl,", "")
191        .replace("-C link-arg=--no-keep-memory", "")
192        .replace("-C link-arg=--threads=1", "")
193        .split_whitespace()
194        .collect::<Vec<_>>()
195        .join(" ")
196}
197
198fn check_canonical_world_export(manifest_dir: &Path, manifest: &JsonValue) -> Result<()> {
199    if env::var_os("GREENTIC_SKIP_NODE_EXPORT_CHECK").is_some() {
200        println!("World export check skipped (GREENTIC_SKIP_NODE_EXPORT_CHECK=1)");
201        return Ok(());
202    }
203    let wasm_path = resolve_wasm_path(manifest_dir, manifest)?;
204    let canonical_world = canonical_component_world();
205    match abi::check_world_base(&wasm_path, canonical_world) {
206        Ok(exported) => println!("Exported world: {exported}"),
207        Err(err) => match err {
208            AbiError::WorldMismatch { expected, found } if is_fallback_world(&found) => {
209                println!("Exported world: fallback {found} (expected {expected})");
210            }
211            err => {
212                return Err(err)
213                    .with_context(|| format!("component must export world {canonical_world}"));
214            }
215        },
216    }
217    Ok(())
218}
219
220fn update_manifest_hashes(
221    manifest_dir: &Path,
222    manifest: &mut JsonValue,
223) -> Result<(PathBuf, String)> {
224    let artifact_path = resolve_wasm_path(manifest_dir, manifest)?;
225    let wasm_bytes = fs::read(&artifact_path)
226        .with_context(|| format!("failed to read wasm at {}", artifact_path.display()))?;
227    let digest = blake3::hash(&wasm_bytes).to_hex().to_string();
228
229    manifest["artifacts"]["component_wasm"] =
230        JsonValue::String(path_string_relative(manifest_dir, &artifact_path)?);
231    manifest["hashes"]["component_wasm"] = JsonValue::String(format!("blake3:{digest}"));
232
233    Ok((artifact_path, format!("blake3:{digest}")))
234}
235
236fn path_string_relative(base: &Path, target: &Path) -> Result<String> {
237    let rel = pathdiff::diff_paths(target, base).unwrap_or_else(|| target.to_path_buf());
238    rel.to_str()
239        .map(|s| s.to_string())
240        .ok_or_else(|| anyhow!("failed to stringify path {}", target.display()))
241}
242
243fn resolve_wasm_path(manifest_dir: &Path, manifest: &JsonValue) -> Result<PathBuf> {
244    let manifest_root = manifest_dir
245        .canonicalize()
246        .with_context(|| format!("failed to canonicalize {}", manifest_dir.display()))?;
247    let candidate = manifest
248        .get("artifacts")
249        .and_then(|a| a.get("component_wasm"))
250        .and_then(|v| v.as_str())
251        .map(PathBuf::from)
252        .unwrap_or_else(|| {
253            let raw_name = manifest
254                .get("name")
255                .and_then(|v| v.as_str())
256                .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
257                .unwrap_or("component");
258            let sanitized = raw_name.replace(['-', '.'], "_");
259            manifest_dir.join(format!("target/wasm32-wasip2/release/{sanitized}.wasm"))
260        });
261    if candidate.exists() {
262        let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
263            if candidate.is_absolute() {
264                candidate
265                    .canonicalize()
266                    .with_context(|| format!("failed to canonicalize {}", candidate.display()))
267            } else {
268                normalize_under_root(&manifest_root, &candidate)
269            }
270        })?;
271        return Ok(normalized);
272    }
273
274    if let Some(cargo_target_dir) = env::var_os("CARGO_TARGET_DIR") {
275        let relative = candidate
276            .strip_prefix(manifest_dir)
277            .unwrap_or(&candidate)
278            .to_path_buf();
279        if relative.starts_with("target") {
280            let alt =
281                PathBuf::from(cargo_target_dir).join(relative.strip_prefix("target").unwrap());
282            if alt.exists() {
283                return alt
284                    .canonicalize()
285                    .with_context(|| format!("failed to canonicalize {}", alt.display()));
286            }
287        }
288    }
289
290    let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
291        if candidate.is_absolute() {
292            candidate
293                .canonicalize()
294                .with_context(|| format!("failed to canonicalize {}", candidate.display()))
295        } else {
296            normalize_under_root(&manifest_root, &candidate)
297        }
298    })?;
299    Ok(normalized)
300}
301
302fn write_manifest(manifest_path: &Path, manifest: &JsonValue) -> Result<()> {
303    let formatted = serde_json::to_string_pretty(manifest)?;
304    fs::write(manifest_path, formatted + "\n")
305        .with_context(|| format!("failed to write {}", manifest_path.display()))
306}