1use std::{fmt, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::PackageError;
9
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
18pub struct ManifestDigest([u8; 32]);
19
20impl ManifestDigest {
21 #[must_use]
23 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
24 Self(bytes)
25 }
26
27 #[must_use]
29 pub const fn as_bytes(&self) -> &[u8; 32] {
30 &self.0
31 }
32}
33
34impl fmt::Display for ManifestDigest {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 for byte in &self.0 {
37 write!(formatter, "{byte:02x}")?;
38 }
39 Ok(())
40 }
41}
42
43pub const CURRENT_FORMAT_VERSION: u32 = 1;
45
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ManifestVersion(pub String);
53
54impl ManifestVersion {
55 #[must_use]
57 pub fn new(version: impl Into<String>) -> Self {
58 Self(version.into())
59 }
60
61 #[must_use]
63 pub fn as_str(&self) -> &str {
64 &self.0
65 }
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct DeclaredActivity {
71 #[serde(rename = "activity_type")]
76 pub activity_type: String,
77}
78
79#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct WorkflowEntry {
86 pub workflow_type: String,
88 pub entry_module: String,
90 pub entry_function: String,
92 pub input_schema: serde_json::Value,
94 pub output_schema: serde_json::Value,
96 pub timeout: Duration,
98 #[serde(default)]
100 pub internal: bool,
101}
102
103#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
109pub struct Manifest {
110 #[serde(rename = "entry_module")]
112 pub entry_module: String,
113 #[serde(rename = "entry_function")]
115 pub entry_function: String,
116 #[serde(rename = "input_schema")]
118 pub input_schema: serde_json::Value,
119 #[serde(rename = "output_schema")]
121 pub output_schema: serde_json::Value,
122 #[serde(rename = "timeout")]
124 pub timeout: Duration,
125 #[serde(rename = "activities")]
127 pub activities: Vec<DeclaredActivity>,
128 #[serde(rename = "version")]
130 pub version: ManifestVersion,
131 #[serde(rename = "format_version")]
135 pub format_version: u32,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub additional_workflows: Vec<WorkflowEntry>,
141}
142
143impl Manifest {
144 pub fn check_format_version(&self) -> Result<(), PackageError> {
151 if self.format_version == CURRENT_FORMAT_VERSION {
152 Ok(())
153 } else {
154 Err(PackageError::UnknownFormatVersion {
155 found: self.format_version,
156 })
157 }
158 }
159
160 pub fn canonical_digest(&self) -> Result<ManifestDigest, PackageError> {
173 let bytes = serde_json::to_vec(self)
174 .map_err(|source| PackageError::ManifestSerialise { source })?;
175 let mut digest = Sha256::new();
176 digest.update(&bytes);
177 Ok(ManifestDigest(digest.finalize().into()))
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use std::time::Duration;
184
185 use serde_json::json;
186
187 use super::{
188 CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion, WorkflowEntry,
189 };
190 use crate::PackageError;
191
192 fn sample_manifest() -> Manifest {
193 Manifest {
194 entry_module: "workflow/order".to_owned(),
195 entry_function: "run".to_owned(),
196 input_schema: json!({
197 "$schema": "https://json-schema.org/draft/2020-12/schema",
198 "type": "object",
199 "required": ["order_id"],
200 "properties": {
201 "order_id": { "type": "string" },
202 "retry": { "type": "boolean" }
203 }
204 }),
205 output_schema: json!({
206 "$schema": "https://json-schema.org/draft/2020-12/schema",
207 "type": "object",
208 "required": ["status"],
209 "properties": {
210 "status": { "enum": ["accepted", "rejected"] },
211 "total": { "type": "number" }
212 }
213 }),
214 timeout: Duration::new(30, 250_000_000),
215 activities: vec![
216 DeclaredActivity {
217 activity_type: "charge_card".to_owned(),
218 },
219 DeclaredActivity {
220 activity_type: "send_receipt".to_owned(),
221 },
222 ],
223 version: ManifestVersion::new(
224 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
225 ),
226 format_version: CURRENT_FORMAT_VERSION,
227 additional_workflows: Vec::new(),
228 }
229 }
230
231 #[test]
232 fn manifest_round_trips_losslessly_through_json() -> Result<(), serde_json::Error> {
233 let manifest = sample_manifest();
234
235 let json = serde_json::to_string(&manifest)?;
236 let decoded: Manifest = serde_json::from_str(&json)?;
237
238 assert_eq!(decoded, manifest);
239 Ok(())
240 }
241
242 #[test]
243 fn absent_additional_workflow_list_loads_as_legacy_empty() -> Result<(), serde_json::Error> {
244 let mut value = serde_json::to_value(sample_manifest())?;
245 let object = value
246 .as_object_mut()
247 .ok_or_else(|| serde_json::Error::io(std::io::Error::other("manifest not object")))?;
248 object.remove("additional_workflows");
249 let decoded: Manifest = serde_json::from_value(value)?;
250 assert!(decoded.additional_workflows.is_empty());
251 Ok(())
252 }
253
254 #[test]
255 fn additional_workflow_entry_round_trips_with_internal_flag() -> Result<(), serde_json::Error> {
256 let mut manifest = sample_manifest();
257 manifest.additional_workflows.push(WorkflowEntry {
258 workflow_type: "awl_distribute_items_0".to_owned(),
259 entry_module: manifest.entry_module.clone(),
260 entry_function: "awl_distribute_items_0_run".to_owned(),
261 input_schema: json!({ "type": "object" }),
262 output_schema: json!({ "type": "string" }),
263 timeout: Duration::from_secs(30),
264 internal: true,
265 });
266 let encoded = serde_json::to_string(&manifest)?;
267 let decoded: Manifest = serde_json::from_str(&encoded)?;
268 assert_eq!(decoded, manifest);
269 assert!(
270 decoded
271 .additional_workflows
272 .first()
273 .is_some_and(|entry| entry.internal)
274 );
275 Ok(())
276 }
277
278 #[test]
279 fn manifest_with_schemas_and_declared_activities_round_trips() -> Result<(), serde_json::Error>
280 {
281 let manifest = sample_manifest();
282
283 let json = serde_json::to_string(&manifest)?;
284 let decoded: Manifest = serde_json::from_str(&json)?;
285
286 assert_eq!(
287 decoded.input_schema["properties"]["order_id"]["type"],
288 "string"
289 );
290 assert_eq!(
291 decoded.output_schema["properties"]["status"]["enum"][0],
292 "accepted"
293 );
294 assert_eq!(decoded.activities.len(), 2);
295 assert_eq!(decoded, manifest);
296 Ok(())
297 }
298
299 #[test]
300 fn supported_format_version_passes() -> Result<(), PackageError> {
301 sample_manifest().check_format_version()
302 }
303
304 #[test]
308 fn canonical_digest_detects_manifest_divergence() -> Result<(), PackageError> {
309 let manifest = sample_manifest();
310 let same = sample_manifest();
311 let mut diverged = sample_manifest();
312 diverged.entry_function = "start".to_owned();
313
314 assert_eq!(manifest.canonical_digest()?, same.canonical_digest()?);
315 assert_ne!(manifest.canonical_digest()?, diverged.canonical_digest()?);
316 Ok(())
317 }
318
319 #[test]
320 fn canonical_digest_renders_as_lowercase_hex() -> Result<(), PackageError> {
321 let digest = sample_manifest().canonical_digest()?;
322 let text = digest.to_string();
323
324 assert_eq!(text.len(), 64);
325 assert!(
326 text.bytes()
327 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
328 );
329 Ok(())
330 }
331
332 #[test]
333 fn unsupported_format_version_returns_typed_error() {
334 let mut manifest = sample_manifest();
335 manifest.format_version = CURRENT_FORMAT_VERSION + 1;
336
337 let result = manifest.check_format_version();
338
339 assert!(matches!(
340 result,
341 Err(PackageError::UnknownFormatVersion { found }) if found == CURRENT_FORMAT_VERSION + 1
342 ));
343 }
344
345 #[test]
346 fn manifest_json_keys_are_stable() -> Result<(), serde_json::Error> {
347 let manifest = sample_manifest();
348
349 let json = serde_json::to_value(&manifest)?;
350
351 assert!(json.get("entry_module").is_some());
352 assert!(json.get("entry_function").is_some());
353 assert!(json.get("input_schema").is_some());
354 assert!(json.get("output_schema").is_some());
355 assert!(json.get("timeout").is_some());
356 assert!(json.get("activities").is_some());
357 assert!(json.get("version").is_some());
358 assert!(json.get("format_version").is_some());
359 assert_eq!(json["activities"][0]["activity_type"], "charge_card");
360 Ok(())
361 }
362}