Skip to main content

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_types::Flow;
9use greentic_types::error::ErrorCode;
10use greentic_types::flow_resolve::{
11    FlowResolveV1, read_flow_resolve, sidecar_path_for_flow, write_flow_resolve,
12};
13
14use crate::config::FlowConfig;
15
16#[derive(Clone, Debug)]
17pub struct FlowResolveSidecar {
18    pub flow_id: String,
19    pub flow_path: PathBuf,
20    pub sidecar_path: PathBuf,
21    pub document: Option<FlowResolveV1>,
22    pub warning: Option<String>,
23}
24
25/// Discover flow resolve sidecars for the configured flows.
26///
27/// Missing or unreadable sidecars produce warnings but do not fail.
28pub fn discover_flow_resolves(pack_dir: &Path, flows: &[FlowConfig]) -> Vec<FlowResolveSidecar> {
29    flows
30        .iter()
31        .map(|flow| {
32            let flow_path = if flow.file.is_absolute() {
33                flow.file.clone()
34            } else {
35                pack_dir.join(&flow.file)
36            };
37            let sidecar_path = sidecar_path_for_flow(&flow_path);
38
39            let (document, warning) = match read_flow_resolve(&sidecar_path) {
40                Ok(doc) => (Some(doc), None),
41                Err(err) if err.code == ErrorCode::NotFound => (
42                    None,
43                    Some(format!(
44                        "flow resolve sidecar missing for {} ({})",
45                        flow.id,
46                        sidecar_path.display()
47                    )),
48                ),
49                Err(err) => (
50                    None,
51                    Some(format!(
52                        "failed to read flow resolve sidecar for {}: {}",
53                        flow.id, err
54                    )),
55                ),
56            };
57
58            FlowResolveSidecar {
59                flow_id: flow.id.clone(),
60                flow_path,
61                sidecar_path,
62                document,
63                warning,
64            }
65        })
66        .collect()
67}
68
69/// Ensure a resolve sidecar exists for a flow and optionally enforce node mappings.
70///
71/// When the sidecar is missing, an empty document is created. Missing node mappings
72/// emit warnings, or become errors when `strict` is set.
73pub fn ensure_sidecar_exists(
74    pack_dir: &Path,
75    flow: &FlowConfig,
76    compiled: &Flow,
77    strict: bool,
78) -> Result<()> {
79    let flow_path = if flow.file.is_absolute() {
80        flow.file.clone()
81    } else {
82        pack_dir.join(&flow.file)
83    };
84    let sidecar_path = sidecar_path_for_flow(&flow_path);
85
86    let doc = match read_flow_resolve(&sidecar_path) {
87        Ok(doc) => doc,
88        Err(err) if err.code == ErrorCode::NotFound => {
89            let doc = FlowResolveV1 {
90                schema_version: 1,
91                flow: flow.file.to_string_lossy().into_owned(),
92                nodes: BTreeMap::new(),
93            };
94            if let Some(parent) = sidecar_path.parent() {
95                fs::create_dir_all(parent)
96                    .with_context(|| format!("failed to create {}", parent.display()))?;
97            }
98            write_flow_resolve(&sidecar_path, &doc)
99                .with_context(|| format!("failed to write {}", sidecar_path.display()))?;
100            doc
101        }
102        Err(err) => {
103            return Err(anyhow!(
104                "failed to read flow resolve sidecar for {}: {}",
105                flow.id,
106                err
107            ));
108        }
109    };
110
111    let missing = missing_node_mappings(compiled, &doc);
112    if !missing.is_empty() {
113        if strict {
114            anyhow::bail!(
115                "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
116                flow.id,
117                missing.join(", "),
118                sidecar_path.display()
119            );
120        } else {
121            eprintln!(
122                "warning: flow {} has no resolve entries for nodes {} ({}); add mappings to the sidecar and rerun `greentic-pack resolve`",
123                flow.id,
124                missing.join(", "),
125                sidecar_path.display()
126            );
127        }
128    }
129
130    Ok(())
131}
132
133/// Require that a resolve sidecar exists and covers every node in the compiled flow.
134pub fn enforce_sidecar_mappings(pack_dir: &Path, flow: &FlowConfig, compiled: &Flow) -> Result<()> {
135    let flow_path = if flow.file.is_absolute() {
136        flow.file.clone()
137    } else {
138        pack_dir.join(&flow.file)
139    };
140    let sidecar_path = sidecar_path_for_flow(&flow_path);
141    let doc = read_flow_resolve(&sidecar_path).map_err(|err| {
142        anyhow!(
143            "flow {} requires a resolve sidecar; expected {}: {}",
144            flow.id,
145            sidecar_path.display(),
146            err
147        )
148    })?;
149
150    let missing = missing_node_mappings(compiled, &doc);
151    if !missing.is_empty() {
152        anyhow::bail!(
153            "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
154            flow.id,
155            missing.join(", "),
156            sidecar_path.display()
157        );
158    }
159
160    Ok(())
161}
162
163/// Compute which nodes in a flow lack resolve entries.
164pub fn missing_node_mappings(flow: &Flow, doc: &FlowResolveV1) -> Vec<String> {
165    flow.nodes
166        .keys()
167        .filter_map(|node| {
168            let id = node.to_string();
169            if doc.nodes.contains_key(id.as_str()) {
170                None
171            } else {
172                Some(id)
173            }
174        })
175        .collect()
176}