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