use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::String;
use semver::Version;
#[cfg(feature = "schemars")]
use schemars::JsonSchema;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{ComponentId, ErrorCode, GResult, GreenticError};
pub const FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "schemars",
derive(JsonSchema),
schemars(
title = "Greentic Flow Resolve Summary v1",
description = "Stable component resolution summary for flow nodes.",
rename = "greentic.flow.resolve-summary.v1"
)
)]
pub struct FlowResolveSummaryV1 {
pub schema_version: u32,
pub flow: String,
pub nodes: BTreeMap<String, NodeResolveSummaryV1>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct NodeResolveSummaryV1 {
pub component_id: ComponentId,
pub source: FlowResolveSummarySourceRefV1,
pub digest: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub manifest: Option<FlowResolveSummaryManifestV1>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct FlowResolveSummaryManifestV1 {
pub world: String,
#[cfg_attr(
feature = "schemars",
schemars(with = "String", description = "SemVer version")
)]
pub version: Version,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub enum FlowResolveSummarySourceRefV1 {
Local {
path: String,
},
Oci {
r#ref: String,
},
Repo {
r#ref: String,
},
Store {
r#ref: String,
},
}
#[cfg(feature = "std")]
use std::ffi::OsString;
#[cfg(feature = "std")]
use std::fs;
#[cfg(feature = "std")]
use std::path::{Path, PathBuf};
#[cfg(feature = "std")]
pub fn resolve_summary_path_for_flow(flow_path: &Path) -> PathBuf {
let file_name = flow_path
.file_name()
.map(OsString::from)
.unwrap_or_else(|| OsString::from("flow.ygtc"));
let mut sidecar_name = file_name;
sidecar_name.push(".resolve.summary.json");
flow_path.with_file_name(sidecar_name)
}
#[cfg(all(feature = "std", feature = "serde"))]
pub fn read_flow_resolve_summary(path: &Path) -> GResult<FlowResolveSummaryV1> {
let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve summary", err))?;
let doc: FlowResolveSummaryV1 =
serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve summary", err))?;
validate_flow_resolve_summary(&doc)?;
Ok(doc)
}
#[cfg(all(feature = "std", feature = "serde"))]
pub fn write_flow_resolve_summary(path: &Path, doc: &FlowResolveSummaryV1) -> GResult<()> {
validate_flow_resolve_summary(doc)?;
let raw = serde_json::to_string_pretty(doc)
.map_err(|err| json_error("serialize flow resolve summary", err))?;
fs::write(path, raw).map_err(|err| io_error("write flow resolve summary", err))?;
Ok(())
}
#[cfg(feature = "std")]
pub fn validate_flow_resolve_summary(doc: &FlowResolveSummaryV1) -> GResult<()> {
if doc.schema_version != FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION {
return Err(GreenticError::new(
ErrorCode::InvalidInput,
format!(
"flow resolve summary schema_version must be {}",
FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION
),
));
}
for (node_name, node) in &doc.nodes {
if let FlowResolveSummarySourceRefV1::Local { path } = &node.source
&& Path::new(path).is_absolute()
{
return Err(GreenticError::new(
ErrorCode::InvalidInput,
format!(
"local component path for node '{}' must be relative",
node_name
),
));
}
validate_digest(&node.digest)?;
if let Some(metadata) = &node.manifest
&& metadata.world.trim().is_empty()
{
return Err(GreenticError::new(
ErrorCode::InvalidInput,
format!("manifest world for node '{}' must not be empty", node_name),
));
}
}
Ok(())
}
#[cfg(feature = "std")]
fn validate_digest(digest: &str) -> GResult<()> {
let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
})?;
if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(GreenticError::new(
ErrorCode::InvalidInput,
"digest must match sha256:<hex>",
));
}
Ok(())
}
#[cfg(all(feature = "std", feature = "serde"))]
fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
}
#[cfg(feature = "std")]
fn io_error(context: &str, err: std::io::Error) -> GreenticError {
let code = match err.kind() {
std::io::ErrorKind::NotFound => ErrorCode::NotFound,
std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
_ => ErrorCode::Unavailable,
};
GreenticError::new(code, format!("{context}: {err}")).with_source(err)
}