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}