1use std::fs::File;
4use std::io::{BufRead, BufReader, ErrorKind};
5
6use serde::{Deserialize, Serialize};
7
8use super::unified_ledger::UnifiedSavingsEventV2;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct LedgerExportBundle {
13 pub schema_version: u32,
14 pub exported_at: String,
15 pub event_count: usize,
16 pub hash_chain_valid: bool,
17 pub total_saved_tokens: u64,
18 pub events: Vec<UnifiedSavingsEventV2>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct VerificationResult {
24 pub valid: bool,
25 pub event_count: usize,
26 pub chain_breaks: Vec<usize>,
27 pub total_saved_tokens: u64,
28 pub errors: Vec<String>,
29}
30
31pub fn export_unified_ledger() -> Result<LedgerExportBundle, String> {
33 let events = load_unified_events()?;
34 let hash_chain_valid = verify_hash_chain(&events);
35 let total_saved_tokens = events.iter().map(|event| event.saved_tokens).sum();
36
37 Ok(LedgerExportBundle {
38 schema_version: 2,
39 exported_at: chrono::Utc::now().to_rfc3339(),
40 event_count: events.len(),
41 hash_chain_valid,
42 total_saved_tokens,
43 events,
44 })
45}
46
47pub fn verify_export_bundle(bundle: &LedgerExportBundle) -> VerificationResult {
49 let chain_breaks = bundle
50 .events
51 .windows(2)
52 .enumerate()
53 .filter_map(|(index, window)| {
54 (window[1].prev_hash != window[0].event_hash).then_some(index + 1)
55 })
56 .collect::<Vec<_>>();
57 let mut errors = Vec::new();
58
59 let total_saved_tokens = bundle.events.iter().map(|event| event.saved_tokens).sum();
60 if total_saved_tokens != bundle.total_saved_tokens {
61 errors.push(format!(
62 "token total mismatch: header={}, computed={}",
63 bundle.total_saved_tokens, total_saved_tokens
64 ));
65 }
66
67 if bundle.events.len() != bundle.event_count {
68 errors.push(format!(
69 "event count mismatch: header={}, actual={}",
70 bundle.event_count,
71 bundle.events.len()
72 ));
73 }
74
75 VerificationResult {
76 valid: chain_breaks.is_empty() && errors.is_empty(),
77 event_count: bundle.events.len(),
78 chain_breaks,
79 total_saved_tokens,
80 errors,
81 }
82}
83
84fn load_unified_events() -> Result<Vec<UnifiedSavingsEventV2>, String> {
85 let path = crate::core::data_dir::lean_ctx_data_dir()
86 .map_err(|error| format!("failed to load unified ledger: {error}"))?
87 .join("ledger")
88 .join("unified_events.jsonl");
89 let file = match File::open(&path) {
90 Ok(file) => file,
91 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
92 Err(error) => {
93 return Err(format!(
94 "failed to load unified ledger {}: {error}",
95 path.display()
96 ));
97 }
98 };
99
100 Ok(BufReader::new(file)
101 .lines()
102 .map_while(Result::ok)
103 .filter_map(|line| serde_json::from_str(&line).ok())
104 .collect())
105}
106
107fn verify_hash_chain(events: &[UnifiedSavingsEventV2]) -> bool {
108 events
109 .windows(2)
110 .all(|window| window[1].prev_hash == window[0].event_hash)
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 fn event(prev_hash: &str, event_hash: &str, saved_tokens: u64) -> UnifiedSavingsEventV2 {
118 UnifiedSavingsEventV2 {
119 tool_name: "ctx_read".to_owned(),
120 mode: "auto".to_owned(),
121 original_tokens: saved_tokens + 10,
122 compressed_tokens: 10,
123 saved_tokens,
124 content_hash: "content".to_owned(),
125 timestamp_epoch_ms: 1,
126 prev_hash: prev_hash.to_owned(),
127 event_hash: event_hash.to_owned(),
128 intent: None,
129 outcome: None,
130 routing_decision: None,
131 agent_id: None,
132 efficiency_etpao: None,
133 attribution_id: "attribution".to_owned(),
134 trace_id: None,
135 request_id: None,
136 session_id: None,
137 quality_ref: None,
138 }
139 }
140
141 fn bundle(events: Vec<UnifiedSavingsEventV2>) -> LedgerExportBundle {
142 LedgerExportBundle {
143 schema_version: 2,
144 exported_at: "2026-01-01T00:00:00Z".to_owned(),
145 event_count: events.len(),
146 hash_chain_valid: verify_hash_chain(&events),
147 total_saved_tokens: events.iter().map(|event| event.saved_tokens).sum(),
148 events,
149 }
150 }
151
152 #[test]
153 fn test_export_empty_ledger() {
154 let _data_dir = crate::core::data_dir::isolated_data_dir();
155
156 let exported = export_unified_ledger().expect("empty ledger should export");
157
158 assert_eq!(exported.event_count, 0);
159 assert!(exported.events.is_empty());
160 assert!(exported.hash_chain_valid);
161 assert_eq!(exported.total_saved_tokens, 0);
162 }
163
164 #[test]
165 fn test_verify_valid_bundle() {
166 let exported = bundle(vec![event("", "first", 5), event("first", "second", 7)]);
167
168 let result = verify_export_bundle(&exported);
169
170 assert!(result.valid);
171 assert!(result.chain_breaks.is_empty());
172 assert_eq!(result.total_saved_tokens, 12);
173 }
174
175 #[test]
176 fn test_verify_broken_chain() {
177 let exported = bundle(vec![event("", "first", 5), event("tampered", "second", 7)]);
178
179 let result = verify_export_bundle(&exported);
180
181 assert!(!result.valid);
182 assert_eq!(result.chain_breaks, vec![1]);
183 }
184
185 #[test]
186 fn test_verify_token_mismatch() {
187 let mut exported = bundle(vec![event("", "first", 5)]);
188 exported.total_saved_tokens = 99;
189
190 let result = verify_export_bundle(&exported);
191
192 assert!(!result.valid);
193 assert_eq!(
194 result.errors[0],
195 "token total mismatch: header=99, computed=5"
196 );
197 }
198
199 #[test]
200 fn test_verify_count_mismatch() {
201 let mut exported = bundle(vec![event("", "first", 5)]);
202 exported.event_count = 2;
203
204 let result = verify_export_bundle(&exported);
205
206 assert!(!result.valid);
207 assert_eq!(result.errors[0], "event count mismatch: header=2, actual=1");
208 }
209}