Skip to main content

lean_ctx/core/session/
mod.rs

1mod compaction;
2mod heuristics;
3mod paths;
4mod persistence;
5pub mod playbook;
6mod state;
7mod types;
8
9pub use playbook::{DeltaOutcome, EntryKind, Playbook, PlaybookEntry};
10pub use types::{
11    Decision, EvidenceKind, EvidenceRecord, FileTouched, Finding, ManifestEntry, PreparedSave,
12    ProgressEntry, SessionState, SessionStats, SessionSummary, TaskInfo, TestSnapshot,
13};
14
15#[cfg(test)]
16mod tests {
17    use super::paths::{extract_cd_target, sessions_dir};
18    use super::types::*;
19    use chrono::{Duration, Utc};
20
21    #[test]
22    fn load_latest_for_broad_root_returns_none_without_scanning() {
23        // The daemon boots with cwd "/" and the dispatcher passes that as the
24        // project root. Broad roots must bail out before walking the session
25        // store — stat-ing persisted roots under ~/Documents from the launchd
26        // daemon pops the macOS TCC prompt (#356).
27        assert!(SessionState::load_latest_for_project_root("/").is_none());
28        if let Some(home) = dirs::home_dir() {
29            let home = home.to_string_lossy().to_string();
30            assert!(SessionState::load_latest_for_project_root(&home).is_none());
31        }
32    }
33
34    #[test]
35    #[cfg(target_os = "macos")]
36    #[serial_test::serial]
37    fn normalize_session_skips_marker_probe_for_real_roots() {
38        // A session whose project_root is a plausible real project must not be
39        // marker-probed at load time when the process is TCC-standalone: the
40        // probe itself would trip the privacy prompt (#356). The repair
41        // heuristic only ever fires for agent/temp roots.
42        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
43        let mut session = SessionState::new();
44        let docs_root = dirs::home_dir()
45            .unwrap_or_default()
46            .join("Documents/some-project")
47            .to_string_lossy()
48            .to_string();
49        session.project_root = Some(docs_root.clone());
50        session.shell_cwd = Some(docs_root.clone());
51        let normalized = super::heuristics::normalize_loaded_session(session);
52        // Root is not an agent/temp dir → kept as-is, no probe needed.
53        assert_eq!(normalized.project_root.as_deref(), Some(docs_root.as_str()));
54        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
55    }
56
57    /// #707: an explicit, jail-accepted `cwd` param must persist as the live
58    /// shell cwd — worktree switches arrive as `cwd` params, not `cd`
59    /// commands, and the divergence check in path resolution reads
60    /// `shell_cwd`. Relative or nonexistent paths are ignored.
61    #[test]
62    fn note_explicit_cwd_persists_absolute_dirs_only() {
63        let tmp = std::env::temp_dir().join(format!("lc_707_cwd_{}", std::process::id()));
64        std::fs::create_dir_all(&tmp).unwrap();
65
66        let mut session = SessionState::new();
67        session.note_explicit_cwd(&tmp.to_string_lossy());
68        let noted = session.shell_cwd.clone().expect("cwd should persist");
69        // Canonicalized (macOS /tmp → /private/tmp), so compare canonical forms.
70        assert_eq!(
71            noted,
72            crate::core::pathutil::safe_canonicalize_or_self(&tmp)
73                .to_string_lossy()
74                .to_string()
75        );
76
77        session.note_explicit_cwd("relative/dir");
78        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
79        session.note_explicit_cwd(&tmp.join("does-not-exist").to_string_lossy());
80        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
81
82        let _ = std::fs::remove_dir_all(&tmp);
83    }
84
85    #[test]
86    fn delete_session_removes_file_snapshot_and_latest_pointer() {
87        let _data = crate::core::data_dir::isolated_data_dir();
88        let mut session = SessionState::new();
89        session.id = "delete-me".to_string();
90        session.save().unwrap();
91
92        let dir = sessions_dir().unwrap();
93        let path = dir.join("delete-me.json");
94        let snapshot = dir.join("delete-me_snapshot.txt");
95        let latest = dir.join("latest.json");
96        std::fs::write(&snapshot, "snapshot").unwrap();
97        assert!(path.exists());
98        assert!(snapshot.exists());
99        assert!(latest.exists());
100
101        assert!(SessionState::delete_session("delete-me").unwrap());
102
103        assert!(!path.exists());
104        assert!(!snapshot.exists());
105        assert!(!latest.exists());
106        assert!(SessionState::list_sessions().is_empty());
107    }
108
109    #[test]
110    fn delete_latest_session_repoints_latest_to_newest_remaining() {
111        let _data = crate::core::data_dir::isolated_data_dir();
112        let mut older = SessionState::new();
113        older.id = "older".to_string();
114        older.updated_at = Utc::now() - Duration::days(1);
115        older.save().unwrap();
116
117        let mut newer = SessionState::new();
118        newer.id = "newer".to_string();
119        newer.updated_at = Utc::now();
120        newer.save().unwrap();
121        assert_eq!(
122            SessionState::load_global_latest_pointer().unwrap().id,
123            "newer"
124        );
125
126        assert!(SessionState::delete_session("newer").unwrap());
127
128        let latest = SessionState::load_global_latest_pointer().unwrap();
129        assert_eq!(latest.id, "older");
130        assert_eq!(SessionState::list_sessions().len(), 1);
131    }
132
133    #[test]
134    fn delete_session_rejects_path_traversal_id() {
135        let data = crate::core::data_dir::isolated_data_dir();
136        let outside = data.path().join("outside.json");
137        std::fs::write(&outside, "{}").unwrap();
138
139        let err = SessionState::delete_session("../outside").unwrap_err();
140
141        assert_eq!(err, "invalid session id");
142        assert!(outside.exists());
143    }
144
145    #[test]
146    fn extract_cd_absolute_path() {
147        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
148        assert_eq!(result, Some("/usr/local/bin".to_string()));
149    }
150
151    #[test]
152    fn extract_cd_relative_path() {
153        let result = extract_cd_target("cd subdir", "/home/user");
154        assert_eq!(result, Some("/home/user/subdir".to_string()));
155    }
156
157    #[test]
158    fn extract_cd_with_chained_command() {
159        let result = extract_cd_target("cd /tmp && ls", "/home/user");
160        assert_eq!(result, Some("/tmp".to_string()));
161    }
162
163    #[test]
164    fn extract_cd_with_semicolon() {
165        let result = extract_cd_target("cd /tmp; ls", "/home/user");
166        assert_eq!(result, Some("/tmp".to_string()));
167    }
168
169    #[test]
170    fn extract_cd_parent_dir() {
171        let result = extract_cd_target("cd ..", "/home/user/project");
172        assert_eq!(result, Some("/home/user/project/..".to_string()));
173    }
174
175    #[test]
176    fn extract_cd_no_cd_returns_none() {
177        let result = extract_cd_target("ls -la", "/home/user");
178        assert!(result.is_none());
179    }
180
181    #[test]
182    fn extract_cd_bare_cd_goes_home() {
183        let result = extract_cd_target("cd", "/home/user");
184        assert!(result.is_some());
185    }
186
187    #[test]
188    fn effective_cwd_explicit_takes_priority() {
189        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
190        let sub = tmp.join("sub");
191        let _ = std::fs::create_dir_all(&sub);
192        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
193            .to_string_lossy()
194            .to_string();
195        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
196            .to_string_lossy()
197            .to_string();
198
199        let mut session = SessionState::new();
200        session.project_root = Some(root_canon);
201        let result = session.effective_cwd(Some(&sub_canon));
202        assert_eq!(result, sub_canon);
203        let _ = std::fs::remove_dir_all(&tmp);
204    }
205
206    #[cfg(not(feature = "no-jail"))]
207    #[test]
208    fn effective_cwd_explicit_outside_root_is_jailed() {
209        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
210        let _ = std::fs::create_dir_all(&tmp);
211        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
212            .to_string_lossy()
213            .to_string();
214
215        let mut session = SessionState::new();
216        session.project_root = Some(root_canon.clone());
217        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
218        assert_eq!(result, root_canon);
219        let _ = std::fs::remove_dir_all(&tmp);
220    }
221
222    /// The checked variant must report *why* a jailed cwd was rejected, so
223    /// `ctx_shell` can surface it instead of silently swapping in the root (#629).
224    #[cfg(not(feature = "no-jail"))]
225    #[test]
226    fn effective_cwd_checked_reports_jail_rejection_reason() {
227        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
228        let _ = std::fs::create_dir_all(&tmp);
229        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
230            .to_string_lossy()
231            .to_string();
232
233        let mut session = SessionState::new();
234        session.project_root = Some(root_canon.clone());
235
236        // Rejected: falls back to the root AND surfaces a non-empty reason.
237        let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
238        assert_eq!(path, root_canon);
239        let reason = reason.expect("a jailed cwd must report a rejection reason");
240        assert!(!reason.is_empty(), "the rejection reason must not be empty");
241
242        // Accepted (no explicit cwd): no reason, path is the project root.
243        let (accepted, none_reason) = session.effective_cwd_checked(None);
244        assert_eq!(accepted, root_canon);
245        assert!(
246            none_reason.is_none(),
247            "a non-jailed path must not report a reason"
248        );
249
250        let _ = std::fs::remove_dir_all(&tmp);
251    }
252
253    #[test]
254    fn effective_cwd_shell_cwd_second_priority() {
255        let mut session = SessionState::new();
256        session.project_root = Some("/project".to_string());
257        session.shell_cwd = Some("/project/src".to_string());
258        assert_eq!(session.effective_cwd(None), "/project/src");
259    }
260
261    #[test]
262    fn effective_cwd_project_root_third_priority() {
263        let mut session = SessionState::new();
264        session.project_root = Some("/project".to_string());
265        assert_eq!(session.effective_cwd(None), "/project");
266    }
267
268    #[test]
269    fn effective_cwd_dot_ignored() {
270        let mut session = SessionState::new();
271        session.project_root = Some("/project".to_string());
272        assert_eq!(session.effective_cwd(Some(".")), "/project");
273    }
274
275    #[test]
276    fn compaction_snapshot_includes_compression_config_when_enabled() {
277        let mut session = SessionState::new();
278        session.compression_level = "standard".to_string();
279        session.terse_mode = true;
280        session.set_task("x", None);
281        let snapshot = session.build_compaction_snapshot();
282        assert!(snapshot.contains("<config compression=\"standard\" />"));
283    }
284
285    #[test]
286    fn resume_block_prefixes_compression_hint_when_enabled() {
287        let mut session = SessionState::new();
288        session.compression_level = "lite".to_string();
289        session.terse_mode = true;
290        let block = session.build_resume_block();
291        assert!(block.contains("[COMPRESSION: lite]"));
292    }
293
294    #[test]
295    fn compaction_snapshot_includes_task() {
296        let mut session = SessionState::new();
297        session.set_task("fix auth bug", None);
298        let snapshot = session.build_compaction_snapshot();
299        assert!(snapshot.contains("<task>fix auth bug</task>"));
300        assert!(snapshot.contains("<session_snapshot>"));
301        assert!(snapshot.contains("</session_snapshot>"));
302    }
303
304    #[test]
305    fn compaction_snapshot_includes_files() {
306        let mut session = SessionState::new();
307        session.touch_file("src/auth.rs", None, "full", 500);
308        session.files_touched[0].modified = true;
309        session.touch_file("src/main.rs", None, "map", 100);
310        let snapshot = session.build_compaction_snapshot();
311        assert!(snapshot.contains("auth.rs"));
312        assert!(snapshot.contains("<files>"));
313    }
314
315    #[test]
316    fn compaction_snapshot_includes_decisions() {
317        let mut session = SessionState::new();
318        session.add_decision("Use JWT RS256", None);
319        let snapshot = session.build_compaction_snapshot();
320        assert!(snapshot.contains("JWT RS256"));
321        assert!(snapshot.contains("<decisions>"));
322    }
323
324    #[test]
325    fn compaction_snapshot_respects_size_limit() {
326        let mut session = SessionState::new();
327        session.set_task("a]task", None);
328        for i in 0..100 {
329            session.add_finding(
330                Some(&format!("file{i}.rs")),
331                Some(i),
332                &format!("Finding number {i} with some detail text here"),
333            );
334        }
335        let snapshot = session.build_compaction_snapshot();
336        assert!(snapshot.len() <= 2200);
337    }
338
339    #[test]
340    fn compaction_snapshot_includes_stats() {
341        let mut session = SessionState::new();
342        session.stats.total_tool_calls = 42;
343        session.stats.total_tokens_saved = 10000;
344        let snapshot = session.build_compaction_snapshot();
345        assert!(snapshot.contains("calls=42"));
346        assert!(snapshot.contains("saved=10000"));
347    }
348}