use greentic_x_events::EventType;
use greentic_x_types::{CompatibilityReference, ContractId, ContractVersion, SchemaReference};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractManifest {
pub contract_id: ContractId,
pub version: ContractVersion,
pub description: String,
pub resources: Vec<ResourceDefinition>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub compatibility: Vec<CompatibilityReference>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub event_declarations: Vec<EventDeclaration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_hook: Option<PolicyHookReference>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub migration_from: Vec<MigrationReference>,
}
impl ContractManifest {
pub fn validate(&self) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if self.description.trim().is_empty() {
issues.push(ValidationIssue::new(
"description",
"contract description must not be empty",
));
}
if self.resources.is_empty() {
issues.push(ValidationIssue::new(
"resources",
"contract must declare at least one resource",
));
}
for (index, resource) in self.resources.iter().enumerate() {
resource.validate(index, &mut issues);
}
issues
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceDefinition {
pub resource_type: String,
pub schema: SchemaReference,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub patch_rules: Vec<MutationRule>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub append_collections: Vec<AppendCollectionDefinition>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub transitions: Vec<TransitionDefinition>,
}
impl ResourceDefinition {
fn validate(&self, index: usize, issues: &mut Vec<ValidationIssue>) {
let prefix = format!("resources[{index}]");
if self.resource_type.trim().is_empty() {
issues.push(ValidationIssue::new(
format!("{prefix}.resource_type"),
"resource_type must not be empty",
));
}
for (rule_index, rule) in self.patch_rules.iter().enumerate() {
if rule.path.trim().is_empty() {
issues.push(ValidationIssue::new(
format!("{prefix}.patch_rules[{rule_index}].path"),
"patch rule path must not be empty",
));
}
}
for (collection_index, collection) in self.append_collections.iter().enumerate() {
if collection.name.trim().is_empty() {
issues.push(ValidationIssue::new(
format!("{prefix}.append_collections[{collection_index}].name"),
"append collection name must not be empty",
));
}
}
for (transition_index, transition) in self.transitions.iter().enumerate() {
if transition.from_state.trim().is_empty() || transition.to_state.trim().is_empty() {
issues.push(ValidationIssue::new(
format!("{prefix}.transitions[{transition_index}]"),
"transition states must not be empty",
));
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MutationRule {
pub path: String,
#[serde(rename = "kind")]
pub rule_kind: MutationRuleKind,
}
impl MutationRule {
pub fn allow(path: impl Into<String>) -> Self {
Self {
path: path.into(),
rule_kind: MutationRuleKind::Allow,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MutationRuleKind {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendCollectionDefinition {
pub name: String,
pub item_schema: SchemaReference,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl AppendCollectionDefinition {
pub fn new(name: impl Into<String>, item_schema: SchemaReference) -> Self {
Self {
name: name.into(),
item_schema,
description: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionDefinition {
pub from_state: String,
pub to_state: String,
}
impl TransitionDefinition {
pub fn new(from_state: impl Into<String>, to_state: impl Into<String>) -> Self {
Self {
from_state: from_state.into(),
to_state: to_state.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventDeclaration {
pub event_type: EventType,
}
impl EventDeclaration {
pub fn resource_created() -> Self {
Self {
event_type: EventType::ResourceCreated,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyHookReference {
pub hook_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationReference {
pub from_version: ContractVersion,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
pub location: String,
pub message: String,
}
impl ValidationIssue {
pub fn new(location: impl Into<String>, message: impl Into<String>) -> Self {
Self {
location: location.into(),
message: message.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn read_contract_manifest(path: &str) -> ContractManifest {
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(path);
let data = std::fs::read_to_string(&manifest_path)
.unwrap_or_else(|_| panic!("failed to read {}", manifest_path.display()));
serde_json::from_str(&data)
.unwrap_or_else(|_| panic!("failed to parse {}", manifest_path.display()))
}
#[test]
fn validates_reference_contract_manifests() {
let manifests = [
"contracts/case/contract.json",
"contracts/evidence/contract.json",
"contracts/outcome/contract.json",
"contracts/playbook/contract.json",
];
for path in manifests {
let manifest = read_contract_manifest(path);
let issues = manifest.validate();
assert!(issues.is_empty(), "validation issues in {path}: {issues:?}");
}
}
#[test]
fn reference_contract_payloads_round_trip() {
let manifest = read_contract_manifest("contracts/case/contract.json");
let json = serde_json::to_value(&manifest).expect("contract manifest must serialize");
assert_eq!(json["contract_id"], Value::String("gx.case".to_owned()));
assert_eq!(
json["resources"][0]["resource_type"],
Value::String("case".to_owned())
);
}
#[test]
fn detects_missing_resource_definitions() {
let manifest = ContractManifest {
contract_id: ContractId::new("gx.invalid").expect("static contract id should be valid"),
version: ContractVersion::new("v1").expect("static version should be valid"),
description: String::new(),
resources: Vec::new(),
compatibility: Vec::new(),
event_declarations: Vec::new(),
policy_hook: None,
migration_from: Vec::new(),
};
let issues = manifest.validate();
assert!(issues.iter().any(|issue| issue.location == "description"));
assert!(issues.iter().any(|issue| issue.location == "resources"));
}
}