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