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