use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SavingsEvent {
pub ts: String,
pub tool: String,
pub model_id: String,
pub tokenizer: String,
pub baseline_tokens: u64,
pub actual_tokens: u64,
pub saved_tokens: u64,
pub bounce_adjustment: u64,
pub unit_price_per_m_usd: f64,
pub saved_usd: f64,
pub repo_hash: String,
pub agent_id: String,
pub prev_hash: String,
pub entry_hash: String,
}
impl SavingsEvent {
pub fn canonical_content(&self) -> String {
format!(
"{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
self.ts,
self.tool,
self.model_id,
self.tokenizer,
self.baseline_tokens,
self.actual_tokens,
self.saved_tokens,
self.bounce_adjustment,
self.unit_price_per_m_usd,
self.saved_usd,
self.repo_hash,
self.agent_id,
)
}
}
pub fn compute_hash(prev_hash: &str, content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(prev_hash.as_bytes());
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
fn ev() -> SavingsEvent {
SavingsEvent {
ts: "2026-06-01T00:00:00+00:00".into(),
tool: "ctx_read".into(),
model_id: "claude-3.5-sonnet".into(),
tokenizer: "o200k_base".into(),
baseline_tokens: 1000,
actual_tokens: 300,
saved_tokens: 700,
bounce_adjustment: 0,
unit_price_per_m_usd: 3.0,
saved_usd: 0.0021,
repo_hash: "abc123".into(),
agent_id: "local".into(),
prev_hash: String::new(),
entry_hash: String::new(),
}
}
#[test]
fn hash_is_deterministic() {
let e = ev();
let a = compute_hash("genesis", &e.canonical_content());
let b = compute_hash("genesis", &e.canonical_content());
assert_eq!(a, b);
assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
}
#[test]
fn hash_changes_when_content_changes() {
let mut e = ev();
let a = compute_hash("genesis", &e.canonical_content());
e.saved_tokens = 701;
let b = compute_hash("genesis", &e.canonical_content());
assert_ne!(a, b, "tampering with a content field must change the hash");
}
#[test]
fn hash_depends_on_prev() {
let e = ev();
let a = compute_hash("genesis", &e.canonical_content());
let b = compute_hash("other", &e.canonical_content());
assert_ne!(a, b, "chain link must depend on prev_hash");
}
}