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    /// #717: the 5-change batch alone left slowly-ticking sessions invisible
86    /// to the dashboard. First change on a fresh in-memory session flushes
87    /// immediately; afterwards the time-based trigger fires once a change is
88    /// older than SESSION_FLUSH_INTERVAL, and the batch threshold still wins.
89    #[test]
90    fn should_save_flushes_first_change_and_stale_changes() {
91        use super::state::SESSION_FLUSH_INTERVAL;
92        let mut session = SessionState::new();
93        assert!(!session.should_save(), "no changes → no save");
94
95        session.increment();
96        assert!(session.should_save(), "first change must flush immediately");
97
98        session.last_flush = Some(std::time::Instant::now());
99        assert!(
100            !session.should_save(),
101            "inside window + below batch → defer"
102        );
103
104        for _ in 0..4 {
105            session.increment();
106        }
107        assert!(session.should_save(), "batch threshold still applies");
108
109        session.stats.unsaved_changes = 1;
110        if let Some(old) = std::time::Instant::now().checked_sub(SESSION_FLUSH_INTERVAL) {
111            session.last_flush = Some(old);
112            assert!(session.should_save(), "stale change must trigger flush");
113        }
114    }
115
116    #[test]
117    fn delete_session_removes_file_snapshot_and_latest_pointer() {
118        let _data = crate::core::data_dir::isolated_data_dir();
119        let mut session = SessionState::new();
120        session.id = "delete-me".to_string();
121        session.save().unwrap();
122
123        let dir = sessions_dir().unwrap();
124        let path = dir.join("delete-me.json");
125        let snapshot = dir.join("delete-me_snapshot.txt");
126        let latest = dir.join("latest.json");
127        std::fs::write(&snapshot, "snapshot").unwrap();
128        assert!(path.exists());
129        assert!(snapshot.exists());
130        assert!(latest.exists());
131
132        assert!(SessionState::delete_session("delete-me").unwrap());
133
134        assert!(!path.exists());
135        assert!(!snapshot.exists());
136        assert!(!latest.exists());
137        assert!(SessionState::list_sessions().is_empty());
138    }
139
140    #[test]
141    fn delete_latest_session_repoints_latest_to_newest_remaining() {
142        let _data = crate::core::data_dir::isolated_data_dir();
143        let mut older = SessionState::new();
144        older.id = "older".to_string();
145        older.updated_at = Utc::now() - Duration::days(1);
146        older.save().unwrap();
147
148        let mut newer = SessionState::new();
149        newer.id = "newer".to_string();
150        newer.updated_at = Utc::now();
151        newer.save().unwrap();
152        assert_eq!(
153            SessionState::load_global_latest_pointer().unwrap().id,
154            "newer"
155        );
156
157        assert!(SessionState::delete_session("newer").unwrap());
158
159        let latest = SessionState::load_global_latest_pointer().unwrap();
160        assert_eq!(latest.id, "older");
161        assert_eq!(SessionState::list_sessions().len(), 1);
162    }
163
164    #[test]
165    fn delete_session_rejects_path_traversal_id() {
166        let data = crate::core::data_dir::isolated_data_dir();
167        let outside = data.path().join("outside.json");
168        std::fs::write(&outside, "{}").unwrap();
169
170        let err = SessionState::delete_session("../outside").unwrap_err();
171
172        assert_eq!(err, "invalid session id");
173        assert!(outside.exists());
174    }
175
176    #[test]
177    fn extract_cd_absolute_path() {
178        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
179        assert_eq!(result, Some("/usr/local/bin".to_string()));
180    }
181
182    #[test]
183    fn extract_cd_relative_path() {
184        let result = extract_cd_target("cd subdir", "/home/user");
185        assert_eq!(result, Some("/home/user/subdir".to_string()));
186    }
187
188    #[test]
189    fn extract_cd_with_chained_command() {
190        let result = extract_cd_target("cd /tmp && ls", "/home/user");
191        assert_eq!(result, Some("/tmp".to_string()));
192    }
193
194    #[test]
195    fn extract_cd_with_semicolon() {
196        let result = extract_cd_target("cd /tmp; ls", "/home/user");
197        assert_eq!(result, Some("/tmp".to_string()));
198    }
199
200    #[test]
201    fn extract_cd_parent_dir() {
202        let result = extract_cd_target("cd ..", "/home/user/project");
203        assert_eq!(result, Some("/home/user/project/..".to_string()));
204    }
205
206    #[test]
207    fn extract_cd_no_cd_returns_none() {
208        let result = extract_cd_target("ls -la", "/home/user");
209        assert!(result.is_none());
210    }
211
212    #[test]
213    fn extract_cd_bare_cd_goes_home() {
214        let result = extract_cd_target("cd", "/home/user");
215        assert!(result.is_some());
216    }
217
218    #[test]
219    fn effective_cwd_explicit_takes_priority() {
220        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
221        let sub = tmp.join("sub");
222        let _ = std::fs::create_dir_all(&sub);
223        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
224            .to_string_lossy()
225            .to_string();
226        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
227            .to_string_lossy()
228            .to_string();
229
230        let mut session = SessionState::new();
231        session.project_root = Some(root_canon);
232        let result = session.effective_cwd(Some(&sub_canon));
233        assert_eq!(result, sub_canon);
234        let _ = std::fs::remove_dir_all(&tmp);
235    }
236
237    #[cfg(not(feature = "no-jail"))]
238    #[test]
239    fn effective_cwd_explicit_outside_root_is_jailed() {
240        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
241        let _ = std::fs::create_dir_all(&tmp);
242        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
243            .to_string_lossy()
244            .to_string();
245
246        let mut session = SessionState::new();
247        session.project_root = Some(root_canon.clone());
248        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
249        assert_eq!(result, root_canon);
250        let _ = std::fs::remove_dir_all(&tmp);
251    }
252
253    /// The checked variant must report *why* a jailed cwd was rejected, so
254    /// `ctx_shell` can surface it instead of silently swapping in the root (#629).
255    #[cfg(not(feature = "no-jail"))]
256    #[test]
257    fn effective_cwd_checked_reports_jail_rejection_reason() {
258        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
259        let _ = std::fs::create_dir_all(&tmp);
260        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
261            .to_string_lossy()
262            .to_string();
263
264        let mut session = SessionState::new();
265        session.project_root = Some(root_canon.clone());
266
267        // Rejected: falls back to the root AND surfaces a non-empty reason.
268        let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
269        assert_eq!(path, root_canon);
270        let reason = reason.expect("a jailed cwd must report a rejection reason");
271        assert!(!reason.is_empty(), "the rejection reason must not be empty");
272
273        // Accepted (no explicit cwd): no reason, path is the project root.
274        let (accepted, none_reason) = session.effective_cwd_checked(None);
275        assert_eq!(accepted, root_canon);
276        assert!(
277            none_reason.is_none(),
278            "a non-jailed path must not report a reason"
279        );
280
281        let _ = std::fs::remove_dir_all(&tmp);
282    }
283
284    #[test]
285    fn effective_cwd_shell_cwd_second_priority() {
286        let mut session = SessionState::new();
287        session.project_root = Some("/project".to_string());
288        session.shell_cwd = Some("/project/src".to_string());
289        assert_eq!(session.effective_cwd(None), "/project/src");
290    }
291
292    #[test]
293    fn effective_cwd_project_root_third_priority() {
294        let mut session = SessionState::new();
295        session.project_root = Some("/project".to_string());
296        assert_eq!(session.effective_cwd(None), "/project");
297    }
298
299    #[test]
300    fn effective_cwd_dot_ignored() {
301        let mut session = SessionState::new();
302        session.project_root = Some("/project".to_string());
303        assert_eq!(session.effective_cwd(Some(".")), "/project");
304    }
305
306    #[test]
307    fn compaction_snapshot_includes_compression_config_when_enabled() {
308        let mut session = SessionState::new();
309        session.compression_level = "standard".to_string();
310        session.terse_mode = true;
311        session.set_task("x", None);
312        let snapshot = session.build_compaction_snapshot();
313        assert!(snapshot.contains("<config compression=\"standard\" />"));
314    }
315
316    #[test]
317    fn resume_block_prefixes_compression_hint_when_enabled() {
318        let mut session = SessionState::new();
319        session.compression_level = "lite".to_string();
320        session.terse_mode = true;
321        let block = session.build_resume_block();
322        assert!(block.contains("[COMPRESSION: lite]"));
323    }
324
325    #[test]
326    fn compaction_snapshot_includes_task() {
327        let mut session = SessionState::new();
328        session.set_task("fix auth bug", None);
329        let snapshot = session.build_compaction_snapshot();
330        assert!(snapshot.contains("<task>fix auth bug</task>"));
331        assert!(snapshot.contains("<session_snapshot>"));
332        assert!(snapshot.contains("</session_snapshot>"));
333    }
334
335    #[test]
336    fn compaction_snapshot_includes_files() {
337        let mut session = SessionState::new();
338        session.touch_file("src/auth.rs", None, "full", 500);
339        session.files_touched[0].modified = true;
340        session.touch_file("src/main.rs", None, "map", 100);
341        let snapshot = session.build_compaction_snapshot();
342        assert!(snapshot.contains("auth.rs"));
343        assert!(snapshot.contains("<files>"));
344    }
345
346    #[test]
347    fn compaction_snapshot_includes_decisions() {
348        let mut session = SessionState::new();
349        session.add_decision("Use JWT RS256", None);
350        let snapshot = session.build_compaction_snapshot();
351        assert!(snapshot.contains("JWT RS256"));
352        assert!(snapshot.contains("<decisions>"));
353    }
354
355    #[test]
356    fn compaction_snapshot_respects_size_limit() {
357        let mut session = SessionState::new();
358        session.set_task("a]task", None);
359        for i in 0..100 {
360            session.add_finding(
361                Some(&format!("file{i}.rs")),
362                Some(i),
363                &format!("Finding number {i} with some detail text here"),
364            );
365        }
366        let snapshot = session.build_compaction_snapshot();
367        assert!(snapshot.len() <= 2200);
368    }
369
370    #[test]
371    fn compaction_snapshot_includes_stats() {
372        let mut session = SessionState::new();
373        session.stats.total_tool_calls = 42;
374        session.stats.total_tokens_saved = 10000;
375        let snapshot = session.build_compaction_snapshot();
376        assert!(snapshot.contains("calls=42"));
377        assert!(snapshot.contains("saved=10000"));
378    }
379}