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::Flow;
10use greentic_types::error::ErrorCode;
11use greentic_types::flow_resolve::{
12 FlowResolveV1, read_flow_resolve, sidecar_path_for_flow, write_flow_resolve,
13};
14use greentic_types::flow_resolve_summary::{
15 FlowResolveSummaryV1, read_flow_resolve_summary, resolve_summary_path_for_flow,
16};
17
18use crate::config::FlowConfig;
19
20#[derive(Clone, Debug)]
21pub struct FlowResolveSidecar {
22 pub flow_id: String,
23 pub flow_path: PathBuf,
24 pub sidecar_path: PathBuf,
25 pub document: Option<FlowResolveV1>,
26 pub warning: Option<String>,
27}
28
29pub fn discover_flow_resolves(pack_dir: &Path, flows: &[FlowConfig]) -> Vec<FlowResolveSidecar> {
33 flows
34 .iter()
35 .map(|flow| {
36 let flow_path = if flow.file.is_absolute() {
37 flow.file.clone()
38 } else {
39 pack_dir.join(&flow.file)
40 };
41 let sidecar_path = sidecar_path_for_flow(&flow_path);
42
43 let (document, warning) = match read_flow_resolve(&sidecar_path) {
44 Ok(doc) => (Some(doc), None),
45 Err(err) if err.code == ErrorCode::NotFound => (
46 None,
47 Some(format!(
48 "flow resolve sidecar missing for {} ({})",
49 flow.id,
50 sidecar_path.display()
51 )),
52 ),
53 Err(err) => (
54 None,
55 Some(format!(
56 "failed to read flow resolve sidecar for {}: {}",
57 flow.id, err
58 )),
59 ),
60 };
61
62 FlowResolveSidecar {
63 flow_id: flow.id.clone(),
64 flow_path,
65 sidecar_path,
66 document,
67 warning,
68 }
69 })
70 .collect()
71}
72
73pub fn load_flow_resolve_summary(
75 pack_dir: &Path,
76 flow: &FlowConfig,
77 compiled: &Flow,
78) -> Result<FlowResolveSummaryV1> {
79 let flow_path = resolve_flow_path(pack_dir, flow);
80 let summary = read_or_write_flow_resolve_summary(&flow_path, flow)?;
81 enforce_summary_mappings(flow, compiled, &summary, &flow_path)?;
82 Ok(summary)
83}
84
85pub fn read_flow_resolve_summary_for_flow(
87 pack_dir: &Path,
88 flow: &FlowConfig,
89) -> Result<FlowResolveSummaryV1> {
90 let flow_path = resolve_flow_path(pack_dir, flow);
91 read_or_write_flow_resolve_summary(&flow_path, flow)
92}
93
94pub fn ensure_sidecar_exists(
99 pack_dir: &Path,
100 flow: &FlowConfig,
101 compiled: &Flow,
102 strict: bool,
103) -> Result<()> {
104 let flow_path = if flow.file.is_absolute() {
105 flow.file.clone()
106 } else {
107 pack_dir.join(&flow.file)
108 };
109 let sidecar_path = sidecar_path_for_flow(&flow_path);
110
111 let doc = match read_flow_resolve(&sidecar_path) {
112 Ok(doc) => doc,
113 Err(err) if err.code == ErrorCode::NotFound => {
114 let doc = FlowResolveV1 {
115 schema_version: 1,
116 flow: flow.file.to_string_lossy().into_owned(),
117 nodes: BTreeMap::new(),
118 };
119 if let Some(parent) = sidecar_path.parent() {
120 fs::create_dir_all(parent)
121 .with_context(|| format!("failed to create {}", parent.display()))?;
122 }
123 write_flow_resolve(&sidecar_path, &doc)
124 .with_context(|| format!("failed to write {}", sidecar_path.display()))?;
125 doc
126 }
127 Err(err) => {
128 return Err(anyhow!(
129 "failed to read flow resolve sidecar for {}: {}",
130 flow.id,
131 err
132 ));
133 }
134 };
135
136 let missing = missing_node_mappings(compiled, &doc);
137 if !missing.is_empty() {
138 if strict {
139 anyhow::bail!(
140 "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
141 flow.id,
142 missing.join(", "),
143 sidecar_path.display()
144 );
145 } else {
146 eprintln!(
147 "warning: flow {} has no resolve entries for nodes {} ({}); add mappings to the sidecar and rerun `greentic-pack resolve`",
148 flow.id,
149 missing.join(", "),
150 sidecar_path.display()
151 );
152 }
153 }
154
155 Ok(())
156}
157
158pub fn enforce_sidecar_mappings(pack_dir: &Path, flow: &FlowConfig, compiled: &Flow) -> Result<()> {
160 let flow_path = resolve_flow_path(pack_dir, flow);
161 let sidecar_path = sidecar_path_for_flow(&flow_path);
162 let doc = read_flow_resolve(&sidecar_path).map_err(|err| {
163 anyhow!(
164 "flow {} requires a resolve sidecar; expected {}: {}",
165 flow.id,
166 sidecar_path.display(),
167 err
168 )
169 })?;
170
171 let missing = missing_node_mappings(compiled, &doc);
172 if !missing.is_empty() {
173 anyhow::bail!(
174 "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
175 flow.id,
176 missing.join(", "),
177 sidecar_path.display()
178 );
179 }
180
181 Ok(())
182}
183
184pub fn missing_node_mappings(flow: &Flow, doc: &FlowResolveV1) -> Vec<String> {
186 flow.nodes
187 .keys()
188 .filter_map(|node| {
189 let id = node.to_string();
190 if doc.nodes.contains_key(id.as_str()) {
191 None
192 } else {
193 Some(id)
194 }
195 })
196 .collect()
197}
198
199fn resolve_flow_path(pack_dir: &Path, flow: &FlowConfig) -> PathBuf {
200 if flow.file.is_absolute() {
201 flow.file.clone()
202 } else {
203 pack_dir.join(&flow.file)
204 }
205}
206
207fn read_or_write_flow_resolve_summary(
208 flow_path: &Path,
209 flow: &FlowConfig,
210) -> Result<FlowResolveSummaryV1> {
211 let summary_path = resolve_summary_path_for_flow(flow_path);
212 if !summary_path.exists() {
213 let sidecar_path = sidecar_path_for_flow(flow_path);
214 let sidecar = read_flow_resolve(&sidecar_path).map_err(|err| {
215 anyhow!(
216 "flow {} requires a resolve sidecar to generate summary; expected {}: {}",
217 flow.id,
218 sidecar_path.display(),
219 err
220 )
221 })?;
222 write_flow_resolve_summary_safe(flow_path, &sidecar).with_context(|| {
223 format!(
224 "failed to generate flow resolve summary for {}",
225 flow_path.display()
226 )
227 })?;
228 }
229
230 read_flow_resolve_summary(&summary_path).map_err(|err| {
231 anyhow!(
232 "failed to read flow resolve summary for {}: {}",
233 flow.id,
234 err
235 )
236 })
237}
238
239fn write_flow_resolve_summary_safe(flow_path: &Path, sidecar: &FlowResolveV1) -> Result<PathBuf> {
240 if tokio::runtime::Handle::try_current().is_ok() {
241 let flow_path = flow_path.to_path_buf();
242 let sidecar = sidecar.clone();
243 let join =
244 std::thread::spawn(move || write_flow_resolve_summary_for_flow(&flow_path, &sidecar));
245 join.join()
246 .map_err(|_| anyhow!("flow resolve summary generation panicked"))?
247 } else {
248 write_flow_resolve_summary_for_flow(flow_path, sidecar)
249 }
250}
251
252fn enforce_summary_mappings(
253 flow: &FlowConfig,
254 compiled: &Flow,
255 summary: &FlowResolveSummaryV1,
256 flow_path: &Path,
257) -> Result<()> {
258 let missing = missing_summary_node_mappings(compiled, summary);
259 if !missing.is_empty() {
260 let summary_path = resolve_summary_path_for_flow(flow_path);
261 anyhow::bail!(
262 "flow {} is missing resolve summary entries for nodes {} (summary {}). Regenerate the summary and rerun build.",
263 flow.id,
264 missing.join(", "),
265 summary_path.display()
266 );
267 }
268 Ok(())
269}
270
271fn missing_summary_node_mappings(flow: &Flow, doc: &FlowResolveSummaryV1) -> Vec<String> {
272 flow.nodes
273 .keys()
274 .filter_map(|node| {
275 let id = node.to_string();
276 if doc.nodes.contains_key(id.as_str()) {
277 None
278 } else {
279 Some(id)
280 }
281 })
282 .collect()
283}