1use alloc::collections::BTreeMap;
26use alloc::format;
27use alloc::string::String;
28
29use semver::Version;
30
31#[cfg(feature = "schemars")]
32use schemars::JsonSchema;
33#[cfg(feature = "serde")]
34use serde::{Deserialize, Serialize};
35
36use crate::{ComponentId, ErrorCode, GResult, GreenticError};
37
38pub const FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION: u32 = 1;
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44#[cfg_attr(
45 feature = "schemars",
46 derive(JsonSchema),
47 schemars(
48 title = "Greentic Flow Resolve Summary v1",
49 description = "Stable component resolution summary for flow nodes.",
50 rename = "greentic.flow.resolve-summary.v1"
51 )
52)]
53pub struct FlowResolveSummaryV1 {
54 pub schema_version: u32,
56 pub flow: String,
58 pub nodes: BTreeMap<String, NodeResolveSummaryV1>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
65#[cfg_attr(feature = "schemars", derive(JsonSchema))]
66pub struct NodeResolveSummaryV1 {
67 pub component_id: ComponentId,
69 pub source: FlowResolveSummarySourceRefV1,
71 pub digest: String,
73 #[cfg_attr(
75 feature = "serde",
76 serde(default, skip_serializing_if = "Option::is_none")
77 )]
78 pub manifest: Option<FlowResolveSummaryManifestV1>,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
83#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
84#[cfg_attr(feature = "schemars", derive(JsonSchema))]
85pub struct FlowResolveSummaryManifestV1 {
86 pub world: String,
88 #[cfg_attr(
90 feature = "schemars",
91 schemars(with = "String", description = "SemVer version")
92 )]
93 pub version: Version,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
100#[cfg_attr(feature = "schemars", derive(JsonSchema))]
101pub enum FlowResolveSummarySourceRefV1 {
102 Local {
104 path: String,
106 },
107 Oci {
109 r#ref: String,
111 },
112 Repo {
114 r#ref: String,
116 },
117 Store {
119 r#ref: String,
121 },
122 Ext {
124 r#ref: String,
126 },
127}
128
129#[cfg(feature = "std")]
130use std::ffi::OsString;
131#[cfg(feature = "std")]
132use std::fs;
133#[cfg(feature = "std")]
134use std::path::{Path, PathBuf};
135
136#[cfg(feature = "std")]
138pub fn resolve_summary_path_for_flow(flow_path: &Path) -> PathBuf {
139 let file_name = flow_path
140 .file_name()
141 .map(OsString::from)
142 .unwrap_or_else(|| OsString::from("flow.ygtc"));
143 let mut sidecar_name = file_name;
144 sidecar_name.push(".resolve.summary.json");
145 flow_path.with_file_name(sidecar_name)
146}
147
148#[cfg(all(feature = "std", feature = "serde"))]
150pub fn read_flow_resolve_summary(path: &Path) -> GResult<FlowResolveSummaryV1> {
151 let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve summary", err))?;
152 let doc: FlowResolveSummaryV1 =
153 serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve summary", err))?;
154 validate_flow_resolve_summary(&doc)?;
155 Ok(doc)
156}
157
158#[cfg(all(feature = "std", feature = "serde"))]
160pub fn write_flow_resolve_summary(path: &Path, doc: &FlowResolveSummaryV1) -> GResult<()> {
161 validate_flow_resolve_summary(doc)?;
162 let raw = serde_json::to_string_pretty(doc)
163 .map_err(|err| json_error("serialize flow resolve summary", err))?;
164 fs::write(path, raw).map_err(|err| io_error("write flow resolve summary", err))?;
165 Ok(())
166}
167
168#[cfg(feature = "std")]
170pub fn validate_flow_resolve_summary(doc: &FlowResolveSummaryV1) -> GResult<()> {
171 if doc.schema_version != FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION {
172 return Err(GreenticError::new(
173 ErrorCode::InvalidInput,
174 format!(
175 "flow resolve summary schema_version must be {}",
176 FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION
177 ),
178 ));
179 }
180
181 for (node_name, node) in &doc.nodes {
182 if let FlowResolveSummarySourceRefV1::Local { path } = &node.source
183 && Path::new(path).is_absolute()
184 {
185 return Err(GreenticError::new(
186 ErrorCode::InvalidInput,
187 format!(
188 "local component path for node '{}' must be relative",
189 node_name
190 ),
191 ));
192 }
193 validate_digest(&node.digest)?;
194 if let Some(metadata) = &node.manifest
195 && metadata.world.trim().is_empty()
196 {
197 return Err(GreenticError::new(
198 ErrorCode::InvalidInput,
199 format!("manifest world for node '{}' must not be empty", node_name),
200 ));
201 }
202 }
203
204 Ok(())
205}
206
207#[cfg(feature = "std")]
208fn validate_digest(digest: &str) -> GResult<()> {
209 let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
210 GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
211 })?;
212 if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
213 return Err(GreenticError::new(
214 ErrorCode::InvalidInput,
215 "digest must match sha256:<hex>",
216 ));
217 }
218 Ok(())
219}
220
221#[cfg(all(feature = "std", feature = "serde"))]
222fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
223 GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
224}
225
226#[cfg(feature = "std")]
227fn io_error(context: &str, err: std::io::Error) -> GreenticError {
228 let code = match err.kind() {
229 std::io::ErrorKind::NotFound => ErrorCode::NotFound,
230 std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
231 _ => ErrorCode::Unavailable,
232 };
233 GreenticError::new(code, format!("{context}: {err}")).with_source(err)
234}