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 #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
30 pub manifest: PathBuf,
31 #[arg(long = "cargo", value_name = "PATH")]
33 pub cargo_bin: Option<PathBuf>,
34 #[arg(long = "no-flow")]
36 pub no_flow: bool,
37 #[arg(long = "no-infer-config")]
39 pub no_infer_config: bool,
40 #[arg(long = "no-write-schema")]
42 pub no_write_schema: bool,
43 #[arg(long = "force-write-schema")]
45 pub force_write_schema: bool,
46 #[arg(long = "no-validate")]
48 pub no_validate: bool,
49 #[arg(long = "json")]
51 pub json: bool,
52 #[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
199fn resolved_wasm_rustflags() -> Option<String> {
201 env::var("WASM_RUSTFLAGS")
202 .ok()
203 .or_else(|| env::var("RUSTFLAGS").ok())
204}
205
206fn 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}