Skip to main content

lean_ctx/core/session/
persistence.rs

1use 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    /// Writes the pre-serialized session data, latest pointer, and compaction
11    /// snapshot to disk atomically.
12    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    /// Serializes and writes the session state to disk synchronously.
36    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    /// Serialize session state while holding the lock (CPU-only), reset the
48    /// unsaved counter, and return a `PreparedSave` whose I/O can be deferred
49    /// to a background thread via `write_to_disk()`.
50    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    /// Loads the most recent session from disk.
73    ///
74    /// Prefers the session matching the current working directory's project root.
75    /// Falls back to the global `latest.json` pointer only if no project-scoped
76    /// match is found. This prevents cross-project session leakage.
77    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    /// Loads the most recent session matching a specific project root.
94    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    /// Loads a specific session from disk by its unique ID.
132    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    /// Lists all saved sessions as summaries, sorted by most recently updated.
141    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    /// Deletes sessions older than `max_age_days`, preserving the latest. Returns count removed.
177    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}