lean_ctx/core/session/
mod.rs1mod 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 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 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 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 #[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 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 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}