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}
123
124#[cfg(feature = "std")]
125use std::ffi::OsString;
126#[cfg(feature = "std")]
127use std::fs;
128#[cfg(feature = "std")]
129use std::path::{Path, PathBuf};
130
131/// Returns the expected summary sidecar path for a flow file.
132#[cfg(feature = "std")]
133pub fn resolve_summary_path_for_flow(flow_path: &Path) -> PathBuf {
134    let file_name = flow_path
135        .file_name()
136        .map(OsString::from)
137        .unwrap_or_else(|| OsString::from("flow.ygtc"));
138    let mut sidecar_name = file_name;
139    sidecar_name.push(".resolve.summary.json");
140    flow_path.with_file_name(sidecar_name)
141}
142
143/// Reads a flow resolve summary from disk and validates it.
144#[cfg(all(feature = "std", feature = "serde"))]
145pub fn read_flow_resolve_summary(path: &Path) -> GResult<FlowResolveSummaryV1> {
146    let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve summary", err))?;
147    let doc: FlowResolveSummaryV1 =
148        serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve summary", err))?;
149    validate_flow_resolve_summary(&doc)?;
150    Ok(doc)
151}
152
153/// Writes a flow resolve summary to disk after validation.
154#[cfg(all(feature = "std", feature = "serde"))]
155pub fn write_flow_resolve_summary(path: &Path, doc: &FlowResolveSummaryV1) -> GResult<()> {
156    validate_flow_resolve_summary(doc)?;
157    let raw = serde_json::to_string_pretty(doc)
158        .map_err(|err| json_error("serialize flow resolve summary", err))?;
159    fs::write(path, raw).map_err(|err| io_error("write flow resolve summary", err))?;
160    Ok(())
161}
162
163/// Validates a flow resolve summary document.
164#[cfg(feature = "std")]
165pub fn validate_flow_resolve_summary(doc: &FlowResolveSummaryV1) -> GResult<()> {
166    if doc.schema_version != FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION {
167        return Err(GreenticError::new(
168            ErrorCode::InvalidInput,
169            format!(
170                "flow resolve summary schema_version must be {}",
171                FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION
172            ),
173        ));
174    }
175
176    for (node_name, node) in &doc.nodes {
177        if let FlowResolveSummarySourceRefV1::Local { path } = &node.source {
178            if Path::new(path).is_absolute() {
179                return Err(GreenticError::new(
180                    ErrorCode::InvalidInput,
181                    format!(
182                        "local component path for node '{}' must be relative",
183                        node_name
184                    ),
185                ));
186            }
187        }
188        validate_digest(&node.digest)?;
189        if let Some(metadata) = &node.manifest {
190            if metadata.world.trim().is_empty() {
191                return Err(GreenticError::new(
192                    ErrorCode::InvalidInput,
193                    format!("manifest world for node '{}' must not be empty", node_name),
194                ));
195            }
196        }
197    }
198
199    Ok(())
200}
201
202#[cfg(feature = "std")]
203fn validate_digest(digest: &str) -> GResult<()> {
204    let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
205        GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
206    })?;
207    if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
208        return Err(GreenticError::new(
209            ErrorCode::InvalidInput,
210            "digest must match sha256:<hex>",
211        ));
212    }
213    Ok(())
214}
215
216#[cfg(all(feature = "std", feature = "serde"))]
217fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
218    GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
219}
220
221#[cfg(feature = "std")]
222fn io_error(context: &str, err: std::io::Error) -> GreenticError {
223    let code = match err.kind() {
224        std::io::ErrorKind::NotFound => ErrorCode::NotFound,
225        std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
226        _ => ErrorCode::Unavailable,
227    };
228    GreenticError::new(code, format!("{context}: {err}")).with_source(err)
229}