packc/
flow_resolve.rs

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
29/// Discover flow resolve sidecars for the configured flows.
30///
31/// Missing or unreadable sidecars produce warnings but do not fail.
32pub 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
73/// Ensure a flow resolve summary exists, generating it from the resolve sidecar if missing.
74pub 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
85/// Read or generate a flow resolve summary for a flow (no node enforcement).
86pub 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
94/// Ensure a resolve sidecar exists for a flow and optionally enforce node mappings.
95///
96/// When the sidecar is missing, an empty document is created. Missing node mappings
97/// emit warnings, or become errors when `strict` is set.
98pub 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
158/// Require that a resolve sidecar exists and covers every node in the compiled flow.
159pub 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
184/// Compute which nodes in a flow lack resolve entries.
185pub 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}