1use chrono::TimeZone;
2use serde::Serialize;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6static PROOFS_COLLECTED: AtomicU64 = AtomicU64::new(0);
7static PROOFS_WRITTEN: AtomicU64 = AtomicU64::new(0);
8static LAST_WRITTEN_UNIX_MS: AtomicU64 = AtomicU64::new(0);
9
10#[derive(Debug, Clone, Serialize)]
11pub struct ProofStatsSnapshot {
12 pub collected: u64,
13 pub written: u64,
14 pub last_written_at: Option<String>,
15}
16
17pub fn proof_stats_snapshot() -> ProofStatsSnapshot {
18 let collected = PROOFS_COLLECTED.load(Ordering::Relaxed);
19 let written = PROOFS_WRITTEN.load(Ordering::Relaxed);
20 let last_ms = LAST_WRITTEN_UNIX_MS.load(Ordering::Relaxed);
21 let last_written_at = if last_ms == 0 {
22 None
23 } else {
24 chrono::Utc
25 .timestamp_millis_opt(last_ms as i64)
26 .single()
27 .map(|t| t.to_rfc3339())
28 };
29 ProofStatsSnapshot {
30 collected,
31 written,
32 last_written_at,
33 }
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ContextProofV1 {
38 pub schema_version: u32,
39 pub created_at: String,
40 pub lean_ctx_version: String,
41 pub session_id: Option<String>,
42 pub project: ProjectIdentity,
43 pub role: RoleIdentity,
44 pub profile: ProfileIdentity,
45 pub budgets: crate::core::budget_tracker::BudgetSnapshot,
46 pub slo: crate::core::slo::SloSnapshot,
47 pub pipeline: crate::core::pipeline::PipelineStats,
48 pub verification: crate::core::output_verification::VerificationSnapshot,
49 pub ledger: LedgerSummary,
50 pub evidence: Vec<EvidenceReceipt>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct ProjectIdentity {
55 pub project_root_hash: Option<String>,
56 pub project_identity_hash: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct RoleIdentity {
61 pub name: String,
62 pub policy_md5: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct ProfileIdentity {
67 pub name: String,
68 pub policy_md5: String,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct LedgerSummary {
73 pub window_size: usize,
74 pub entries: usize,
75 pub total_tokens_sent: usize,
76 pub total_tokens_saved: usize,
77 pub compression_ratio: f64,
78 pub top_files_by_sent_tokens: Vec<LedgerFileSummary>,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct LedgerFileSummary {
83 pub path: String,
84 pub mode: String,
85 pub sent_tokens: usize,
86 pub original_tokens: usize,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct EvidenceReceipt {
91 pub tool: Option<String>,
92 pub input_md5: Option<String>,
93 pub output_md5: Option<String>,
94 pub agent_id: Option<String>,
95 pub client_name: Option<String>,
96 pub timestamp: String,
97}
98
99#[derive(Debug, Clone, Copy, Default)]
100pub struct ProofOptions {
101 pub max_evidence: usize,
102 pub max_ledger_files: usize,
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct ProofSources {
107 pub project_root: Option<String>,
108 pub session: Option<crate::core::session::SessionState>,
109 pub pipeline: Option<crate::core::pipeline::PipelineStats>,
110 pub ledger: Option<crate::core::context_ledger::ContextLedger>,
111}
112
113pub fn collect_v1(sources: ProofSources, opts: ProofOptions) -> ContextProofV1 {
114 PROOFS_COLLECTED.fetch_add(1, Ordering::Relaxed);
115 let created_at = chrono::Utc::now().to_rfc3339();
116
117 let role_name = crate::core::roles::active_role_name();
118 let role = crate::core::roles::active_role();
119
120 let profile_name = crate::core::profiles::active_profile_name();
121 let profile = crate::core::profiles::active_profile();
122
123 let role_policy_md5 = md5_hex(&serde_json::to_string(&role).unwrap_or_default());
124 let profile_policy_md5 = md5_hex(&serde_json::to_string(&profile).unwrap_or_default());
125
126 let project_root = sources.project_root.clone().or_else(|| {
127 sources
128 .session
129 .as_ref()
130 .and_then(|s| s.project_root.clone())
131 });
132
133 let (project_root_hash, project_identity_hash) = if let Some(ref root) = project_root {
134 let root_hash = crate::core::project_hash::hash_project_root(root);
135 let identity = crate::core::project_hash::project_identity(root);
136 let identity_hash = identity.as_deref().map(md5_hex);
137 (Some(root_hash), identity_hash)
138 } else {
139 (None, None)
140 };
141
142 let budgets = crate::core::budget_tracker::BudgetTracker::global().check();
143 let slo = crate::core::slo::evaluate_quiet();
144 let verification = crate::core::output_verification::stats_snapshot();
145
146 let pipeline = sources
147 .pipeline
148 .unwrap_or_else(crate::core::pipeline::PipelineStats::load);
149
150 let ledger = sources
151 .ledger
152 .unwrap_or_else(crate::core::context_ledger::ContextLedger::load);
153
154 let top_files = ledger
155 .files_by_token_cost()
156 .into_iter()
157 .take(opts.max_ledger_files.max(1))
158 .filter_map(|(path, _)| ledger.entries.iter().find(|e| e.path == path).cloned())
159 .map(|e| LedgerFileSummary {
160 path: e.path,
161 mode: e.mode,
162 sent_tokens: e.sent_tokens,
163 original_tokens: e.original_tokens,
164 })
165 .collect::<Vec<_>>();
166
167 let ledger_summary = LedgerSummary {
168 window_size: ledger.window_size,
169 entries: ledger.entries.len(),
170 total_tokens_sent: ledger.total_tokens_sent,
171 total_tokens_saved: ledger.total_tokens_saved,
172 compression_ratio: ledger.compression_ratio(),
173 top_files_by_sent_tokens: top_files,
174 };
175
176 let session = sources.session.or_else(|| {
177 project_root
178 .as_deref()
179 .and_then(crate::core::session::SessionState::load_latest_for_project_root)
180 .or_else(crate::core::session::SessionState::load_latest)
181 });
182
183 let (session_id, evidence) = if let Some(s) = session {
184 let mut receipts = s
185 .evidence
186 .into_iter()
187 .filter(|e| matches!(e.kind, crate::core::session::EvidenceKind::ToolCall))
188 .rev()
189 .take(opts.max_evidence.max(1))
190 .map(|e| EvidenceReceipt {
191 tool: e.tool,
192 input_md5: e.input_md5,
193 output_md5: e.output_md5,
194 agent_id: e.agent_id,
195 client_name: e.client_name,
196 timestamp: e.timestamp.to_rfc3339(),
197 })
198 .collect::<Vec<_>>();
199 receipts.reverse();
200 (Some(s.id), receipts)
201 } else {
202 (None, Vec::new())
203 };
204
205 ContextProofV1 {
206 schema_version: crate::core::contracts::CONTEXT_PROOF_V1_SCHEMA_VERSION,
207 created_at,
208 lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
209 session_id,
210 project: ProjectIdentity {
211 project_root_hash,
212 project_identity_hash,
213 },
214 role: RoleIdentity {
215 name: role_name,
216 policy_md5: role_policy_md5,
217 },
218 profile: ProfileIdentity {
219 name: profile_name,
220 policy_md5: profile_policy_md5,
221 },
222 budgets,
223 slo,
224 pipeline,
225 verification,
226 ledger: ledger_summary,
227 evidence,
228 }
229}
230
231pub fn write_project_proof(
232 project_root: &Path,
233 proof: &ContextProofV1,
234 filename: Option<&str>,
235) -> Result<PathBuf, String> {
236 let proofs_dir = project_root.join(".lean-ctx").join("proofs");
237 std::fs::create_dir_all(&proofs_dir).map_err(|e| e.to_string())?;
238
239 let ts = chrono::Utc::now().format("%Y-%m-%d_%H%M%S");
240 let name = filename.map_or_else(
241 || format!("context-proof-v1_{ts}.json"),
242 std::string::ToString::to_string,
243 );
244 let path = proofs_dir.join(name);
245
246 let json = serde_json::to_string_pretty(proof).map_err(|e| e.to_string())?;
247 let json = crate::core::redaction::redact_text(&json);
249 crate::config_io::write_atomic(&path, &json)?;
250
251 PROOFS_WRITTEN.fetch_add(1, Ordering::Relaxed);
252 let ms = std::time::SystemTime::now()
253 .duration_since(std::time::SystemTime::UNIX_EPOCH)
254 .map_or(0, |d| d.as_millis() as u64);
255 LAST_WRITTEN_UNIX_MS.store(ms, Ordering::Relaxed);
256
257 Ok(path)
258}
259
260fn md5_hex(s: &str) -> String {
261 use md5::{Digest, Md5};
262 let mut hasher = Md5::new();
263 hasher.update(s.as_bytes());
264 format!("{:x}", hasher.finalize())
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn proof_has_required_fields() {
273 let proof = collect_v1(
274 ProofSources {
275 project_root: Some(".".to_string()),
276 ..Default::default()
277 },
278 ProofOptions {
279 max_evidence: 5,
280 max_ledger_files: 3,
281 },
282 );
283 let v = serde_json::to_value(&proof).unwrap();
284 assert_eq!(v["schema_version"], 1);
285 assert!(v["created_at"].as_str().unwrap_or_default().contains('T'));
286 assert!(v["lean_ctx_version"].as_str().unwrap_or_default().len() >= 3);
287 }
288
289 #[test]
290 fn write_project_proof_creates_file() {
291 let dir = tempfile::tempdir().unwrap();
292 let proof = collect_v1(
293 ProofSources {
294 project_root: Some(dir.path().to_string_lossy().to_string()),
295 ..Default::default()
296 },
297 ProofOptions {
298 max_evidence: 3,
299 max_ledger_files: 2,
300 },
301 );
302 let path = write_project_proof(dir.path(), &proof, None).unwrap();
303 assert!(path.exists());
304 let content = std::fs::read_to_string(path).unwrap();
305 assert!(content.contains("\"schema_version\": 1"));
306 }
307}