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 #[serde(serialize_with = "crate::canonical::serialize_value")]
94 pub input_schema: serde_json::Value,
95 #[serde(serialize_with = "crate::canonical::serialize_value")]
97 pub output_schema: serde_json::Value,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub timeout: Option<Duration>,
106 #[serde(default)]
108 pub internal: bool,
109}
110
111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
117pub struct Manifest {
118 #[serde(rename = "entry_module")]
120 pub entry_module: String,
121 #[serde(rename = "entry_function")]
123 pub entry_function: String,
124 #[serde(
126 rename = "input_schema",
127 serialize_with = "crate::canonical::serialize_value"
128 )]
129 pub input_schema: serde_json::Value,
130 #[serde(
132 rename = "output_schema",
133 serialize_with = "crate::canonical::serialize_value"
134 )]
135 pub output_schema: serde_json::Value,
136 #[serde(rename = "timeout", default, skip_serializing_if = "Option::is_none")]
146 pub timeout: Option<Duration>,
147 #[serde(rename = "activities")]
149 pub activities: Vec<DeclaredActivity>,
150 #[serde(rename = "version")]
152 pub version: ManifestVersion,
153 #[serde(rename = "format_version")]
157 pub format_version: u32,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub additional_workflows: Vec<WorkflowEntry>,
163}
164
165impl Manifest {
166 pub fn check_format_version(&self) -> Result<(), PackageError> {
173 if self.format_version == CURRENT_FORMAT_VERSION {
174 Ok(())
175 } else {
176 Err(PackageError::UnknownFormatVersion {
177 found: self.format_version,
178 })
179 }
180 }
181
182 pub fn canonical_digest(&self) -> Result<ManifestDigest, PackageError> {
200 let bytes = serde_json::to_vec(self)
201 .map_err(|source| PackageError::ManifestSerialise { source })?;
202 let mut digest = Sha256::new();
203 digest.update(&bytes);
204 Ok(ManifestDigest(digest.finalize().into()))
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use std::time::Duration;
211
212 use serde_json::json;
213
214 use super::{
215 CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion, WorkflowEntry,
216 };
217 use crate::PackageError;
218
219 fn sample_manifest() -> Manifest {
220 Manifest {
221 entry_module: "workflow/order".to_owned(),
222 entry_function: "run".to_owned(),
223 input_schema: json!({
224 "$schema": "https://json-schema.org/draft/2020-12/schema",
225 "type": "object",
226 "required": ["order_id"],
227 "properties": {
228 "order_id": { "type": "string" },
229 "retry": { "type": "boolean" }
230 }
231 }),
232 output_schema: json!({
233 "$schema": "https://json-schema.org/draft/2020-12/schema",
234 "type": "object",
235 "required": ["status"],
236 "properties": {
237 "status": { "enum": ["accepted", "rejected"] },
238 "total": { "type": "number" }
239 }
240 }),
241 timeout: Some(Duration::new(30, 250_000_000)),
242 activities: vec![
243 DeclaredActivity {
244 activity_type: "charge_card".to_owned(),
245 },
246 DeclaredActivity {
247 activity_type: "send_receipt".to_owned(),
248 },
249 ],
250 version: ManifestVersion::new(
251 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
252 ),
253 format_version: CURRENT_FORMAT_VERSION,
254 additional_workflows: Vec::new(),
255 }
256 }
257
258 #[test]
259 fn manifest_round_trips_losslessly_through_json() -> Result<(), serde_json::Error> {
260 let manifest = sample_manifest();
261
262 let json = serde_json::to_string(&manifest)?;
263 let decoded: Manifest = serde_json::from_str(&json)?;
264
265 assert_eq!(decoded, manifest);
266 Ok(())
267 }
268
269 #[test]
270 fn absent_additional_workflow_list_loads_as_legacy_empty() -> Result<(), serde_json::Error> {
271 let mut value = serde_json::to_value(sample_manifest())?;
272 let object = value
273 .as_object_mut()
274 .ok_or_else(|| serde_json::Error::io(std::io::Error::other("manifest not object")))?;
275 object.remove("additional_workflows");
276 let decoded: Manifest = serde_json::from_value(value)?;
277 assert!(decoded.additional_workflows.is_empty());
278 Ok(())
279 }
280
281 #[test]
282 fn additional_workflow_entry_round_trips_with_internal_flag() -> Result<(), serde_json::Error> {
283 let mut manifest = sample_manifest();
284 manifest.additional_workflows.push(WorkflowEntry {
285 workflow_type: "awl_distribute_items_0".to_owned(),
286 entry_module: manifest.entry_module.clone(),
287 entry_function: "awl_distribute_items_0_run".to_owned(),
288 input_schema: json!({ "type": "object" }),
289 output_schema: json!({ "type": "string" }),
290 timeout: Some(Duration::from_secs(30)),
291 internal: true,
292 });
293 let encoded = serde_json::to_string(&manifest)?;
294 let decoded: Manifest = serde_json::from_str(&encoded)?;
295 assert_eq!(decoded, manifest);
296 assert!(
297 decoded
298 .additional_workflows
299 .first()
300 .is_some_and(|entry| entry.internal)
301 );
302 Ok(())
303 }
304
305 #[test]
306 fn manifest_with_schemas_and_declared_activities_round_trips() -> Result<(), serde_json::Error>
307 {
308 let manifest = sample_manifest();
309
310 let json = serde_json::to_string(&manifest)?;
311 let decoded: Manifest = serde_json::from_str(&json)?;
312
313 assert_eq!(
314 decoded.input_schema["properties"]["order_id"]["type"],
315 "string"
316 );
317 assert_eq!(
318 decoded.output_schema["properties"]["status"]["enum"][0],
319 "accepted"
320 );
321 assert_eq!(decoded.activities.len(), 2);
322 assert_eq!(decoded, manifest);
323 Ok(())
324 }
325
326 #[test]
327 fn supported_format_version_passes() -> Result<(), PackageError> {
328 sample_manifest().check_format_version()
329 }
330
331 #[test]
335 fn canonical_digest_detects_manifest_divergence() -> Result<(), PackageError> {
336 let manifest = sample_manifest();
337 let same = sample_manifest();
338 let mut diverged = sample_manifest();
339 diverged.entry_function = "start".to_owned();
340
341 assert_eq!(manifest.canonical_digest()?, same.canonical_digest()?);
342 assert_ne!(manifest.canonical_digest()?, diverged.canonical_digest()?);
343 Ok(())
344 }
345
346 #[test]
347 fn canonical_digest_renders_as_lowercase_hex() -> Result<(), PackageError> {
348 let digest = sample_manifest().canonical_digest()?;
349 let text = digest.to_string();
350
351 assert_eq!(text.len(), 64);
352 assert!(
353 text.bytes()
354 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
355 );
356 Ok(())
357 }
358
359 #[test]
360 fn unsupported_format_version_returns_typed_error() {
361 let mut manifest = sample_manifest();
362 manifest.format_version = CURRENT_FORMAT_VERSION + 1;
363
364 let result = manifest.check_format_version();
365
366 assert!(matches!(
367 result,
368 Err(PackageError::UnknownFormatVersion { found }) if found == CURRENT_FORMAT_VERSION + 1
369 ));
370 }
371
372 #[test]
373 fn manifest_json_keys_are_stable() -> Result<(), serde_json::Error> {
374 let manifest = sample_manifest();
375
376 let json = serde_json::to_value(&manifest)?;
377
378 assert!(json.get("entry_module").is_some());
379 assert!(json.get("entry_function").is_some());
380 assert!(json.get("input_schema").is_some());
381 assert!(json.get("output_schema").is_some());
382 assert!(json.get("timeout").is_some());
383 assert!(json.get("activities").is_some());
384 assert!(json.get("version").is_some());
385 assert!(json.get("format_version").is_some());
386 assert_eq!(json["activities"][0]["activity_type"], "charge_card");
387 Ok(())
388 }
389}