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