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