lean_ctx/core/session/
persistence.rs1use chrono::Utc;
2
3use super::heuristics::{normalize_loaded_session, session_matches_project_root};
4use super::paths::sessions_dir;
5use super::state::BATCH_SAVE_INTERVAL;
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9impl PreparedSave {
10 pub fn write_to_disk(self) -> Result<(), String> {
13 if !self.dir.exists() {
14 std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
15 }
16 let path = self.dir.join(format!("{}.json", self.id));
17 let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
18 std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
19 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
20
21 let latest_path = self.dir.join("latest.json");
22 let latest_tmp = self.dir.join(".latest.json.tmp");
23 std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
24 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
25
26 if let Some(snapshot) = self.compaction_snapshot {
27 let snap_path = self.dir.join(format!("{}_snapshot.txt", self.id));
28 let _ = std::fs::write(&snap_path, &snapshot);
29 }
30 Ok(())
31 }
32}
33
34impl SessionState {
35 pub fn save(&mut self) -> Result<(), String> {
37 let prepared = self.prepare_save()?;
38 match prepared.write_to_disk() {
39 Ok(()) => Ok(()),
40 Err(e) => {
41 self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
42 Err(e)
43 }
44 }
45 }
46
47 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
51 let dir = sessions_dir().ok_or("cannot determine home directory")?;
52 let compaction_snapshot = if self.stats.total_tool_calls > 0 {
53 Some(self.build_compaction_snapshot())
54 } else {
55 None
56 };
57 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
58 let pointer_json = serde_json::to_string(&LatestPointer {
59 id: self.id.clone(),
60 })
61 .map_err(|e| e.to_string())?;
62 self.stats.unsaved_changes = 0;
63 Ok(PreparedSave {
64 dir,
65 id: self.id.clone(),
66 json,
67 pointer_json,
68 compaction_snapshot,
69 })
70 }
71
72 pub fn load_latest() -> Option<Self> {
78 if let Some(project_root) = std::env::current_dir()
79 .ok()
80 .map(|p| p.to_string_lossy().to_string())
81 {
82 if let Some(session) = Self::load_latest_for_project_root(&project_root) {
83 return Some(session);
84 }
85 }
86 let dir = sessions_dir()?;
87 let latest_path = dir.join("latest.json");
88 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
89 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
90 Self::load_by_id(&pointer.id)
91 }
92
93 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
95 let dir = sessions_dir()?;
96 let target_root =
97 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
98 let mut latest_match: Option<Self> = None;
99
100 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
101 let path = entry.path();
102 if path.extension().and_then(|e| e.to_str()) != Some("json") {
103 continue;
104 }
105 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
106 continue;
107 }
108
109 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
110 continue;
111 };
112 let Some(session) = Self::load_by_id(id) else {
113 continue;
114 };
115
116 if !session_matches_project_root(&session, &target_root) {
117 continue;
118 }
119
120 if latest_match
121 .as_ref()
122 .is_none_or(|existing| session.updated_at > existing.updated_at)
123 {
124 latest_match = Some(session);
125 }
126 }
127
128 latest_match
129 }
130
131 pub fn load_by_id(id: &str) -> Option<Self> {
133 let dir = sessions_dir()?;
134 let path = dir.join(format!("{id}.json"));
135 let json = std::fs::read_to_string(&path).ok()?;
136 let session: Self = serde_json::from_str(&json).ok()?;
137 Some(normalize_loaded_session(session))
138 }
139
140 pub fn list_sessions() -> Vec<SessionSummary> {
142 let Some(dir) = sessions_dir() else {
143 return Vec::new();
144 };
145
146 let mut summaries = Vec::new();
147 if let Ok(entries) = std::fs::read_dir(&dir) {
148 for entry in entries.flatten() {
149 let path = entry.path();
150 if path.extension().and_then(|e| e.to_str()) != Some("json") {
151 continue;
152 }
153 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
154 continue;
155 }
156 if let Ok(json) = std::fs::read_to_string(&path) {
157 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
158 summaries.push(SessionSummary {
159 id: session.id,
160 started_at: session.started_at,
161 updated_at: session.updated_at,
162 version: session.version,
163 task: session.task.as_ref().map(|t| t.description.clone()),
164 tool_calls: session.stats.total_tool_calls,
165 tokens_saved: session.stats.total_tokens_saved,
166 });
167 }
168 }
169 }
170 }
171
172 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
173 summaries
174 }
175
176 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
178 let Some(dir) = sessions_dir() else { return 0 };
179
180 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
181 let latest = Self::load_latest().map(|s| s.id);
182 let mut removed = 0u32;
183
184 if let Ok(entries) = std::fs::read_dir(&dir) {
185 for entry in entries.flatten() {
186 let path = entry.path();
187 if path.extension().and_then(|e| e.to_str()) != Some("json") {
188 continue;
189 }
190 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
191 if filename == "latest" || filename.starts_with('.') {
192 continue;
193 }
194 if latest.as_deref() == Some(filename) {
195 continue;
196 }
197 if let Ok(json) = std::fs::read_to_string(&path) {
198 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
199 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
200 removed += 1;
201 }
202 }
203 }
204 }
205 }
206
207 removed
208 }
209}