1use chrono::{DateTime, Utc};
2use serde_json::Value;
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5use std::fs::{OpenOptions, create_dir_all};
6use std::io::Write;
7use std::path::PathBuf;
8use tracing::info;
9
10#[derive(Debug)]
11pub enum LedgerError {
12 Io,
13 Serialization,
14}
15
16impl Display for LedgerError {
17 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Self::Io => write!(f, "ledger io failure"),
20 Self::Serialization => write!(f, "ledger serialization failure"),
21 }
22 }
23}
24
25impl Error for LedgerError {}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LedgerRecord {
29 pub digest: [u8; 32],
30 pub recorded_at: DateTime<Utc>,
31 pub metadata: Value,
32}
33
34pub trait LedgerAdapter: Send + Sync {
36 fn submit(&self, record: &LedgerRecord) -> Result<(), LedgerError>;
37}
38
39pub struct NullLedger;
40
41impl LedgerAdapter for NullLedger {
42 fn submit(&self, _record: &LedgerRecord) -> Result<(), LedgerError> {
43 Ok(())
44 }
45}
46
47pub struct FileLedgerAdapter {
48 path: PathBuf,
49}
50
51impl FileLedgerAdapter {
52 pub fn new(path: PathBuf) -> Result<Self, LedgerError> {
54 tracing::debug!("creating file ledger adapter at: {}", path.display());
55 if let Some(parent) = path.parent()
56 && !parent.as_os_str().is_empty()
57 {
58 tracing::debug!("ensuring parent directory exists: {}", parent.display());
59 create_dir_all(parent).map_err(|e| {
60 tracing::error!(
61 "failed to create ledger parent directory '{}': {}",
62 parent.display(),
63 e
64 );
65 LedgerError::Io
66 })?;
67 }
68 tracing::info!("file ledger adapter initialized at: {}", path.display());
69 Ok(Self { path })
70 }
71}
72
73impl LedgerAdapter for FileLedgerAdapter {
74 fn submit(&self, record: &LedgerRecord) -> Result<(), LedgerError> {
75 let mut file = OpenOptions::new()
76 .create(true)
77 .append(true)
78 .open(&self.path)
79 .map_err(|_| LedgerError::Io)?;
80 let doc = serde_json::json!({
81 "digest": hex_digest(&record.digest),
82 "recorded_at": record.recorded_at.to_rfc3339(),
83 "metadata": record.metadata,
84 });
85 let payload = serde_json::to_vec(&doc).map_err(|_| LedgerError::Serialization)?;
86 file.write_all(&payload).map_err(|_| LedgerError::Io)?;
87 file.write_all(b"\n").map_err(|_| LedgerError::Io)?;
88 Ok(())
89 }
90}
91
92pub struct DebugLedgerAdapter;
93
94impl LedgerAdapter for DebugLedgerAdapter {
95 fn submit(&self, record: &LedgerRecord) -> Result<(), LedgerError> {
96 info!(target: "commucat::ledger", digest = %hex_digest(&record.digest), "ledger debug submission");
97 Ok(())
98 }
99}
100
101fn hex_digest(bytes: &[u8; 32]) -> String {
102 let mut output = String::with_capacity(64);
103 for byte in bytes.iter() {
104 let hi = byte >> 4;
105 let lo = byte & 0x0f;
106 output.push(nibble(hi));
107 output.push(nibble(lo));
108 }
109 output
110}
111
112fn nibble(value: u8) -> char {
113 match value {
114 0..=9 => char::from(b'0' + value),
115 10..=15 => char::from(b'a' + (value - 10)),
116 _ => '0',
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn hex_digest_format() {
126 let digest = [0u8; 32];
127 let hex = hex_digest(&digest);
128 assert_eq!(hex.len(), 64);
129 assert!(hex.chars().all(|c| c == '0'));
130 }
131}