lean_ctx/core/context_kernel/
shadow.rs1use std::fs;
4use std::path::PathBuf;
5
6use super::types::{ContextPlanV1, ContextReceiptV1};
7
8pub struct ShadowLogger {
10 log_dir: PathBuf,
11 max_entries: usize,
12}
13
14impl ShadowLogger {
15 pub fn new(log_dir: PathBuf, max_entries: usize) -> Self {
17 Self {
18 log_dir,
19 max_entries,
20 }
21 }
22
23 pub fn default_for_project(_project_root: &str) -> Self {
25 let dir = dirs::cache_dir()
26 .unwrap_or_else(|| PathBuf::from("/tmp"))
27 .join("lean-ctx")
28 .join("kernel");
29 Self::new(dir, 100)
30 }
31
32 pub fn log_plan(&self, plan: &ContextPlanV1) {
34 if fs::create_dir_all(&self.log_dir).is_err() {
35 return;
36 }
37 if let Ok(json) = serde_json::to_string_pretty(plan) {
38 let path = self.log_dir.join(format!("{}.plan.json", plan.plan_id));
39 if fs::write(path, json).is_ok() {
40 self.rotate();
41 }
42 }
43 }
44
45 pub fn log_receipt(&self, receipt: &ContextReceiptV1) {
47 if fs::create_dir_all(&self.log_dir).is_err() {
48 return;
49 }
50 if let Ok(json) = serde_json::to_string_pretty(receipt) {
51 let path = self
52 .log_dir
53 .join(format!("{}.receipt.json", receipt.plan_id));
54 if fs::write(path, json).is_ok() {
55 self.rotate();
56 }
57 }
58 }
59
60 fn rotate(&self) {
61 let Ok(entries) = fs::read_dir(&self.log_dir) else {
62 return;
63 };
64 let mut json_files: Vec<(std::time::SystemTime, PathBuf)> = entries
65 .filter_map(|entry| {
66 let entry = entry.ok()?;
67 let path = entry.path();
68 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
69 return None;
70 }
71 let modified = entry.metadata().ok()?.modified().ok()?;
72 Some((modified, path))
73 })
74 .collect();
75 json_files.sort_by_key(|(modified, path)| (*modified, path.clone()));
76
77 let remove_count = json_files.len().saturating_sub(self.max_entries);
78 for (_, path) in json_files.into_iter().take(remove_count) {
79 let _ = fs::remove_file(path);
80 }
81 }
82}
83
84pub fn log_kernel_event(project_root: &str, plan: &ContextPlanV1) {
86 let logger = ShadowLogger::default_for_project(project_root);
87 logger.log_plan(plan);
88}
89
90#[cfg(test)]
91mod tests {
92 use std::fs;
93
94 use tempfile::TempDir;
95
96 use super::super::types::ContextPlanV1;
97 use super::ShadowLogger;
98 use crate::core::context_field::TokenBudget;
99
100 fn plan_with_id(plan_id: &str) -> ContextPlanV1 {
101 let mut plan = ContextPlanV1::empty(
102 "shadow logging",
103 TokenBudget {
104 total: 100,
105 used: 0,
106 },
107 );
108 plan.plan_id = plan_id.to_owned();
109 plan
110 }
111
112 #[test]
113 fn log_plan_creates_json_file() {
114 let temp_dir = TempDir::new().expect("create temporary directory");
115 let logger = ShadowLogger::new(temp_dir.path().to_path_buf(), 10);
116 let plan = plan_with_id("test-plan");
117
118 logger.log_plan(&plan);
119
120 let path = temp_dir.path().join("test-plan.plan.json");
121 assert!(path.exists());
122 let contents = fs::read_to_string(path).expect("read shadow plan");
123 let parsed: ContextPlanV1 =
124 serde_json::from_str(&contents).expect("parse shadow plan JSON");
125 assert_eq!(parsed.plan_id, plan.plan_id);
126 }
127
128 #[test]
129 fn rotation_enforces_max_entries() {
130 let temp_dir = TempDir::new().expect("create temporary directory");
131 let logger = ShadowLogger::new(temp_dir.path().to_path_buf(), 3);
132
133 for index in 0..5 {
134 logger.log_plan(&plan_with_id(&format!("plan-{index}")));
135 }
136
137 let json_count = fs::read_dir(temp_dir.path())
138 .expect("list shadow logs")
139 .filter_map(Result::ok)
140 .filter(|entry| {
141 entry.path().extension().and_then(|value| value.to_str()) == Some("json")
142 })
143 .count();
144 assert_eq!(json_count, 3);
145 }
146}