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