1use greentic_x_events::EventType;
43use greentic_x_types::{CompatibilityReference, ContractId, ContractVersion, SchemaReference};
44use serde::{Deserialize, Serialize};
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ContractManifest {
49 pub contract_id: ContractId,
50 pub version: ContractVersion,
51 pub description: String,
52 pub resources: Vec<ResourceDefinition>,
53 #[serde(skip_serializing_if = "Vec::is_empty", default)]
54 pub compatibility: Vec<CompatibilityReference>,
55 #[serde(skip_serializing_if = "Vec::is_empty", default)]
56 pub event_declarations: Vec<EventDeclaration>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub policy_hook: Option<PolicyHookReference>,
59 #[serde(skip_serializing_if = "Vec::is_empty", default)]
60 pub migration_from: Vec<MigrationReference>,
61}
62
63impl ContractManifest {
64 pub fn validate(&self) -> Vec<ValidationIssue> {
65 let mut issues = Vec::new();
66
67 if self.description.trim().is_empty() {
68 issues.push(ValidationIssue::new(
69 "description",
70 "contract description must not be empty",
71 ));
72 }
73
74 if self.resources.is_empty() {
75 issues.push(ValidationIssue::new(
76 "resources",
77 "contract must declare at least one resource",
78 ));
79 }
80
81 for (index, resource) in self.resources.iter().enumerate() {
82 resource.validate(index, &mut issues);
83 }
84
85 issues
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ResourceDefinition {
92 pub resource_type: String,
93 pub schema: SchemaReference,
94 #[serde(skip_serializing_if = "Vec::is_empty", default)]
95 pub patch_rules: Vec<MutationRule>,
96 #[serde(skip_serializing_if = "Vec::is_empty", default)]
97 pub append_collections: Vec<AppendCollectionDefinition>,
98 #[serde(skip_serializing_if = "Vec::is_empty", default)]
99 pub transitions: Vec<TransitionDefinition>,
100}
101
102impl ResourceDefinition {
103 fn validate(&self, index: usize, issues: &mut Vec<ValidationIssue>) {
104 let prefix = format!("resources[{index}]");
105
106 if self.resource_type.trim().is_empty() {
107 issues.push(ValidationIssue::new(
108 format!("{prefix}.resource_type"),
109 "resource_type must not be empty",
110 ));
111 }
112
113 for (rule_index, rule) in self.patch_rules.iter().enumerate() {
114 if rule.path.trim().is_empty() {
115 issues.push(ValidationIssue::new(
116 format!("{prefix}.patch_rules[{rule_index}].path"),
117 "patch rule path must not be empty",
118 ));
119 }
120 }
121
122 for (collection_index, collection) in self.append_collections.iter().enumerate() {
123 if collection.name.trim().is_empty() {
124 issues.push(ValidationIssue::new(
125 format!("{prefix}.append_collections[{collection_index}].name"),
126 "append collection name must not be empty",
127 ));
128 }
129 }
130
131 for (transition_index, transition) in self.transitions.iter().enumerate() {
132 if transition.from_state.trim().is_empty() || transition.to_state.trim().is_empty() {
133 issues.push(ValidationIssue::new(
134 format!("{prefix}.transitions[{transition_index}]"),
135 "transition states must not be empty",
136 ));
137 }
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct MutationRule {
145 pub path: String,
146 #[serde(rename = "kind")]
147 pub rule_kind: MutationRuleKind,
148}
149
150impl MutationRule {
151 pub fn allow(path: impl Into<String>) -> Self {
152 Self {
153 path: path.into(),
154 rule_kind: MutationRuleKind::Allow,
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum MutationRuleKind {
163 Allow,
164 Deny,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct AppendCollectionDefinition {
170 pub name: String,
171 pub item_schema: SchemaReference,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub description: Option<String>,
174}
175
176impl AppendCollectionDefinition {
177 pub fn new(name: impl Into<String>, item_schema: SchemaReference) -> Self {
178 Self {
179 name: name.into(),
180 item_schema,
181 description: None,
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct TransitionDefinition {
189 pub from_state: String,
190 pub to_state: String,
191}
192
193impl TransitionDefinition {
194 pub fn new(from_state: impl Into<String>, to_state: impl Into<String>) -> Self {
195 Self {
196 from_state: from_state.into(),
197 to_state: to_state.into(),
198 }
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct EventDeclaration {
205 pub event_type: EventType,
206}
207
208impl EventDeclaration {
209 pub fn resource_created() -> Self {
210 Self {
211 event_type: EventType::ResourceCreated,
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct PolicyHookReference {
219 pub hook_id: String,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub description: Option<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct MigrationReference {
227 pub from_version: ContractVersion,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub note: Option<String>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ValidationIssue {
235 pub location: String,
236 pub message: String,
237}
238
239impl ValidationIssue {
240 pub fn new(location: impl Into<String>, message: impl Into<String>) -> Self {
241 Self {
242 location: location.into(),
243 message: message.into(),
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use serde_json::Value;
252
253 fn read_contract_manifest(path: &str) -> ContractManifest {
254 let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
255 .join("../..")
256 .join(path);
257 let data = std::fs::read_to_string(&manifest_path)
258 .unwrap_or_else(|_| panic!("failed to read {}", manifest_path.display()));
259 serde_json::from_str(&data)
260 .unwrap_or_else(|_| panic!("failed to parse {}", manifest_path.display()))
261 }
262
263 #[test]
264 fn validates_reference_contract_manifests() {
265 let manifests = [
266 "contracts/case/contract.json",
267 "contracts/evidence/contract.json",
268 "contracts/outcome/contract.json",
269 "contracts/playbook/contract.json",
270 ];
271
272 for path in manifests {
273 let manifest = read_contract_manifest(path);
274 let issues = manifest.validate();
275 assert!(issues.is_empty(), "validation issues in {path}: {issues:?}");
276 }
277 }
278
279 #[test]
280 fn reference_contract_payloads_round_trip() {
281 let manifest = read_contract_manifest("contracts/case/contract.json");
282 let json = serde_json::to_value(&manifest).expect("contract manifest must serialize");
283 assert_eq!(json["contract_id"], Value::String("gx.case".to_owned()));
284 assert_eq!(
285 json["resources"][0]["resource_type"],
286 Value::String("case".to_owned())
287 );
288 }
289
290 #[test]
291 fn detects_missing_resource_definitions() {
292 let manifest = ContractManifest {
293 contract_id: ContractId::new("gx.invalid").expect("static contract id should be valid"),
294 version: ContractVersion::new("v1").expect("static version should be valid"),
295 description: String::new(),
296 resources: Vec::new(),
297 compatibility: Vec::new(),
298 event_declarations: Vec::new(),
299 policy_hook: None,
300 migration_from: Vec::new(),
301 };
302
303 let issues = manifest.validate();
304 assert!(issues.iter().any(|issue| issue.location == "description"));
305 assert!(issues.iter().any(|issue| issue.location == "resources"));
306 }
307}