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]
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 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]
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 #[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 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 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}