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    #[test]
58    fn delete_session_removes_file_snapshot_and_latest_pointer() {
59        let _data = crate::core::data_dir::isolated_data_dir();
60        let mut session = SessionState::new();
61        session.id = "delete-me".to_string();
62        session.save().unwrap();
63
64        let dir = sessions_dir().unwrap();
65        let path = dir.join("delete-me.json");
66        let snapshot = dir.join("delete-me_snapshot.txt");
67        let latest = dir.join("latest.json");
68        std::fs::write(&snapshot, "snapshot").unwrap();
69        assert!(path.exists());
70        assert!(snapshot.exists());
71        assert!(latest.exists());
72
73        assert!(SessionState::delete_session("delete-me").unwrap());
74
75        assert!(!path.exists());
76        assert!(!snapshot.exists());
77        assert!(!latest.exists());
78        assert!(SessionState::list_sessions().is_empty());
79    }
80
81    #[test]
82    fn delete_latest_session_repoints_latest_to_newest_remaining() {
83        let _data = crate::core::data_dir::isolated_data_dir();
84        let mut older = SessionState::new();
85        older.id = "older".to_string();
86        older.updated_at = Utc::now() - Duration::days(1);
87        older.save().unwrap();
88
89        let mut newer = SessionState::new();
90        newer.id = "newer".to_string();
91        newer.updated_at = Utc::now();
92        newer.save().unwrap();
93        assert_eq!(
94            SessionState::load_global_latest_pointer().unwrap().id,
95            "newer"
96        );
97
98        assert!(SessionState::delete_session("newer").unwrap());
99
100        let latest = SessionState::load_global_latest_pointer().unwrap();
101        assert_eq!(latest.id, "older");
102        assert_eq!(SessionState::list_sessions().len(), 1);
103    }
104
105    #[test]
106    fn delete_session_rejects_path_traversal_id() {
107        let data = crate::core::data_dir::isolated_data_dir();
108        let outside = data.path().join("outside.json");
109        std::fs::write(&outside, "{}").unwrap();
110
111        let err = SessionState::delete_session("../outside").unwrap_err();
112
113        assert_eq!(err, "invalid session id");
114        assert!(outside.exists());
115    }
116
117    #[test]
118    fn extract_cd_absolute_path() {
119        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
120        assert_eq!(result, Some("/usr/local/bin".to_string()));
121    }
122
123    #[test]
124    fn extract_cd_relative_path() {
125        let result = extract_cd_target("cd subdir", "/home/user");
126        assert_eq!(result, Some("/home/user/subdir".to_string()));
127    }
128
129    #[test]
130    fn extract_cd_with_chained_command() {
131        let result = extract_cd_target("cd /tmp && ls", "/home/user");
132        assert_eq!(result, Some("/tmp".to_string()));
133    }
134
135    #[test]
136    fn extract_cd_with_semicolon() {
137        let result = extract_cd_target("cd /tmp; ls", "/home/user");
138        assert_eq!(result, Some("/tmp".to_string()));
139    }
140
141    #[test]
142    fn extract_cd_parent_dir() {
143        let result = extract_cd_target("cd ..", "/home/user/project");
144        assert_eq!(result, Some("/home/user/project/..".to_string()));
145    }
146
147    #[test]
148    fn extract_cd_no_cd_returns_none() {
149        let result = extract_cd_target("ls -la", "/home/user");
150        assert!(result.is_none());
151    }
152
153    #[test]
154    fn extract_cd_bare_cd_goes_home() {
155        let result = extract_cd_target("cd", "/home/user");
156        assert!(result.is_some());
157    }
158
159    #[test]
160    fn effective_cwd_explicit_takes_priority() {
161        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
162        let sub = tmp.join("sub");
163        let _ = std::fs::create_dir_all(&sub);
164        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
165            .to_string_lossy()
166            .to_string();
167        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
168            .to_string_lossy()
169            .to_string();
170
171        let mut session = SessionState::new();
172        session.project_root = Some(root_canon);
173        let result = session.effective_cwd(Some(&sub_canon));
174        assert_eq!(result, sub_canon);
175        let _ = std::fs::remove_dir_all(&tmp);
176    }
177
178    #[cfg(not(feature = "no-jail"))]
179    #[test]
180    fn effective_cwd_explicit_outside_root_is_jailed() {
181        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
182        let _ = std::fs::create_dir_all(&tmp);
183        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
184            .to_string_lossy()
185            .to_string();
186
187        let mut session = SessionState::new();
188        session.project_root = Some(root_canon.clone());
189        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
190        assert_eq!(result, root_canon);
191        let _ = std::fs::remove_dir_all(&tmp);
192    }
193
194    /// The checked variant must report *why* a jailed cwd was rejected, so
195    /// `ctx_shell` can surface it instead of silently swapping in the root (#629).
196    #[cfg(not(feature = "no-jail"))]
197    #[test]
198    fn effective_cwd_checked_reports_jail_rejection_reason() {
199        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
200        let _ = std::fs::create_dir_all(&tmp);
201        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
202            .to_string_lossy()
203            .to_string();
204
205        let mut session = SessionState::new();
206        session.project_root = Some(root_canon.clone());
207
208        // Rejected: falls back to the root AND surfaces a non-empty reason.
209        let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
210        assert_eq!(path, root_canon);
211        let reason = reason.expect("a jailed cwd must report a rejection reason");
212        assert!(!reason.is_empty(), "the rejection reason must not be empty");
213
214        // Accepted (no explicit cwd): no reason, path is the project root.
215        let (accepted, none_reason) = session.effective_cwd_checked(None);
216        assert_eq!(accepted, root_canon);
217        assert!(
218            none_reason.is_none(),
219            "a non-jailed path must not report a reason"
220        );
221
222        let _ = std::fs::remove_dir_all(&tmp);
223    }
224
225    #[test]
226    fn effective_cwd_shell_cwd_second_priority() {
227        let mut session = SessionState::new();
228        session.project_root = Some("/project".to_string());
229        session.shell_cwd = Some("/project/src".to_string());
230        assert_eq!(session.effective_cwd(None), "/project/src");
231    }
232
233    #[test]
234    fn effective_cwd_project_root_third_priority() {
235        let mut session = SessionState::new();
236        session.project_root = Some("/project".to_string());
237        assert_eq!(session.effective_cwd(None), "/project");
238    }
239
240    #[test]
241    fn effective_cwd_dot_ignored() {
242        let mut session = SessionState::new();
243        session.project_root = Some("/project".to_string());
244        assert_eq!(session.effective_cwd(Some(".")), "/project");
245    }
246
247    #[test]
248    fn compaction_snapshot_includes_compression_config_when_enabled() {
249        let mut session = SessionState::new();
250        session.compression_level = "standard".to_string();
251        session.terse_mode = true;
252        session.set_task("x", None);
253        let snapshot = session.build_compaction_snapshot();
254        assert!(snapshot.contains("<config compression=\"standard\" />"));
255    }
256
257    #[test]
258    fn resume_block_prefixes_compression_hint_when_enabled() {
259        let mut session = SessionState::new();
260        session.compression_level = "lite".to_string();
261        session.terse_mode = true;
262        let block = session.build_resume_block();
263        assert!(block.contains("[COMPRESSION: lite]"));
264    }
265
266    #[test]
267    fn compaction_snapshot_includes_task() {
268        let mut session = SessionState::new();
269        session.set_task("fix auth bug", None);
270        let snapshot = session.build_compaction_snapshot();
271        assert!(snapshot.contains("<task>fix auth bug</task>"));
272        assert!(snapshot.contains("<session_snapshot>"));
273        assert!(snapshot.contains("</session_snapshot>"));
274    }
275
276    #[test]
277    fn compaction_snapshot_includes_files() {
278        let mut session = SessionState::new();
279        session.touch_file("src/auth.rs", None, "full", 500);
280        session.files_touched[0].modified = true;
281        session.touch_file("src/main.rs", None, "map", 100);
282        let snapshot = session.build_compaction_snapshot();
283        assert!(snapshot.contains("auth.rs"));
284        assert!(snapshot.contains("<files>"));
285    }
286
287    #[test]
288    fn compaction_snapshot_includes_decisions() {
289        let mut session = SessionState::new();
290        session.add_decision("Use JWT RS256", None);
291        let snapshot = session.build_compaction_snapshot();
292        assert!(snapshot.contains("JWT RS256"));
293        assert!(snapshot.contains("<decisions>"));
294    }
295
296    #[test]
297    fn compaction_snapshot_respects_size_limit() {
298        let mut session = SessionState::new();
299        session.set_task("a]task", None);
300        for i in 0..100 {
301            session.add_finding(
302                Some(&format!("file{i}.rs")),
303                Some(i),
304                &format!("Finding number {i} with some detail text here"),
305            );
306        }
307        let snapshot = session.build_compaction_snapshot();
308        assert!(snapshot.len() <= 2200);
309    }
310
311    #[test]
312    fn compaction_snapshot_includes_stats() {
313        let mut session = SessionState::new();
314        session.stats.total_tool_calls = 42;
315        session.stats.total_tokens_saved = 10000;
316        let snapshot = session.build_compaction_snapshot();
317        assert!(snapshot.contains("calls=42"));
318        assert!(snapshot.contains("saved=10000"));
319    }
320}