use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use sha2::{Digest as _, Sha256};
pub type Timestamp = time::OffsetDateTime;
#[must_use]
pub fn format_timestamp(at: Timestamp) -> String {
at.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| at.unix_timestamp().to_string())
}
pub type Seq = u64;
pub type Epoch = u64;
macro_rules! ulid_newtype {
($(#[$m:meta])* $name:ident, $prefix:literal) => {
$(#[$m])*
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub ulid::Ulid);
impl $name {
#[allow(clippy::disallowed_methods)]
#[must_use]
pub fn generate() -> Self {
Self(ulid::Ulid::new())
}
pub fn parse(s: &str) -> Result<Self, ulid::DecodeError> {
let bare = s.strip_prefix(concat!($prefix, "_")).unwrap_or(s);
ulid::Ulid::from_string(bare).map(Self)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}_{}", $prefix, self.0)
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self}")
}
}
};
}
ulid_newtype!(
RunId, "run"
);
ulid_newtype!(
CaseId, "case"
);
ulid_newtype!(
BatchId, "batch"
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StepId(pub u32);
impl fmt::Display for StepId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "s{}", self.0)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub struct Digest([u8; 32]);
impl Digest {
pub const ZERO: Self = Self([0u8; 32]);
#[must_use]
pub fn of(bytes: &[u8]) -> Self {
let mut h = Sha256::new();
h.update(bytes);
Self(h.finalize().into())
}
#[must_use]
pub fn chain(prev: Self, bytes: &[u8]) -> Self {
let mut h = Sha256::new();
h.update(prev.0);
h.update(bytes);
Self(h.finalize().into())
}
#[must_use]
pub const fn from_bytes(b: [u8; 32]) -> Self {
Self(b)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[must_use]
pub fn to_hex(self) -> String {
hex::encode(self.0)
}
pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
let mut out = [0u8; 32];
hex::decode_to_slice(s, &mut out)?;
Ok(Self(out))
}
#[must_use]
pub fn short(self) -> String {
hex::encode(&self.0[..4])
}
}
impl fmt::Display for Digest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_hex())
}
}
impl fmt::Debug for Digest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}…", self.short())
}
}
impl Serialize for Digest {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for Digest {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::from_hex(&s).map_err(D::Error::custom)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EffectKey(Digest);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
#[default]
Forward,
Compensating,
}
impl Phase {
#[must_use]
pub const fn is_forward(self) -> bool {
matches!(self, Self::Forward)
}
#[allow(clippy::trivially_copy_pass_by_ref)]
#[must_use]
pub const fn is_forward_ref(v: &Self) -> bool {
v.is_forward()
}
}
impl EffectKey {
pub(crate) fn derive(
step: StepId,
phase: Phase,
ordinal: u32,
attempt: u32,
kind: &str,
canonical_args: &[u8],
) -> Self {
let mut h = Sha256::new();
h.update(step.0.to_be_bytes());
h.update([phase as u8]);
h.update(ordinal.to_be_bytes());
h.update(attempt.to_be_bytes());
h.update((kind.len() as u64).to_be_bytes());
h.update(kind.as_bytes());
h.update(canonical_args);
Self(Digest(h.finalize().into()))
}
#[must_use]
pub fn to_hex(self) -> String {
self.0.to_hex()
}
pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
Digest::from_hex(s).map(Self)
}
}
impl fmt::Display for EffectKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ek:{}", self.0.to_hex())
}
}
impl fmt::Debug for EffectKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ek:{}…", self.0.short())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chain_is_order_sensitive() {
let a = Digest::chain(Digest::ZERO, b"a");
let b = Digest::chain(a, b"b");
let swapped = Digest::chain(Digest::chain(Digest::ZERO, b"b"), b"a");
assert_ne!(b, swapped, "chain must not be commutative");
}
#[test]
fn chain_detects_any_mutation() {
let genuine = Digest::chain(Digest::ZERO, b"record-1");
let tampered = Digest::chain(Digest::ZERO, b"record-2");
assert_ne!(genuine, tampered);
}
#[test]
fn digest_hex_roundtrips() {
let d = Digest::of(b"hello");
assert_eq!(Digest::from_hex(&d.to_hex()).unwrap(), d);
}
#[test]
fn effect_key_separates_step_phase_ordinal_and_attempt() {
let fwd = Phase::Forward;
let base = EffectKey::derive(StepId(0), fwd, 0, 1, "tool", b"{}");
let other_ordinal = EffectKey::derive(StepId(0), fwd, 1, 1, "tool", b"{}");
let other_step = EffectKey::derive(StepId(1), fwd, 0, 1, "tool", b"{}");
let other_attempt = EffectKey::derive(StepId(0), fwd, 0, 2, "tool", b"{}");
let compensating = EffectKey::derive(StepId(0), Phase::Compensating, 0, 1, "tool", b"{}");
assert_ne!(base, other_ordinal, "ordinal must be part of the key");
assert_ne!(base, other_step, "step must be part of the key");
assert_ne!(
base, other_attempt,
"attempt must be part of the key, or a retry collides with the \
failure it is retrying"
);
assert_ne!(
base, compensating,
"phase must be part of the key, or a step's compensation collides \
with its own forward pass"
);
}
#[test]
fn effect_key_kind_is_length_prefixed() {
let a = EffectKey::derive(StepId(0), Phase::Forward, 0, 1, "ab", b"c");
let b = EffectKey::derive(StepId(0), Phase::Forward, 0, 1, "a", b"bc");
assert_ne!(a, b);
}
#[test]
fn ids_display_with_prefix() {
let r = RunId::generate();
assert!(r.to_string().starts_with("run_"));
}
#[test]
fn ids_round_trip_through_their_displayed_form() {
let r = RunId::generate();
assert_eq!(RunId::parse(&r.to_string()).unwrap(), r);
let c = CaseId::generate();
assert_eq!(CaseId::parse(&c.to_string()).unwrap(), c);
}
#[test]
fn bare_ulids_still_parse() {
let r = RunId::generate();
assert_eq!(RunId::parse(&r.0.to_string()).unwrap(), r);
}
#[test]
fn parsing_rejects_garbage() {
assert!(RunId::parse("run_not-a-ulid").is_err());
}
#[test]
fn the_digest_matches_sha256_computed_elsewhere() {
assert_eq!(
Digest::of(b"").to_hex(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"the empty digest moved, so every digest in every journal moved with it"
);
assert_eq!(
Digest::of(b"agentplane").to_hex(),
"c0f8f77669f4860960387db0dc9984894587bcfcc75d1846ffd3574563833443"
);
assert_eq!(
Digest::chain(Digest::of(b""), b"agentplane").to_hex(),
"171c5eddf30189b0efde9c71eb14f77685fab77d7750152d032f9e7cf4181159"
);
}
}