greentic_component/cmd/
build.rs1#![cfg(feature = "cli")]
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result, anyhow, bail};
8use clap::Args;
9use serde_json::Value as JsonValue;
10
11use crate::cmd::flow::{
12 FlowUpdateResult, manifest_component_id, resolve_operation, update_with_manifest,
13};
14use crate::config::{
15 ConfigInferenceOptions, ConfigSchemaSource, load_manifest_with_schema, resolve_manifest_path,
16};
17use crate::path_safety::normalize_under_root;
18
19const DEFAULT_MANIFEST: &str = "component.manifest.json";
20
21#[derive(Args, Debug, Clone)]
22pub struct BuildArgs {
23 #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
25 pub manifest: PathBuf,
26 #[arg(long = "cargo", value_name = "PATH")]
28 pub cargo_bin: Option<PathBuf>,
29 #[arg(long = "no-flow")]
31 pub no_flow: bool,
32 #[arg(long = "no-infer-config")]
34 pub no_infer_config: bool,
35 #[arg(long = "no-write-schema")]
37 pub no_write_schema: bool,
38 #[arg(long = "force-write-schema")]
40 pub force_write_schema: bool,
41 #[arg(long = "no-validate")]
43 pub no_validate: bool,
44 #[arg(long = "json")]
46 pub json: bool,
47}
48
49#[derive(Debug, serde::Serialize)]
50struct BuildSummary {
51 manifest: PathBuf,
52 wasm_path: PathBuf,
53 wasm_hash: String,
54 config_source: ConfigSchemaSource,
55 schema_written: bool,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 flows: Option<FlowUpdateResult>,
58}
59
60pub fn run(args: BuildArgs) -> Result<()> {
61 let manifest_path = resolve_manifest_path(&args.manifest);
62 let cwd = std::env::current_dir().context("failed to read current directory")?;
63 let manifest_path = if manifest_path.is_absolute() {
64 manifest_path
65 } else {
66 cwd.join(manifest_path)
67 };
68 if !manifest_path.exists() {
69 bail!("manifest not found at {}", manifest_path.display());
70 }
71 let cargo_bin = args
72 .cargo_bin
73 .clone()
74 .or_else(|| std::env::var_os("CARGO").map(PathBuf::from))
75 .unwrap_or_else(|| PathBuf::from("cargo"));
76 let inference_opts = ConfigInferenceOptions {
77 allow_infer: !args.no_infer_config,
78 write_schema: !args.no_write_schema,
79 force_write_schema: args.force_write_schema,
80 validate: !args.no_validate,
81 };
82 println!(
83 "Using manifest at {} (cargo: {})",
84 manifest_path.display(),
85 cargo_bin.display()
86 );
87
88 let config = load_manifest_with_schema(&manifest_path, &inference_opts)?;
89 let component_id = manifest_component_id(&config.manifest)?;
90 let _operation = resolve_operation(&config.manifest, component_id)?;
91 let flow_outcome = if args.no_flow {
92 None
93 } else {
94 Some(update_with_manifest(&config)?)
95 };
96
97 let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
98 build_wasm(manifest_dir, &cargo_bin)?;
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 if !config.persist_schema {
105 manifest_to_write
106 .as_object_mut()
107 .map(|obj| obj.remove("config_schema"));
108 }
109 let (wasm_path, wasm_hash) = update_manifest_hashes(manifest_dir, &mut manifest_to_write)?;
110 write_manifest(&manifest_path, &manifest_to_write)?;
111
112 if args.json {
113 let payload = BuildSummary {
114 manifest: manifest_path.clone(),
115 wasm_path,
116 wasm_hash,
117 config_source: config.source,
118 schema_written: config.schema_written && config.persist_schema,
119 flows: flow_outcome.as_ref().map(|outcome| outcome.result),
120 };
121 serde_json::to_writer_pretty(std::io::stdout(), &payload)?;
122 println!();
123 } else {
124 println!("Built wasm artifact at {}", wasm_path.display());
125 println!("Updated {} hashes (blake3)", manifest_path.display());
126 if config.schema_written && config.persist_schema {
127 println!(
128 "Updated {} with inferred config_schema ({:?})",
129 manifest_path.display(),
130 config.source
131 );
132 }
133 if let Some(outcome) = flow_outcome {
134 let flows = outcome.result;
135 println!(
136 "Flows updated (default: {}, custom: {})",
137 flows.default_updated, flows.custom_updated
138 );
139 } else {
140 println!("Flow regeneration skipped (--no-flow)");
141 }
142 }
143
144 Ok(())
145}
146
147fn build_wasm(manifest_dir: &Path, cargo_bin: &Path) -> Result<()> {
148 println!(
149 "Running cargo build via {} in {}",
150 cargo_bin.display(),
151 manifest_dir.display()
152 );
153 let status = Command::new(cargo_bin)
154 .arg("build")
155 .arg("--target")
156 .arg("wasm32-wasip2")
157 .arg("--release")
158 .current_dir(manifest_dir)
159 .status()
160 .with_context(|| format!("failed to run cargo build via {}", cargo_bin.display()))?;
161
162 if !status.success() {
163 bail!(
164 "cargo build --target wasm32-wasip2 --release failed with status {}",
165 status
166 );
167 }
168 Ok(())
169}
170
171fn update_manifest_hashes(
172 manifest_dir: &Path,
173 manifest: &mut JsonValue,
174) -> Result<(PathBuf, String)> {
175 let artifact_path = resolve_wasm_path(manifest_dir, manifest)?;
176 let wasm_bytes = fs::read(&artifact_path)
177 .with_context(|| format!("failed to read wasm at {}", artifact_path.display()))?;
178 let digest = blake3::hash(&wasm_bytes).to_hex().to_string();
179
180 manifest["artifacts"]["component_wasm"] =
181 JsonValue::String(path_string_relative(manifest_dir, &artifact_path)?);
182 manifest["hashes"]["component_wasm"] = JsonValue::String(format!("blake3:{digest}"));
183
184 Ok((artifact_path, format!("blake3:{digest}")))
185}
186
187fn path_string_relative(base: &Path, target: &Path) -> Result<String> {
188 let rel = pathdiff::diff_paths(target, base).unwrap_or_else(|| target.to_path_buf());
189 rel.to_str()
190 .map(|s| s.to_string())
191 .ok_or_else(|| anyhow!("failed to stringify path {}", target.display()))
192}
193
194fn resolve_wasm_path(manifest_dir: &Path, manifest: &JsonValue) -> Result<PathBuf> {
195 let manifest_root = manifest_dir
196 .canonicalize()
197 .with_context(|| format!("failed to canonicalize {}", manifest_dir.display()))?;
198 let candidate = manifest
199 .get("artifacts")
200 .and_then(|a| a.get("component_wasm"))
201 .and_then(|v| v.as_str())
202 .map(PathBuf::from)
203 .unwrap_or_else(|| {
204 let raw_name = manifest
205 .get("name")
206 .and_then(|v| v.as_str())
207 .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
208 .unwrap_or("component");
209 let sanitized = raw_name.replace(['-', '.'], "_");
210 manifest_dir.join(format!("target/wasm32-wasip2/release/{sanitized}.wasm"))
211 });
212 let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
213 if candidate.is_absolute() {
214 candidate
215 .canonicalize()
216 .with_context(|| format!("failed to canonicalize {}", candidate.display()))
217 } else {
218 normalize_under_root(&manifest_root, &candidate)
219 }
220 })?;
221 Ok(normalized)
222}
223
224fn write_manifest(manifest_path: &Path, manifest: &JsonValue) -> Result<()> {
225 let formatted = serde_json::to_string_pretty(manifest)?;
226 fs::write(manifest_path, formatted + "\n")
227 .with_context(|| format!("failed to write {}", manifest_path.display()))
228}