use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::id::{FlowRunId, NodeId, StepRunId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Receipt {
pub id: ReceiptId,
pub flow_run_id: FlowRunId,
pub node_id: NodeId,
pub step_run_id: StepRunId,
pub connector: String,
pub timestamp: DateTime<Utc>,
pub attempt_id_raw: Uuid,
pub input_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_hash: Option<String>,
pub status: ReceiptStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub duration_ms: u64,
}
impl Receipt {
#[allow(clippy::too_many_arguments)]
pub fn new(
flow_run_id: FlowRunId,
node_id: NodeId,
step_run_id: StepRunId,
connector: String,
timestamp: DateTime<Utc>,
attempt_id_raw: Uuid,
input_hash: String,
output_hash: Option<String>,
status: ReceiptStatus,
error: Option<String>,
duration_ms: u64,
) -> Self {
let id = Self::compute_id(
&flow_run_id,
&node_id,
&step_run_id,
&connector,
×tamp,
&attempt_id_raw,
&input_hash,
output_hash.as_deref(),
&status,
error.as_deref(),
duration_ms,
);
Self {
id,
flow_run_id,
node_id,
step_run_id,
connector,
timestamp,
attempt_id_raw,
input_hash,
output_hash,
status,
error,
duration_ms,
}
}
#[allow(clippy::too_many_arguments)]
fn compute_id(
flow_run_id: &FlowRunId,
node_id: &NodeId,
step_run_id: &StepRunId,
connector: &str,
timestamp: &DateTime<Utc>,
attempt_id_raw: &Uuid,
input_hash: &str,
output_hash: Option<&str>,
status: &ReceiptStatus,
error: Option<&str>,
duration_ms: u64,
) -> ReceiptId {
let mut hasher = Sha256::new();
hasher.update(flow_run_id.as_ref().as_bytes());
hasher.update(node_id.as_ref().as_bytes());
hasher.update(step_run_id.as_ref().as_bytes());
hasher.update(connector.as_bytes());
hasher.update(timestamp.to_rfc3339().as_bytes());
hasher.update(attempt_id_raw.as_bytes());
hasher.update(input_hash.as_bytes());
match output_hash {
Some(h) => {
hasher.update(b"\x01");
hasher.update(h.as_bytes());
}
None => hasher.update(b"\x00"),
}
hasher.update(status.as_str().as_bytes());
match error {
Some(e) => {
hasher.update(b"\x01");
hasher.update(e.as_bytes());
}
None => hasher.update(b"\x00"),
}
hasher.update(duration_ms.to_le_bytes());
let hash = hasher.finalize();
ReceiptId(format!("{:x}", hash))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ReceiptId(String);
impl ReceiptId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ReceiptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptStatus {
Success,
Failure,
Timeout,
}
impl ReceiptStatus {
fn as_str(&self) -> &'static str {
match self {
Self::Success => "success",
Self::Failure => "failure",
Self::Timeout => "timeout",
}
}
}
pub fn sha256_hex(data: &[u8]) -> String {
let hash = Sha256::digest(data);
format!("{:x}", hash)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::{FlowRunId, NodeId, StepRunId};
fn sample_receipt() -> Receipt {
Receipt::new(
FlowRunId::from(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()),
NodeId::from(Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()),
StepRunId::from(Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()),
"http.request".into(),
DateTime::parse_from_rfc3339("2026-03-05T12:00:00Z").unwrap().with_timezone(&Utc),
Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(),
"abc123".into(),
Some("def456".into()),
ReceiptStatus::Success,
None,
150,
)
}
#[test]
fn content_addressable_id_is_deterministic() {
let a = sample_receipt();
let b = sample_receipt();
assert_eq!(a.id, b.id);
}
#[test]
fn changing_any_field_changes_id() {
let base = sample_receipt();
let modified = Receipt::new(
base.flow_run_id,
base.node_id,
base.step_run_id,
"fs.write".into(),
base.timestamp,
base.attempt_id_raw,
base.input_hash.clone(),
base.output_hash.clone(),
base.status,
base.error.clone(),
base.duration_ms,
);
assert_ne!(base.id, modified.id);
let modified2 = Receipt::new(
base.flow_run_id,
base.node_id,
base.step_run_id,
base.connector.clone(),
base.timestamp,
base.attempt_id_raw,
base.input_hash.clone(),
base.output_hash.clone(),
base.status,
base.error.clone(),
999,
);
assert_ne!(base.id, modified2.id);
let modified3 = Receipt::new(
base.flow_run_id,
base.node_id,
base.step_run_id,
base.connector.clone(),
base.timestamp,
base.attempt_id_raw,
base.input_hash.clone(),
base.output_hash.clone(),
ReceiptStatus::Failure,
Some("boom".into()),
base.duration_ms,
);
assert_ne!(base.id, modified3.id);
}
#[test]
fn receipt_json_roundtrip() {
let receipt = sample_receipt();
let json = serde_json::to_string(&receipt).unwrap();
let back: Receipt = serde_json::from_str(&json).unwrap();
assert_eq!(receipt, back);
}
#[test]
fn receipt_status_variants_roundtrip() {
for status in [ReceiptStatus::Success, ReceiptStatus::Failure, ReceiptStatus::Timeout] {
let json = serde_json::to_string(&status).unwrap();
let back: ReceiptStatus = serde_json::from_str(&json).unwrap();
assert_eq!(status, back);
}
}
#[test]
fn receipt_with_no_output_hash() {
let receipt = Receipt::new(
FlowRunId::from(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()),
NodeId::from(Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()),
StepRunId::from(Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()),
"http.request".into(),
Utc::now(),
Uuid::new_v4(),
"abc".into(),
None,
ReceiptStatus::Failure,
Some("connection refused".into()),
0,
);
let json = serde_json::to_string(&receipt).unwrap();
let back: Receipt = serde_json::from_str(&json).unwrap();
assert_eq!(receipt, back);
assert!(receipt.output_hash.is_none());
assert!(receipt.error.is_some());
}
#[test]
fn sha256_hex_utility() {
let hash = sha256_hex(b"hello world");
assert_eq!(hash.len(), 64); assert_eq!(hash, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9");
}
#[test]
fn receipt_id_display() {
let receipt = sample_receipt();
let display = receipt.id.to_string();
assert_eq!(display, receipt.id.as_str());
assert_eq!(display.len(), 64);
}
}