1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::resolve_summary::write_flow_resolve_summary_for_flow;
9use greentic_types::ComponentId;
10use greentic_types::Flow;
11use greentic_types::error::ErrorCode;
12use greentic_types::flow_resolve::{
13 ComponentSourceRefV1, FlowResolveV1, read_flow_resolve, sidecar_path_for_flow,
14 write_flow_resolve,
15};
16use greentic_types::flow_resolve_summary::{
17 FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION, FlowResolveSummaryManifestV1,
18 FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
19 read_flow_resolve_summary, resolve_summary_path_for_flow, write_flow_resolve_summary,
20};
21use semver::Version;
22use sha2::{Digest, Sha256};
23
24use crate::config::FlowConfig;
25
26#[derive(Clone, Debug)]
27pub struct FlowResolveSidecar {
28 pub flow_id: String,
29 pub flow_path: PathBuf,
30 pub sidecar_path: PathBuf,
31 pub document: Option<FlowResolveV1>,
32 pub warning: Option<String>,
33}
34
35pub fn discover_flow_resolves(pack_dir: &Path, flows: &[FlowConfig]) -> Vec<FlowResolveSidecar> {
39 flows
40 .iter()
41 .map(|flow| {
42 let flow_path = if flow.file.is_absolute() {
43 flow.file.clone()
44 } else {
45 pack_dir.join(&flow.file)
46 };
47 let sidecar_path = sidecar_path_for_flow(&flow_path);
48
49 let (document, warning) = match read_flow_resolve(&sidecar_path) {
50 Ok(doc) => (Some(doc), None),
51 Err(err) if err.code == ErrorCode::NotFound => (
52 None,
53 Some(format!(
54 "flow resolve sidecar missing for {} ({})",
55 flow.id,
56 sidecar_path.display()
57 )),
58 ),
59 Err(err) => (
60 None,
61 Some(format!(
62 "failed to read flow resolve sidecar for {}: {}",
63 flow.id, err
64 )),
65 ),
66 };
67
68 FlowResolveSidecar {
69 flow_id: flow.id.clone(),
70 flow_path,
71 sidecar_path,
72 document,
73 warning,
74 }
75 })
76 .collect()
77}
78
79pub fn load_flow_resolve_summary(
81 pack_dir: &Path,
82 flow: &FlowConfig,
83 compiled: &Flow,
84) -> Result<FlowResolveSummaryV1> {
85 let flow_path = resolve_flow_path(pack_dir, flow);
86 let summary = read_or_write_flow_resolve_summary(&flow_path, flow)?;
87 enforce_summary_mappings(flow, compiled, &summary, &flow_path)?;
88 Ok(summary)
89}
90
91pub fn read_flow_resolve_summary_for_flow(
93 pack_dir: &Path,
94 flow: &FlowConfig,
95) -> Result<FlowResolveSummaryV1> {
96 let flow_path = resolve_flow_path(pack_dir, flow);
97 read_or_write_flow_resolve_summary(&flow_path, flow)
98}
99
100pub fn ensure_sidecar_exists(
105 pack_dir: &Path,
106 flow: &FlowConfig,
107 compiled: &Flow,
108 strict: bool,
109) -> Result<()> {
110 let flow_path = if flow.file.is_absolute() {
111 flow.file.clone()
112 } else {
113 pack_dir.join(&flow.file)
114 };
115 let sidecar_path = sidecar_path_for_flow(&flow_path);
116
117 let doc = match read_flow_resolve(&sidecar_path) {
118 Ok(doc) => doc,
119 Err(err) if err.code == ErrorCode::NotFound => {
120 let doc = FlowResolveV1 {
121 schema_version: 1,
122 flow: flow.file.to_string_lossy().into_owned(),
123 nodes: BTreeMap::new(),
124 };
125 if let Some(parent) = sidecar_path.parent() {
126 fs::create_dir_all(parent)
127 .with_context(|| format!("failed to create {}", parent.display()))?;
128 }
129 write_flow_resolve(&sidecar_path, &doc)
130 .with_context(|| format!("failed to write {}", sidecar_path.display()))?;
131 doc
132 }
133 Err(err) => {
134 return Err(anyhow!(
135 "failed to read flow resolve sidecar for {}: {}",
136 flow.id,
137 err
138 ));
139 }
140 };
141
142 let missing = missing_node_mappings(compiled, &doc);
143 if !missing.is_empty() {
144 if strict {
145 anyhow::bail!(
146 "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
147 flow.id,
148 missing.join(", "),
149 sidecar_path.display()
150 );
151 } else {
152 eprintln!(
153 "warning: flow {} has no resolve entries for nodes {} ({}); add mappings to the sidecar and rerun `greentic-pack resolve`",
154 flow.id,
155 missing.join(", "),
156 sidecar_path.display()
157 );
158 }
159 }
160
161 Ok(())
162}
163
164pub fn enforce_sidecar_mappings(pack_dir: &Path, flow: &FlowConfig, compiled: &Flow) -> Result<()> {
166 let flow_path = resolve_flow_path(pack_dir, flow);
167 let sidecar_path = sidecar_path_for_flow(&flow_path);
168 let doc = read_flow_resolve(&sidecar_path).map_err(|err| {
169 anyhow!(
170 "flow {} requires a resolve sidecar; expected {}: {}",
171 flow.id,
172 sidecar_path.display(),
173 err
174 )
175 })?;
176
177 let missing = missing_node_mappings(compiled, &doc);
178 if !missing.is_empty() {
179 anyhow::bail!(
180 "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
181 flow.id,
182 missing.join(", "),
183 sidecar_path.display()
184 );
185 }
186
187 Ok(())
188}
189
190pub fn missing_node_mappings(flow: &Flow, doc: &FlowResolveV1) -> Vec<String> {
192 flow.nodes
193 .keys()
194 .filter_map(|node| {
195 let id = node.to_string();
196 if doc.nodes.contains_key(id.as_str()) {
197 None
198 } else {
199 Some(id)
200 }
201 })
202 .collect()
203}
204
205fn resolve_flow_path(pack_dir: &Path, flow: &FlowConfig) -> PathBuf {
206 if flow.file.is_absolute() {
207 flow.file.clone()
208 } else {
209 pack_dir.join(&flow.file)
210 }
211}
212
213fn read_or_write_flow_resolve_summary(
214 flow_path: &Path,
215 flow: &FlowConfig,
216) -> Result<FlowResolveSummaryV1> {
217 let summary_path = resolve_summary_path_for_flow(flow_path);
218 if !summary_path.exists() {
219 let sidecar_path = sidecar_path_for_flow(flow_path);
220 let sidecar = read_flow_resolve(&sidecar_path).map_err(|err| {
221 anyhow!(
222 "flow {} requires a resolve sidecar to generate summary; expected {}: {}",
223 flow.id,
224 sidecar_path.display(),
225 err
226 )
227 })?;
228 write_flow_resolve_summary_safe(flow_path, &sidecar).with_context(|| {
229 format!(
230 "failed to generate flow resolve summary for {}",
231 flow_path.display()
232 )
233 })?;
234 }
235
236 read_flow_resolve_summary(&summary_path).map_err(|err| {
237 anyhow!(
238 "failed to read flow resolve summary for {}: {}",
239 flow.id,
240 err
241 )
242 })
243}
244
245fn write_flow_resolve_summary_safe(flow_path: &Path, sidecar: &FlowResolveV1) -> Result<PathBuf> {
246 let result = if tokio::runtime::Handle::try_current().is_ok() {
247 let flow_path = flow_path.to_path_buf();
248 let sidecar = sidecar.clone();
249 let join =
250 std::thread::spawn(move || write_flow_resolve_summary_for_flow(&flow_path, &sidecar));
251 join.join()
252 .map_err(|_| anyhow!("flow resolve summary generation panicked"))?
253 } else {
254 write_flow_resolve_summary_for_flow(flow_path, sidecar)
255 };
256
257 match result {
258 Ok(path) => Ok(path),
259 Err(err) => {
260 if sidecar
261 .nodes
262 .values()
263 .all(|node| matches!(node.source, ComponentSourceRefV1::Local { .. }))
264 {
265 let summary = build_flow_resolve_summary_fallback(flow_path, sidecar)?;
266 let summary_path = resolve_summary_path_for_flow(flow_path);
267 write_flow_resolve_summary(&summary_path, &summary)
268 .map_err(|e| anyhow!(e.to_string()))?;
269 return Ok(summary_path);
270 }
271 Err(err)
272 }
273 }
274}
275
276fn enforce_summary_mappings(
277 flow: &FlowConfig,
278 compiled: &Flow,
279 summary: &FlowResolveSummaryV1,
280 flow_path: &Path,
281) -> Result<()> {
282 let missing = missing_summary_node_mappings(compiled, summary);
283 if !missing.is_empty() {
284 let summary_path = resolve_summary_path_for_flow(flow_path);
285 anyhow::bail!(
286 "flow {} is missing resolve summary entries for nodes {} (summary {}). Regenerate the summary and rerun build.",
287 flow.id,
288 missing.join(", "),
289 summary_path.display()
290 );
291 }
292 Ok(())
293}
294
295fn missing_summary_node_mappings(flow: &Flow, doc: &FlowResolveSummaryV1) -> Vec<String> {
296 flow.nodes
297 .keys()
298 .filter_map(|node| {
299 let id = node.to_string();
300 if doc.nodes.contains_key(id.as_str()) {
301 None
302 } else {
303 Some(id)
304 }
305 })
306 .collect()
307}
308
309fn build_flow_resolve_summary_fallback(
310 flow_path: &Path,
311 sidecar: &FlowResolveV1,
312) -> Result<FlowResolveSummaryV1> {
313 let mut nodes = BTreeMap::new();
314 for (node_id, entry) in &sidecar.nodes {
315 let summary = summarize_node_fallback(flow_path, node_id, &entry.source)?;
316 nodes.insert(node_id.clone(), summary);
317 }
318 Ok(FlowResolveSummaryV1 {
319 schema_version: FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION,
320 flow: flow_name_from_path(flow_path),
321 nodes,
322 })
323}
324
325fn summarize_node_fallback(
326 flow_path: &Path,
327 node_id: &str,
328 source: &ComponentSourceRefV1,
329) -> Result<NodeResolveSummaryV1> {
330 let ComponentSourceRefV1::Local { path, .. } = source else {
331 anyhow::bail!(
332 "flow resolve fallback only supports local sources (node {})",
333 node_id
334 );
335 };
336 let source_ref = FlowResolveSummarySourceRefV1::Local {
337 path: strip_file_prefix(path),
338 };
339 let wasm_path = local_path_from_sidecar(path, flow_path);
340 let digest = compute_sha256(&wasm_path)?;
341 let manifest_path = find_manifest_for_wasm_loose(&wasm_path).with_context(|| {
342 format!(
343 "component.manifest.json not found for node '{}' ({})",
344 node_id,
345 wasm_path.display()
346 )
347 })?;
348 let (component_id, manifest) = read_manifest_metadata(&manifest_path).with_context(|| {
349 format!(
350 "failed to read component.manifest.json for node '{}' ({})",
351 node_id,
352 manifest_path.display()
353 )
354 })?;
355
356 Ok(NodeResolveSummaryV1 {
357 component_id,
358 source: source_ref,
359 digest,
360 manifest,
361 })
362}
363
364fn find_manifest_for_wasm_loose(wasm_path: &Path) -> Result<PathBuf> {
365 let wasm_abs = fs::canonicalize(wasm_path)
366 .with_context(|| format!("resolve wasm path {}", wasm_path.display()))?;
367 let mut current = wasm_abs.parent();
368 let mut fallback = None;
369 while let Some(dir) = current {
370 let candidate = dir.join("component.manifest.json");
371 if candidate.exists() {
372 if manifest_matches_wasm_loose(&candidate, &wasm_abs)? {
373 return Ok(candidate);
374 }
375 if fallback.is_none() {
376 fallback = Some(candidate);
377 }
378 }
379 current = dir.parent();
380 }
381
382 if let Some(candidate) = fallback {
383 return Ok(candidate);
384 }
385
386 anyhow::bail!(
387 "component.manifest.json not found for wasm {}",
388 wasm_abs.display()
389 );
390}
391
392fn manifest_matches_wasm_loose(manifest_path: &Path, wasm_abs: &Path) -> Result<bool> {
393 let raw = fs::read_to_string(manifest_path)
394 .with_context(|| format!("read {}", manifest_path.display()))?;
395 let json: serde_json::Value =
396 serde_json::from_str(&raw).context("parse component.manifest.json")?;
397 let Some(rel) = json
398 .get("artifacts")
399 .and_then(|v| v.get("component_wasm"))
400 .and_then(|v| v.as_str())
401 else {
402 return Ok(false);
403 };
404 let manifest_dir = manifest_path
405 .parent()
406 .ok_or_else(|| anyhow!("manifest path {} has no parent", manifest_path.display()))?;
407 let Ok(abs) = fs::canonicalize(manifest_dir.join(rel)) else {
408 return Ok(false);
409 };
410 Ok(abs == *wasm_abs)
411}
412
413fn read_manifest_metadata(
414 manifest_path: &Path,
415) -> Result<(ComponentId, Option<FlowResolveSummaryManifestV1>)> {
416 let raw = fs::read_to_string(manifest_path)
417 .with_context(|| format!("read {}", manifest_path.display()))?;
418 let json: serde_json::Value =
419 serde_json::from_str(&raw).context("parse component.manifest.json")?;
420 let id = json
421 .get("id")
422 .and_then(|v| v.as_str())
423 .ok_or_else(|| anyhow!("manifest missing id"))?;
424 let component_id =
425 ComponentId::new(id).with_context(|| format!("invalid component id {}", id))?;
426 let world = json.get("world").and_then(|v| v.as_str());
427 let version = json.get("version").and_then(|v| v.as_str());
428 let manifest = match (world, version) {
429 (Some(world), Some(version)) => {
430 let parsed = Version::parse(version)
431 .with_context(|| format!("invalid semver version {}", version))?;
432 Some(FlowResolveSummaryManifestV1 {
433 world: world.to_string(),
434 version: parsed,
435 })
436 }
437 _ => None,
438 };
439 Ok((component_id, manifest))
440}
441
442fn flow_name_from_path(flow_path: &Path) -> String {
443 flow_path
444 .file_name()
445 .map(|name| name.to_string_lossy().to_string())
446 .unwrap_or_else(|| "flow.ygtc".to_string())
447}
448
449fn strip_file_prefix(path: &str) -> String {
450 path.strip_prefix("file://").unwrap_or(path).to_string()
451}
452
453fn local_path_from_sidecar(path: &str, flow_path: &Path) -> PathBuf {
454 let trimmed = path.strip_prefix("file://").unwrap_or(path);
455 let raw = PathBuf::from(trimmed);
456 if raw.is_absolute() {
457 raw
458 } else {
459 flow_path
460 .parent()
461 .unwrap_or_else(|| Path::new("."))
462 .join(raw)
463 }
464}
465
466fn compute_sha256(path: &Path) -> Result<String> {
467 let bytes = fs::read(path).with_context(|| format!("read wasm at {}", path.display()))?;
468 let mut sha = Sha256::new();
469 sha.update(bytes);
470 Ok(format!("sha256:{:x}", sha.finalize()))
471}