greentic_component/cmd/
build.rs

1#![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    /// Path to component.manifest.json (or directory containing it)
22    #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
23    pub manifest: PathBuf,
24    /// Path to the cargo binary (fallback: $CARGO, then `cargo` on PATH)
25    #[arg(long = "cargo", value_name = "PATH")]
26    pub cargo_bin: Option<PathBuf>,
27    /// Skip flow regeneration
28    #[arg(long = "no-flow")]
29    pub no_flow: bool,
30    /// Skip config inference; fail if config_schema is missing
31    #[arg(long = "no-infer-config")]
32    pub no_infer_config: bool,
33    /// Do not write inferred config_schema back to the manifest
34    #[arg(long = "no-write-schema")]
35    pub no_write_schema: bool,
36    /// Overwrite existing config_schema with inferred schema
37    #[arg(long = "force-write-schema")]
38    pub force_write_schema: bool,
39    /// Skip schema validation
40    #[arg(long = "no-validate")]
41    pub no_validate: bool,
42    /// Emit machine-readable JSON summary
43    #[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}