Skip to main content

greentic_types/
flow_resolve_summary.rs

1//! Stable flow resolve summary types and helpers.
2//!
3//! # JSON shape (v1)
4//! ```json
5//! {
6//!   "schema_version": 1,
7//!   "flow": "main.ygtc",
8//!   "nodes": {
9//!     "fetch": {
10//!       "component_id": "greentic.demo.component",
11//!       "source": {
12//!         "kind": "oci",
13//!         "ref": "ghcr.io/greentic/demo/component:1.2.3"
14//!       },
15//!       "digest": "sha256:deadbeef",
16//!       "manifest": {
17//!         "world": "greentic:component/world",
18//!         "version": "1.2.3"
19//!       }
20//!     }
21//!   }
22//! }
23//! ```
24
25use 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
38/// Current schema version for flow resolve summaries.
39pub const FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION: u32 = 1;
40
41/// Flow resolve summary (v1).
42#[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    /// Schema version (must be 1).
55    pub schema_version: u32,
56    /// Flow file basename (for example `main.ygtc`).
57    pub flow: String,
58    /// Resolve summary keyed by node name.
59    pub nodes: BTreeMap<String, NodeResolveSummaryV1>,
60}
61
62/// Resolve summary for a flow node.
63#[derive(Clone, Debug, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
65#[cfg_attr(feature = "schemars", derive(JsonSchema))]
66pub struct NodeResolveSummaryV1 {
67    /// Component identifier referenced by this node.
68    pub component_id: ComponentId,
69    /// Component source reference.
70    pub source: FlowResolveSummarySourceRefV1,
71    /// Pinned digest for the resolved artifact.
72    pub digest: String,
73    /// Optional manifest metadata captured at resolve time.
74    #[cfg_attr(
75        feature = "serde",
76        serde(default, skip_serializing_if = "Option::is_none")
77    )]
78    pub manifest: Option<FlowResolveSummaryManifestV1>,
79}
80
81/// Minimal manifest metadata included in the summary.
82#[derive(Clone, Debug, PartialEq, Eq)]
83#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
84#[cfg_attr(feature = "schemars", derive(JsonSchema))]
85pub struct FlowResolveSummaryManifestV1 {
86    /// Referenced WIT world binding.
87    pub world: String,
88    /// Semantic component version.
89    #[cfg_attr(
90        feature = "schemars",
91        schemars(with = "String", description = "SemVer version")
92    )]
93    pub version: Version,
94}
95
96/// Component source references for flow resolve summaries.
97#[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 wasm path relative to the flow file.
103    Local {
104        /// Relative path to the wasm artifact.
105        path: String,
106    },
107    /// OCI component reference.
108    Oci {
109        /// OCI reference.
110        r#ref: String,
111    },
112    /// Repository component reference.
113    Repo {
114        /// Repository reference.
115        r#ref: String,
116    },
117    /// Store component reference.
118    Store {
119        /// Store reference.
120        r#ref: String,
121    },
122    /// Extension-sourced component reference (`ext://<id>#component`).
123    Ext {
124        /// Extension component reference.
125        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/// Returns the expected summary sidecar path for a flow file.
137#[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/// Reads a flow resolve summary from disk and validates it.
149#[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/// Writes a flow resolve summary to disk after validation.
159#[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/// Validates a flow resolve summary document.
169#[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}