1pub mod admission;
2pub mod caller;
3mod compaction;
4mod finding_sources;
5mod heuristics;
6pub mod housekeeping;
7mod journal;
8mod paths;
9mod persistence;
10pub mod playbook;
11mod save_outcome;
12mod slot;
13mod state;
14pub(crate) use journal::AttachSessionJournalV1;
15#[cfg(any(test, feature = "test-support"))]
16pub const GIT_TASK_INTENT: &str = state::GIT_TASK_INTENT;
18#[cfg(any(test, feature = "test-support"))]
19pub const INFERRED_TASK_INTENT: &str = state::INFERRED_TASK_INTENT;
21pub use state::{extract_session_facts, is_auto_task_intent};
22mod types;
23pub(crate) use paths::{EngineSessionId, parse_engine_session_id};
24
25pub use playbook::{DeltaOutcome, EntryKind, Playbook, PlaybookEntry};
26pub use slot::{SessionSlot, SharedSession};
27pub use types::{
28 Decision, EvidenceKind, EvidenceRecord, FileTouched, Finding, LiveZoneSessionState,
29 PreparedSave, ProgressEntry, SaveError, SessionDayCounts, SessionState, SessionStats,
30 SessionSummary, TaskInfo, TestSnapshot,
31};
32
33#[cfg(test)]
34mod tests {
35 use super::paths::{extract_cd_target, sessions_dir};
36 use super::types::*;
37 use chrono::{Duration, Utc};
38
39 #[test]
42 fn session_memory_never_stores_a_credential() {
43 let _isolation = crate::core::data_dir::isolated_data_dir();
44 let credential = concat!("AK", "IAQ3EGRZ7MEMORYKEY");
45 let mut session = SessionState::new();
46 session.add_finding(Some("deploy.env"), Some(3), &format!("key is {credential}"));
47 session.add_decision(
48 &format!("rotate {credential}"),
49 Some(&format!("it leaked as {credential}")),
50 );
51 let stored = serde_json::to_string(&session).expect("serialize session");
52 assert!(
53 !stored.contains(credential),
54 "session memory kept the credential"
55 );
56 assert!(stored.contains("key is"), "the finding itself is kept");
57 }
58
59 #[test]
62 fn a_session_with_the_retired_wakeup_manifest_still_loads() {
63 let _isolation = crate::core::data_dir::isolated_data_dir();
64 let mut stored = serde_json::to_value(SessionState::new()).expect("serialize session");
65 stored["wakeup_manifest"] = serde_json::json!([
66 { "key": "fact:a", "position": "begin", "profile": "claude", "missed": true }
67 ]);
68 let loaded: SessionState = serde_json::from_value(stored).expect("old session loads");
69 let saved = serde_json::to_value(&loaded).expect("serialize session");
70 assert!(saved.get("wakeup_manifest").is_none());
71 }
72
73 #[test]
77 fn git_task_names_uncommitted_files_not_diff_counters() {
78 use super::state::describe_uncommitted_files;
79 assert_eq!(describe_uncommitted_files(["", " "]), None);
80 assert_eq!(
81 describe_uncommitted_files([".gitignore", "AGENTS.md"]).as_deref(),
82 Some("Uncommitted changes: .gitignore, AGENTS.md")
83 );
84 assert_eq!(
85 describe_uncommitted_files([
86 "rust/src/core/entropy.rs",
87 "rust/src/core/call_graph.rs",
88 "rust/src/core/quality.rs",
89 "README.md",
90 "docs/a.md",
91 ])
92 .as_deref(),
93 Some("Uncommitted changes: entropy.rs, call_graph.rs, quality.rs (+2 more)")
94 );
95 }
96
97 #[test]
98 fn load_latest_for_broad_root_returns_none_without_scanning() {
99 let _home = crate::core::data_dir::test_env_lock();
100 assert!(SessionState::load_latest_for_project_root("/").is_none());
105 if let Some(home) = dirs::home_dir() {
106 let home = home.to_string_lossy().to_string();
107 assert!(SessionState::load_latest_for_project_root(&home).is_none());
108 }
109 }
110
111 #[test]
112 #[cfg(target_os = "macos")]
113 #[serial_test::serial]
114 fn normalize_session_skips_marker_probe_for_real_roots() {
115 let _env_lock = crate::core::data_dir::test_env_lock();
119 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
124 let mut session = SessionState::new();
125 let docs_root = dirs::home_dir()
126 .unwrap_or_default()
127 .join("Documents/some-project")
128 .to_string_lossy()
129 .to_string();
130 session.project_root = Some(docs_root.clone());
131 session.shell_cwd = Some(docs_root.clone());
132 let normalized = super::heuristics::normalize_loaded_session(session);
133 assert_eq!(normalized.project_root.as_deref(), Some(docs_root.as_str()));
135 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
136 }
137
138 #[test]
143 fn note_explicit_cwd_persists_absolute_dirs_only() {
144 let tmp = std::env::temp_dir().join(format!("lc_707_cwd_{}", std::process::id()));
145 std::fs::create_dir_all(&tmp).unwrap();
146
147 let mut session = SessionState::new();
148 session.note_explicit_cwd(&tmp.to_string_lossy());
149 let noted = session.shell_cwd.clone().expect("cwd should persist");
150 assert_eq!(
152 noted,
153 crate::core::pathutil::safe_canonicalize_or_self(&tmp)
154 .to_string_lossy()
155 .to_string()
156 );
157
158 session.note_explicit_cwd("relative/dir");
159 assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
160 session.note_explicit_cwd(&tmp.join("does-not-exist").to_string_lossy());
161 assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
162
163 let _ = std::fs::remove_dir_all(&tmp);
164 }
165
166 #[test]
171 fn should_save_flushes_first_change_and_stale_changes() {
172 use super::state::SESSION_FLUSH_INTERVAL;
173 let mut session = SessionState::new();
174 assert!(!session.should_save(), "no changes → no save");
175
176 session.increment();
177 assert!(session.should_save(), "first change must flush immediately");
178
179 session.last_flush = Some(std::time::Instant::now());
180 assert!(
181 !session.should_save(),
182 "inside window + below batch → defer"
183 );
184
185 for _ in 0..4 {
186 session.increment();
187 }
188 assert!(session.should_save(), "batch threshold still applies");
189
190 session.stats.unsaved_changes = 1;
191 if let Some(old) = std::time::Instant::now().checked_sub(SESSION_FLUSH_INTERVAL) {
192 session.last_flush = Some(old);
193 assert!(session.should_save(), "stale change must trigger flush");
194 }
195 }
196
197 #[test]
198 fn delete_session_removes_file_snapshot_and_latest_pointer() {
199 let _data = crate::core::data_dir::isolated_data_dir();
200 let mut session = SessionState::new();
201 session.id = "delete-me".to_string();
202 session.save().unwrap();
203
204 let dir = sessions_dir().unwrap();
205 let path = dir.join("delete-me.json");
206 let snapshot = dir.join("delete-me_snapshot.txt");
207 let latest = dir.join("latest.json");
208 std::fs::write(&snapshot, "snapshot").unwrap();
209 assert!(path.exists());
210 assert!(snapshot.exists());
211 assert!(latest.exists());
212
213 assert!(SessionState::delete_session("delete-me").unwrap());
214
215 assert!(!path.exists());
216 assert!(!snapshot.exists());
217 assert!(!latest.exists());
218 assert!(SessionState::list_sessions().is_empty());
219 }
220
221 #[test]
222 fn delete_latest_session_repoints_latest_to_newest_remaining() {
223 let _data = crate::core::data_dir::isolated_data_dir();
224 let mut older = SessionState::new();
225 older.id = "older".to_string();
226 older.updated_at = Utc::now() - Duration::days(1);
227 older.save().unwrap();
228
229 let mut newer = SessionState::new();
230 newer.id = "newer".to_string();
231 newer.updated_at = Utc::now();
232 newer.save().unwrap();
233 assert_eq!(
234 SessionState::load_global_latest_pointer().unwrap().id,
235 "newer"
236 );
237
238 assert!(SessionState::delete_session("newer").unwrap());
239
240 let latest = SessionState::load_global_latest_pointer().unwrap();
241 assert_eq!(latest.id, "older");
242 assert_eq!(SessionState::list_sessions().len(), 1);
243 }
244
245 #[test]
246 fn delete_session_rejects_path_traversal_id() {
247 let data = crate::core::data_dir::isolated_data_dir();
248 let outside = data.path().join("outside.json");
249 std::fs::write(&outside, "{}").unwrap();
250
251 let err = SessionState::delete_session("../outside").unwrap_err();
252
253 assert_eq!(err, "invalid session id");
254 assert!(outside.exists());
255 }
256
257 #[test]
258 fn extract_cd_absolute_path() {
259 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
260 assert_eq!(result, Some("/usr/local/bin".to_string()));
261 }
262
263 #[test]
264 fn extract_cd_relative_path() {
265 let result = extract_cd_target("cd subdir", "/home/user");
266 assert_eq!(result, Some("/home/user/subdir".to_string()));
267 }
268
269 #[test]
270 fn extract_cd_with_chained_command() {
271 let result = extract_cd_target("cd /tmp && ls", "/home/user");
272 assert_eq!(result, Some("/tmp".to_string()));
273 }
274
275 #[test]
276 fn extract_cd_with_semicolon() {
277 let result = extract_cd_target("cd /tmp; ls", "/home/user");
278 assert_eq!(result, Some("/tmp".to_string()));
279 }
280
281 #[test]
282 fn extract_cd_parent_dir() {
283 let result = extract_cd_target("cd ..", "/home/user/project");
284 assert_eq!(result, Some("/home/user/project/..".to_string()));
285 }
286
287 #[test]
288 fn extract_cd_no_cd_returns_none() {
289 let result = extract_cd_target("ls -la", "/home/user");
290 assert!(result.is_none());
291 }
292
293 #[test]
294 fn extract_cd_bare_cd_goes_home() {
295 let result = extract_cd_target("cd", "/home/user");
296 assert!(result.is_some());
297 }
298
299 #[test]
300 fn effective_cwd_explicit_takes_priority() {
301 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
302 let sub = tmp.join("sub");
303 let _ = std::fs::create_dir_all(&sub);
304 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
305 .to_string_lossy()
306 .to_string();
307 let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
308 .to_string_lossy()
309 .to_string();
310
311 let mut session = SessionState::new();
312 session.project_root = Some(root_canon);
313 let result = session.effective_cwd(Some(&sub_canon));
314 assert_eq!(result, sub_canon);
315 let _ = std::fs::remove_dir_all(&tmp);
316 }
317
318 #[cfg(not(feature = "no-jail"))]
319 #[test]
320 fn effective_cwd_explicit_outside_root_is_jailed() {
321 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
322 let _ = std::fs::create_dir_all(&tmp);
323 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
324 .to_string_lossy()
325 .to_string();
326
327 let mut session = SessionState::new();
328 session.project_root = Some(root_canon.clone());
329 let result = session.effective_cwd(Some("/nonexistent-outside-path"));
330 assert_eq!(result, root_canon);
331 let _ = std::fs::remove_dir_all(&tmp);
332 }
333
334 #[cfg(not(feature = "no-jail"))]
337 #[test]
338 fn effective_cwd_checked_reports_jail_rejection_reason() {
339 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
340 let _ = std::fs::create_dir_all(&tmp);
341 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
342 .to_string_lossy()
343 .to_string();
344
345 let mut session = SessionState::new();
346 session.project_root = Some(root_canon.clone());
347
348 let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
350 assert_eq!(path, root_canon);
351 let reason = reason.expect("a jailed cwd must report a rejection reason");
352 assert!(!reason.is_empty(), "the rejection reason must not be empty");
353
354 let (accepted, none_reason) = session.effective_cwd_checked(None);
356 assert_eq!(accepted, root_canon);
357 assert!(
358 none_reason.is_none(),
359 "a non-jailed path must not report a reason"
360 );
361
362 let _ = std::fs::remove_dir_all(&tmp);
363 }
364
365 #[test]
366 fn effective_cwd_shell_cwd_second_priority() {
367 let mut session = SessionState::new();
368 session.project_root = Some("/project".to_string());
369 session.shell_cwd = Some("/project/src".to_string());
370 assert_eq!(session.effective_cwd(None), "/project/src");
371 }
372
373 #[test]
374 fn effective_cwd_project_root_third_priority() {
375 let mut session = SessionState::new();
376 session.project_root = Some("/project".to_string());
377 assert_eq!(session.effective_cwd(None), "/project");
378 }
379
380 #[test]
381 fn effective_cwd_dot_ignored() {
382 let mut session = SessionState::new();
383 session.project_root = Some("/project".to_string());
384 assert_eq!(session.effective_cwd(Some(".")), "/project");
385 }
386
387 #[cfg(not(feature = "no-jail"))]
392 #[test]
393 fn execution_cwd_refuses_instead_of_substituting_the_project_root() {
394 let base = tempfile::tempdir().unwrap();
395 let root = base.path().join("project");
396 let inside = root.join("src");
397 let outside = base.path().join("elsewhere");
398 for dir in [&inside, &outside] {
399 std::fs::create_dir_all(dir).unwrap();
400 }
401 let canon = |path: &std::path::Path| {
402 crate::core::pathutil::safe_canonicalize_or_self(path)
403 .to_string_lossy()
404 .to_string()
405 };
406 let (root, inside, outside) = (canon(&root), canon(&inside), canon(&outside));
407
408 let mut session = SessionState::new();
409 session.project_root = Some(root.clone());
410
411 let refused = session.execution_cwd(Some(&outside));
412 assert!(
413 refused.as_ref().is_err_and(|reason| !reason.is_empty()),
414 "an explicit cwd outside the project must fail: {refused:?}"
415 );
416 assert_eq!(session.execution_cwd(Some(&inside)), Ok(inside.clone()));
417 assert_eq!(session.execution_cwd(None), Ok(root.clone()));
418 assert_eq!(session.execution_cwd(Some(".")), Ok(root.clone()));
419
420 session.shell_cwd = Some(inside.clone());
421 assert_eq!(session.execution_cwd(None), Ok(inside));
422
423 session.shell_cwd = Some(outside.clone());
424 let stale = session.execution_cwd(None);
425 assert!(
426 stale.is_err(),
427 "a stored shell cwd outside the project must fail: {stale:?}"
428 );
429
430 session.project_root = None;
432 assert_eq!(session.execution_cwd(None), Ok(outside));
433 }
434
435 #[test]
436 fn compaction_snapshot_includes_compression_config_when_enabled() {
437 let mut session = SessionState::new();
438 session.compression_level = "standard".to_string();
439 session.terse_mode = true;
440 session.set_task("x", None);
441 let snapshot = session.build_compaction_snapshot();
442 assert!(snapshot.contains("<config compression=\"standard\" />"));
443 }
444
445 #[test]
446 fn resume_block_prefixes_compression_hint_when_enabled() {
447 let mut session = SessionState::new();
448 session.compression_level = "lite".to_string();
449 session.terse_mode = true;
450 let block = session.build_resume_block();
451 assert!(block.contains("[COMPRESSION: lite]"));
452 }
453
454 #[test]
455 fn compaction_snapshot_includes_task() {
456 let mut session = SessionState::new();
457 session.set_task("fix auth bug", None);
458 let snapshot = session.build_compaction_snapshot();
459 assert!(snapshot.contains("<task>fix auth bug</task>"));
460 assert!(snapshot.contains("<session_snapshot>"));
461 assert!(snapshot.contains("</session_snapshot>"));
462 }
463
464 #[test]
465 fn compaction_snapshot_includes_files() {
466 let mut session = SessionState::new();
467 session.touch_file("src/auth.rs", None, "full", 500);
468 session.files_touched[0].modified = true;
469 session.touch_file("src/main.rs", None, "map", 100);
470 let snapshot = session.build_compaction_snapshot();
471 assert!(snapshot.contains("auth.rs"));
472 assert!(snapshot.contains("<files>"));
473 }
474
475 #[test]
476 fn compaction_snapshot_includes_decisions() {
477 let mut session = SessionState::new();
478 session.add_decision("Use JWT RS256", None);
479 let snapshot = session.build_compaction_snapshot();
480 assert!(snapshot.contains("JWT RS256"));
481 assert!(snapshot.contains("<decisions>"));
482 }
483
484 #[test]
485 fn compaction_snapshot_respects_size_limit() {
486 let mut session = SessionState::new();
487 session.set_task("a]task", None);
488 for i in 0..100 {
489 session.add_finding(
490 Some(&format!("file{i}.rs")),
491 Some(i),
492 &format!("Finding number {i} with some detail text here"),
493 );
494 }
495 let snapshot = session.build_compaction_snapshot();
496 assert!(snapshot.len() <= 2200);
497 }
498
499 #[test]
500 fn compaction_snapshot_includes_stats() {
501 let mut session = SessionState::new();
502 session.stats.total_tool_calls = 42;
503 session.stats.total_tokens_saved = 10000;
504 let snapshot = session.build_compaction_snapshot();
505 assert!(snapshot.contains("calls=42"));
506 assert!(snapshot.contains("saved=10000"));
507 }
508}