Skip to main content

greentic_x_contracts/
lib.rs

1//! Contract descriptors and structural validation helpers for Greentic-X.
2//!
3//! ```rust
4//! use greentic_x_contracts::{
5//!     ContractManifest, EventDeclaration, MutationRule, ResourceDefinition, TransitionDefinition,
6//!     ValidationIssue,
7//! };
8//! use greentic_x_types::{CompatibilityMode, CompatibilityReference, ContractId, ContractVersion, SchemaReference};
9//!
10//! let manifest = ContractManifest {
11//!     contract_id: ContractId::new("gx.case").expect("static contract id should be valid"),
12//!     version: ContractVersion::new("v1").expect("static version should be valid"),
13//!     description: "Shared operational case contract".to_owned(),
14//!     resources: vec![ResourceDefinition {
15//!         resource_type: "case".to_owned(),
16//!         schema: SchemaReference::new(
17//!             "greentic-x://contracts/case/resources/case",
18//!             ContractVersion::new("v1").expect("static version should be valid"),
19//!         )
20//!         .expect("static schema should be valid"),
21//!         patch_rules: vec![MutationRule::allow("/title"), MutationRule::allow("/severity")],
22//!         append_collections: vec![],
23//!         transitions: vec![TransitionDefinition::new("triaged", "resolved")],
24//!     }],
25//!     compatibility: vec![CompatibilityReference {
26//!         schema: SchemaReference::new(
27//!             "greentic-x://contracts/case/compatibility",
28//!             ContractVersion::new("v1").expect("static version should be valid"),
29//!         )
30//!         .expect("static schema should be valid"),
31//!         mode: CompatibilityMode::BackwardCompatible,
32//!     }],
33//!     event_declarations: vec![EventDeclaration::resource_created()],
34//!     policy_hook: None,
35//!     migration_from: Vec::new(),
36//! };
37//!
38//! let issues = manifest.validate();
39//! assert!(issues.is_empty(), "unexpected validation issues: {issues:?}");
40//! ```
41
42use greentic_x_events::EventType;
43use greentic_x_types::{CompatibilityReference, ContractId, ContractVersion, SchemaReference};
44use serde::{Deserialize, Serialize};
45
46/// Top-level descriptor for a contract package.
47#[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/// Definition of a resource managed by a contract.
90#[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/// Patchable field declaration.
143#[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/// Whether a path is allowed or denied for patch operations.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum MutationRuleKind {
163    Allow,
164    Deny,
165}
166
167/// Append-only collection declaration.
168#[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/// Resource transition declaration.
187#[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/// Event declaration exposed by the contract.
203#[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/// Optional policy integration hook.
217#[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/// Compatibility or migration source reference.
225#[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/// Validation problem found in a manifest.
233#[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}