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;
18    use super::types::*;
19
20    #[test]
21    fn load_latest_for_broad_root_returns_none_without_scanning() {
22        // The daemon boots with cwd "/" and the dispatcher passes that as the
23        // project root. Broad roots must bail out before walking the session
24        // store — stat-ing persisted roots under ~/Documents from the launchd
25        // daemon pops the macOS TCC prompt (#356).
26        assert!(SessionState::load_latest_for_project_root("/").is_none());
27        if let Some(home) = dirs::home_dir() {
28            let home = home.to_string_lossy().to_string();
29            assert!(SessionState::load_latest_for_project_root(&home).is_none());
30        }
31    }
32
33    #[test]
34    #[cfg(target_os = "macos")]
35    #[serial_test::serial]
36    fn normalize_session_skips_marker_probe_for_real_roots() {
37        // A session whose project_root is a plausible real project must not be
38        // marker-probed at load time when the process is TCC-standalone: the
39        // probe itself would trip the privacy prompt (#356). The repair
40        // heuristic only ever fires for agent/temp roots.
41        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
42        let mut session = SessionState::new();
43        let docs_root = dirs::home_dir()
44            .unwrap_or_default()
45            .join("Documents/some-project")
46            .to_string_lossy()
47            .to_string();
48        session.project_root = Some(docs_root.clone());
49        session.shell_cwd = Some(docs_root.clone());
50        let normalized = super::heuristics::normalize_loaded_session(session);
51        // Root is not an agent/temp dir → kept as-is, no probe needed.
52        assert_eq!(normalized.project_root.as_deref(), Some(docs_root.as_str()));
53        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
54    }
55
56    #[test]
57    fn extract_cd_absolute_path() {
58        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
59        assert_eq!(result, Some("/usr/local/bin".to_string()));
60    }
61
62    #[test]
63    fn extract_cd_relative_path() {
64        let result = extract_cd_target("cd subdir", "/home/user");
65        assert_eq!(result, Some("/home/user/subdir".to_string()));
66    }
67
68    #[test]
69    fn extract_cd_with_chained_command() {
70        let result = extract_cd_target("cd /tmp && ls", "/home/user");
71        assert_eq!(result, Some("/tmp".to_string()));
72    }
73
74    #[test]
75    fn extract_cd_with_semicolon() {
76        let result = extract_cd_target("cd /tmp; ls", "/home/user");
77        assert_eq!(result, Some("/tmp".to_string()));
78    }
79
80    #[test]
81    fn extract_cd_parent_dir() {
82        let result = extract_cd_target("cd ..", "/home/user/project");
83        assert_eq!(result, Some("/home/user/project/..".to_string()));
84    }
85
86    #[test]
87    fn extract_cd_no_cd_returns_none() {
88        let result = extract_cd_target("ls -la", "/home/user");
89        assert!(result.is_none());
90    }
91
92    #[test]
93    fn extract_cd_bare_cd_goes_home() {
94        let result = extract_cd_target("cd", "/home/user");
95        assert!(result.is_some());
96    }
97
98    #[test]
99    fn effective_cwd_explicit_takes_priority() {
100        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
101        let sub = tmp.join("sub");
102        let _ = std::fs::create_dir_all(&sub);
103        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
104            .to_string_lossy()
105            .to_string();
106        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
107            .to_string_lossy()
108            .to_string();
109
110        let mut session = SessionState::new();
111        session.project_root = Some(root_canon);
112        let result = session.effective_cwd(Some(&sub_canon));
113        assert_eq!(result, sub_canon);
114        let _ = std::fs::remove_dir_all(&tmp);
115    }
116
117    #[cfg(not(feature = "no-jail"))]
118    #[test]
119    fn effective_cwd_explicit_outside_root_is_jailed() {
120        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
121        let _ = std::fs::create_dir_all(&tmp);
122        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
123            .to_string_lossy()
124            .to_string();
125
126        let mut session = SessionState::new();
127        session.project_root = Some(root_canon.clone());
128        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
129        assert_eq!(result, root_canon);
130        let _ = std::fs::remove_dir_all(&tmp);
131    }
132
133    #[test]
134    fn effective_cwd_shell_cwd_second_priority() {
135        let mut session = SessionState::new();
136        session.project_root = Some("/project".to_string());
137        session.shell_cwd = Some("/project/src".to_string());
138        assert_eq!(session.effective_cwd(None), "/project/src");
139    }
140
141    #[test]
142    fn effective_cwd_project_root_third_priority() {
143        let mut session = SessionState::new();
144        session.project_root = Some("/project".to_string());
145        assert_eq!(session.effective_cwd(None), "/project");
146    }
147
148    #[test]
149    fn effective_cwd_dot_ignored() {
150        let mut session = SessionState::new();
151        session.project_root = Some("/project".to_string());
152        assert_eq!(session.effective_cwd(Some(".")), "/project");
153    }
154
155    #[test]
156    fn compaction_snapshot_includes_compression_config_when_enabled() {
157        let mut session = SessionState::new();
158        session.compression_level = "standard".to_string();
159        session.terse_mode = true;
160        session.set_task("x", None);
161        let snapshot = session.build_compaction_snapshot();
162        assert!(snapshot.contains("<config compression=\"standard\" />"));
163    }
164
165    #[test]
166    fn resume_block_prefixes_compression_hint_when_enabled() {
167        let mut session = SessionState::new();
168        session.compression_level = "lite".to_string();
169        session.terse_mode = true;
170        let block = session.build_resume_block();
171        assert!(block.contains("[COMPRESSION: lite]"));
172    }
173
174    #[test]
175    fn compaction_snapshot_includes_task() {
176        let mut session = SessionState::new();
177        session.set_task("fix auth bug", None);
178        let snapshot = session.build_compaction_snapshot();
179        assert!(snapshot.contains("<task>fix auth bug</task>"));
180        assert!(snapshot.contains("<session_snapshot>"));
181        assert!(snapshot.contains("</session_snapshot>"));
182    }
183
184    #[test]
185    fn compaction_snapshot_includes_files() {
186        let mut session = SessionState::new();
187        session.touch_file("src/auth.rs", None, "full", 500);
188        session.files_touched[0].modified = true;
189        session.touch_file("src/main.rs", None, "map", 100);
190        let snapshot = session.build_compaction_snapshot();
191        assert!(snapshot.contains("auth.rs"));
192        assert!(snapshot.contains("<files>"));
193    }
194
195    #[test]
196    fn compaction_snapshot_includes_decisions() {
197        let mut session = SessionState::new();
198        session.add_decision("Use JWT RS256", None);
199        let snapshot = session.build_compaction_snapshot();
200        assert!(snapshot.contains("JWT RS256"));
201        assert!(snapshot.contains("<decisions>"));
202    }
203
204    #[test]
205    fn compaction_snapshot_respects_size_limit() {
206        let mut session = SessionState::new();
207        session.set_task("a]task", None);
208        for i in 0..100 {
209            session.add_finding(
210                Some(&format!("file{i}.rs")),
211                Some(i),
212                &format!("Finding number {i} with some detail text here"),
213            );
214        }
215        let snapshot = session.build_compaction_snapshot();
216        assert!(snapshot.len() <= 2200);
217    }
218
219    #[test]
220    fn compaction_snapshot_includes_stats() {
221        let mut session = SessionState::new();
222        session.stats.total_tool_calls = 42;
223        session.stats.total_tokens_saved = 10000;
224        let snapshot = session.build_compaction_snapshot();
225        assert!(snapshot.contains("calls=42"));
226        assert!(snapshot.contains("saved=10000"));
227    }
228}