Skip to main content

lean_ctx/core/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_FINDINGS: usize = 20;
6const MAX_DECISIONS: usize = 10;
7const MAX_FILES: usize = 50;
8#[allow(dead_code)]
9const MAX_PROGRESS: usize = 30;
10#[allow(dead_code)]
11const MAX_NEXT_STEPS: usize = 10;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
15pub struct SessionState {
16    pub id: String,
17    pub version: u32,
18    pub started_at: DateTime<Utc>,
19    pub updated_at: DateTime<Utc>,
20    pub project_root: Option<String>,
21    pub task: Option<TaskInfo>,
22    pub findings: Vec<Finding>,
23    pub decisions: Vec<Decision>,
24    pub files_touched: Vec<FileTouched>,
25    pub test_results: Option<TestSnapshot>,
26    pub progress: Vec<ProgressEntry>,
27    pub next_steps: Vec<String>,
28    pub stats: SessionStats,
29}
30
31#[derive(Serialize, Deserialize, Clone, Debug)]
32pub struct TaskInfo {
33    pub description: String,
34    pub intent: Option<String>,
35    pub progress_pct: Option<u8>,
36}
37
38#[derive(Serialize, Deserialize, Clone, Debug)]
39pub struct Finding {
40    pub file: Option<String>,
41    pub line: Option<u32>,
42    pub summary: String,
43    pub timestamp: DateTime<Utc>,
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug)]
47pub struct Decision {
48    pub summary: String,
49    pub rationale: Option<String>,
50    pub timestamp: DateTime<Utc>,
51}
52
53#[derive(Serialize, Deserialize, Clone, Debug)]
54pub struct FileTouched {
55    pub path: String,
56    pub file_ref: Option<String>,
57    pub read_count: u32,
58    pub modified: bool,
59    pub last_mode: String,
60    pub tokens: usize,
61}
62
63#[derive(Serialize, Deserialize, Clone, Debug)]
64pub struct TestSnapshot {
65    pub command: String,
66    pub passed: u32,
67    pub failed: u32,
68    pub total: u32,
69    pub timestamp: DateTime<Utc>,
70}
71
72#[derive(Serialize, Deserialize, Clone, Debug)]
73pub struct ProgressEntry {
74    pub action: String,
75    pub detail: Option<String>,
76    pub timestamp: DateTime<Utc>,
77}
78
79#[derive(Serialize, Deserialize, Clone, Debug, Default)]
80pub struct SessionStats {
81    pub total_tool_calls: u32,
82    pub total_tokens_saved: u64,
83    pub total_tokens_input: u64,
84    pub cache_hits: u32,
85    pub files_read: u32,
86    pub commands_run: u32,
87    pub unsaved_changes: u32,
88}
89
90#[derive(Serialize, Deserialize, Clone, Debug)]
91struct LatestPointer {
92    id: String,
93}
94
95impl Default for SessionState {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl SessionState {
102    pub fn new() -> Self {
103        let now = Utc::now();
104        Self {
105            id: generate_session_id(),
106            version: 0,
107            started_at: now,
108            updated_at: now,
109            project_root: None,
110            task: None,
111            findings: Vec::new(),
112            decisions: Vec::new(),
113            files_touched: Vec::new(),
114            test_results: None,
115            progress: Vec::new(),
116            next_steps: Vec::new(),
117            stats: SessionStats::default(),
118        }
119    }
120
121    pub fn increment(&mut self) {
122        self.version += 1;
123        self.updated_at = Utc::now();
124        self.stats.unsaved_changes += 1;
125    }
126
127    pub fn should_save(&self) -> bool {
128        self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
129    }
130
131    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
132        self.task = Some(TaskInfo {
133            description: description.to_string(),
134            intent: intent.map(|s| s.to_string()),
135            progress_pct: None,
136        });
137        self.increment();
138    }
139
140    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
141        self.findings.push(Finding {
142            file: file.map(|s| s.to_string()),
143            line,
144            summary: summary.to_string(),
145            timestamp: Utc::now(),
146        });
147        while self.findings.len() > MAX_FINDINGS {
148            self.findings.remove(0);
149        }
150        self.increment();
151    }
152
153    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
154        self.decisions.push(Decision {
155            summary: summary.to_string(),
156            rationale: rationale.map(|s| s.to_string()),
157            timestamp: Utc::now(),
158        });
159        while self.decisions.len() > MAX_DECISIONS {
160            self.decisions.remove(0);
161        }
162        self.increment();
163    }
164
165    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
166        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
167            existing.read_count += 1;
168            existing.last_mode = mode.to_string();
169            existing.tokens = tokens;
170            if let Some(r) = file_ref {
171                existing.file_ref = Some(r.to_string());
172            }
173        } else {
174            self.files_touched.push(FileTouched {
175                path: path.to_string(),
176                file_ref: file_ref.map(|s| s.to_string()),
177                read_count: 1,
178                modified: false,
179                last_mode: mode.to_string(),
180                tokens,
181            });
182            while self.files_touched.len() > MAX_FILES {
183                self.files_touched.remove(0);
184            }
185        }
186        self.stats.files_read += 1;
187        self.increment();
188    }
189
190    pub fn mark_modified(&mut self, path: &str) {
191        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
192            existing.modified = true;
193        }
194        self.increment();
195    }
196
197    #[allow(dead_code)]
198    pub fn set_test_results(&mut self, command: &str, passed: u32, failed: u32, total: u32) {
199        self.test_results = Some(TestSnapshot {
200            command: command.to_string(),
201            passed,
202            failed,
203            total,
204            timestamp: Utc::now(),
205        });
206        self.increment();
207    }
208
209    #[allow(dead_code)]
210    pub fn add_progress(&mut self, action: &str, detail: Option<&str>) {
211        self.progress.push(ProgressEntry {
212            action: action.to_string(),
213            detail: detail.map(|s| s.to_string()),
214            timestamp: Utc::now(),
215        });
216        while self.progress.len() > MAX_PROGRESS {
217            self.progress.remove(0);
218        }
219        self.increment();
220    }
221
222    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
223        self.stats.total_tool_calls += 1;
224        self.stats.total_tokens_saved += tokens_saved;
225        self.stats.total_tokens_input += tokens_input;
226    }
227
228    pub fn record_cache_hit(&mut self) {
229        self.stats.cache_hits += 1;
230    }
231
232    pub fn record_command(&mut self) {
233        self.stats.commands_run += 1;
234    }
235
236    pub fn format_compact(&self) -> String {
237        let duration = self.updated_at - self.started_at;
238        let hours = duration.num_hours();
239        let mins = duration.num_minutes() % 60;
240        let duration_str = if hours > 0 {
241            format!("{hours}h {mins}m")
242        } else {
243            format!("{mins}m")
244        };
245
246        let mut lines = Vec::new();
247        lines.push(format!(
248            "SESSION v{} | {} | {} calls | {} tok saved",
249            self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
250        ));
251
252        if let Some(ref task) = self.task {
253            let pct = task
254                .progress_pct
255                .map_or(String::new(), |p| format!(" [{p}%]"));
256            lines.push(format!("Task: {}{pct}", task.description));
257        }
258
259        if let Some(ref root) = self.project_root {
260            lines.push(format!("Root: {}", shorten_path(root)));
261        }
262
263        if !self.findings.is_empty() {
264            let items: Vec<String> = self
265                .findings
266                .iter()
267                .rev()
268                .take(5)
269                .map(|f| {
270                    let loc = match (&f.file, f.line) {
271                        (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
272                        (Some(file), None) => shorten_path(file),
273                        _ => String::new(),
274                    };
275                    if loc.is_empty() {
276                        f.summary.clone()
277                    } else {
278                        format!("{loc} \u{2014} {}", f.summary)
279                    }
280                })
281                .collect();
282            lines.push(format!(
283                "Findings ({}): {}",
284                self.findings.len(),
285                items.join(" | ")
286            ));
287        }
288
289        if !self.decisions.is_empty() {
290            let items: Vec<&str> = self
291                .decisions
292                .iter()
293                .rev()
294                .take(3)
295                .map(|d| d.summary.as_str())
296                .collect();
297            lines.push(format!("Decisions: {}", items.join(" | ")));
298        }
299
300        if !self.files_touched.is_empty() {
301            let items: Vec<String> = self
302                .files_touched
303                .iter()
304                .rev()
305                .take(10)
306                .map(|f| {
307                    let status = if f.modified { "mod" } else { &f.last_mode };
308                    let r = f.file_ref.as_deref().unwrap_or("?");
309                    format!("[{r} {} {status}]", shorten_path(&f.path))
310                })
311                .collect();
312            lines.push(format!(
313                "Files ({}): {}",
314                self.files_touched.len(),
315                items.join(" ")
316            ));
317        }
318
319        if let Some(ref tests) = self.test_results {
320            lines.push(format!(
321                "Tests: {}/{} pass ({})",
322                tests.passed, tests.total, tests.command
323            ));
324        }
325
326        if !self.next_steps.is_empty() {
327            lines.push(format!("Next: {}", self.next_steps.join(" | ")));
328        }
329
330        lines.join("\n")
331    }
332
333    pub fn save(&mut self) -> Result<(), String> {
334        let dir = sessions_dir().ok_or("cannot determine home directory")?;
335        if !dir.exists() {
336            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
337        }
338
339        let path = dir.join(format!("{}.json", self.id));
340        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
341
342        let tmp = dir.join(format!(".{}.json.tmp", self.id));
343        std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
344        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
345
346        let pointer = LatestPointer {
347            id: self.id.clone(),
348        };
349        let pointer_json = serde_json::to_string(&pointer).map_err(|e| e.to_string())?;
350        let latest_path = dir.join("latest.json");
351        let latest_tmp = dir.join(".latest.json.tmp");
352        std::fs::write(&latest_tmp, &pointer_json).map_err(|e| e.to_string())?;
353        std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
354
355        self.stats.unsaved_changes = 0;
356        Ok(())
357    }
358
359    pub fn load_latest() -> Option<Self> {
360        let dir = sessions_dir()?;
361        let latest_path = dir.join("latest.json");
362        let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
363        let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
364        Self::load_by_id(&pointer.id)
365    }
366
367    pub fn load_by_id(id: &str) -> Option<Self> {
368        let dir = sessions_dir()?;
369        let path = dir.join(format!("{id}.json"));
370        let json = std::fs::read_to_string(&path).ok()?;
371        serde_json::from_str(&json).ok()
372    }
373
374    pub fn list_sessions() -> Vec<SessionSummary> {
375        let dir = match sessions_dir() {
376            Some(d) => d,
377            None => return Vec::new(),
378        };
379
380        let mut summaries = Vec::new();
381        if let Ok(entries) = std::fs::read_dir(&dir) {
382            for entry in entries.flatten() {
383                let path = entry.path();
384                if path.extension().and_then(|e| e.to_str()) != Some("json") {
385                    continue;
386                }
387                if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
388                    continue;
389                }
390                if let Ok(json) = std::fs::read_to_string(&path) {
391                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
392                        summaries.push(SessionSummary {
393                            id: session.id,
394                            started_at: session.started_at,
395                            updated_at: session.updated_at,
396                            version: session.version,
397                            task: session.task.as_ref().map(|t| t.description.clone()),
398                            tool_calls: session.stats.total_tool_calls,
399                            tokens_saved: session.stats.total_tokens_saved,
400                        });
401                    }
402                }
403            }
404        }
405
406        summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
407        summaries
408    }
409
410    pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
411        let dir = match sessions_dir() {
412            Some(d) => d,
413            None => return 0,
414        };
415
416        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
417        let latest = Self::load_latest().map(|s| s.id);
418        let mut removed = 0u32;
419
420        if let Ok(entries) = std::fs::read_dir(&dir) {
421            for entry in entries.flatten() {
422                let path = entry.path();
423                if path.extension().and_then(|e| e.to_str()) != Some("json") {
424                    continue;
425                }
426                let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
427                if filename == "latest" || filename.starts_with('.') {
428                    continue;
429                }
430                if latest.as_deref() == Some(filename) {
431                    continue;
432                }
433                if let Ok(json) = std::fs::read_to_string(&path) {
434                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
435                        if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
436                            removed += 1;
437                        }
438                    }
439                }
440            }
441        }
442
443        removed
444    }
445}
446
447#[derive(Debug, Clone)]
448#[allow(dead_code)]
449pub struct SessionSummary {
450    pub id: String,
451    pub started_at: DateTime<Utc>,
452    pub updated_at: DateTime<Utc>,
453    pub version: u32,
454    pub task: Option<String>,
455    pub tool_calls: u32,
456    pub tokens_saved: u64,
457}
458
459fn sessions_dir() -> Option<PathBuf> {
460    dirs::home_dir().map(|h| h.join(".lean-ctx").join("sessions"))
461}
462
463fn generate_session_id() -> String {
464    let now = Utc::now();
465    let ts = now.format("%Y%m%d-%H%M%S").to_string();
466    let random: u32 = (std::time::SystemTime::now()
467        .duration_since(std::time::UNIX_EPOCH)
468        .unwrap_or_default()
469        .subsec_nanos())
470        % 10000;
471    format!("{ts}-{random:04}")
472}
473
474fn shorten_path(path: &str) -> String {
475    let parts: Vec<&str> = path.split('/').collect();
476    if parts.len() <= 2 {
477        return path.to_string();
478    }
479    let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
480    format!("…/{}/{}", last_two[1], last_two[0])
481}