Skip to main content

lean_ctx/core/context_kernel/
shadow.rs

1//! Shadow logging for Context Kernel plans and receipts.
2
3use std::fs;
4use std::path::PathBuf;
5
6use super::types::{ContextPlanV1, ContextReceiptV1};
7
8/// Persists kernel artifacts for debugging and observability.
9pub struct ShadowLogger {
10    log_dir: PathBuf,
11    max_entries: usize,
12}
13
14impl ShadowLogger {
15    /// Creates a shadow logger with the supplied storage limit.
16    pub fn new(log_dir: PathBuf, max_entries: usize) -> Self {
17        Self {
18            log_dir,
19            max_entries,
20        }
21    }
22
23    /// Creates the default per-user kernel shadow logger.
24    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    /// Writes a context plan as pretty-printed JSON.
33    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    /// Writes a context receipt as pretty-printed JSON.
46    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
84/// Logs a plan using the default project logger.
85pub 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}