commucat_ledger/
lib.rs

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
34/// Persists digest material into upstream ledgers.
35pub 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    /// Creates a file based ledger adapter storing newline-delimited JSON.
53    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}