1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_FINDINGS: usize = 20;
6const MAX_DECISIONS: usize = 10;
7const MAX_FILES: usize = 50;
8#[allow(dead_code)]
9const MAX_PROGRESS: usize = 30;
10#[allow(dead_code)]
11const MAX_NEXT_STEPS: usize = 10;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
15pub struct SessionState {
16 pub id: String,
17 pub version: u32,
18 pub started_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20 pub project_root: Option<String>,
21 #[serde(default)]
22 pub shell_cwd: Option<String>,
23 pub task: Option<TaskInfo>,
24 pub findings: Vec<Finding>,
25 pub decisions: Vec<Decision>,
26 pub files_touched: Vec<FileTouched>,
27 pub test_results: Option<TestSnapshot>,
28 pub progress: Vec<ProgressEntry>,
29 pub next_steps: Vec<String>,
30 pub stats: SessionStats,
31}
32
33#[derive(Serialize, Deserialize, Clone, Debug)]
34pub struct TaskInfo {
35 pub description: String,
36 pub intent: Option<String>,
37 pub progress_pct: Option<u8>,
38}
39
40#[derive(Serialize, Deserialize, Clone, Debug)]
41pub struct Finding {
42 pub file: Option<String>,
43 pub line: Option<u32>,
44 pub summary: String,
45 pub timestamp: DateTime<Utc>,
46}
47
48#[derive(Serialize, Deserialize, Clone, Debug)]
49pub struct Decision {
50 pub summary: String,
51 pub rationale: Option<String>,
52 pub timestamp: DateTime<Utc>,
53}
54
55#[derive(Serialize, Deserialize, Clone, Debug)]
56pub struct FileTouched {
57 pub path: String,
58 pub file_ref: Option<String>,
59 pub read_count: u32,
60 pub modified: bool,
61 pub last_mode: String,
62 pub tokens: usize,
63}
64
65#[derive(Serialize, Deserialize, Clone, Debug)]
66pub struct TestSnapshot {
67 pub command: String,
68 pub passed: u32,
69 pub failed: u32,
70 pub total: u32,
71 pub timestamp: DateTime<Utc>,
72}
73
74#[derive(Serialize, Deserialize, Clone, Debug)]
75pub struct ProgressEntry {
76 pub action: String,
77 pub detail: Option<String>,
78 pub timestamp: DateTime<Utc>,
79}
80
81#[derive(Serialize, Deserialize, Clone, Debug, Default)]
82pub struct SessionStats {
83 pub total_tool_calls: u32,
84 pub total_tokens_saved: u64,
85 pub total_tokens_input: u64,
86 pub cache_hits: u32,
87 pub files_read: u32,
88 pub commands_run: u32,
89 pub unsaved_changes: u32,
90}
91
92#[derive(Serialize, Deserialize, Clone, Debug)]
93struct LatestPointer {
94 id: String,
95}
96
97impl Default for SessionState {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl SessionState {
104 pub fn new() -> Self {
105 let now = Utc::now();
106 Self {
107 id: generate_session_id(),
108 version: 0,
109 started_at: now,
110 updated_at: now,
111 project_root: None,
112 shell_cwd: None,
113 task: None,
114 findings: Vec::new(),
115 decisions: Vec::new(),
116 files_touched: Vec::new(),
117 test_results: None,
118 progress: Vec::new(),
119 next_steps: Vec::new(),
120 stats: SessionStats::default(),
121 }
122 }
123
124 pub fn increment(&mut self) {
125 self.version += 1;
126 self.updated_at = Utc::now();
127 self.stats.unsaved_changes += 1;
128 }
129
130 pub fn should_save(&self) -> bool {
131 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
132 }
133
134 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
135 self.task = Some(TaskInfo {
136 description: description.to_string(),
137 intent: intent.map(|s| s.to_string()),
138 progress_pct: None,
139 });
140 self.increment();
141 }
142
143 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
144 self.findings.push(Finding {
145 file: file.map(|s| s.to_string()),
146 line,
147 summary: summary.to_string(),
148 timestamp: Utc::now(),
149 });
150 while self.findings.len() > MAX_FINDINGS {
151 self.findings.remove(0);
152 }
153 self.increment();
154 }
155
156 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
157 self.decisions.push(Decision {
158 summary: summary.to_string(),
159 rationale: rationale.map(|s| s.to_string()),
160 timestamp: Utc::now(),
161 });
162 while self.decisions.len() > MAX_DECISIONS {
163 self.decisions.remove(0);
164 }
165 self.increment();
166 }
167
168 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
169 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
170 existing.read_count += 1;
171 existing.last_mode = mode.to_string();
172 existing.tokens = tokens;
173 if let Some(r) = file_ref {
174 existing.file_ref = Some(r.to_string());
175 }
176 } else {
177 self.files_touched.push(FileTouched {
178 path: path.to_string(),
179 file_ref: file_ref.map(|s| s.to_string()),
180 read_count: 1,
181 modified: false,
182 last_mode: mode.to_string(),
183 tokens,
184 });
185 while self.files_touched.len() > MAX_FILES {
186 self.files_touched.remove(0);
187 }
188 }
189 self.stats.files_read += 1;
190 self.increment();
191 }
192
193 pub fn mark_modified(&mut self, path: &str) {
194 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
195 existing.modified = true;
196 }
197 self.increment();
198 }
199
200 #[allow(dead_code)]
201 pub fn set_test_results(&mut self, command: &str, passed: u32, failed: u32, total: u32) {
202 self.test_results = Some(TestSnapshot {
203 command: command.to_string(),
204 passed,
205 failed,
206 total,
207 timestamp: Utc::now(),
208 });
209 self.increment();
210 }
211
212 #[allow(dead_code)]
213 pub fn add_progress(&mut self, action: &str, detail: Option<&str>) {
214 self.progress.push(ProgressEntry {
215 action: action.to_string(),
216 detail: detail.map(|s| s.to_string()),
217 timestamp: Utc::now(),
218 });
219 while self.progress.len() > MAX_PROGRESS {
220 self.progress.remove(0);
221 }
222 self.increment();
223 }
224
225 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
226 self.stats.total_tool_calls += 1;
227 self.stats.total_tokens_saved += tokens_saved;
228 self.stats.total_tokens_input += tokens_input;
229 }
230
231 pub fn record_cache_hit(&mut self) {
232 self.stats.cache_hits += 1;
233 }
234
235 pub fn record_command(&mut self) {
236 self.stats.commands_run += 1;
237 }
238
239 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
242 if let Some(cwd) = explicit_cwd {
243 if !cwd.is_empty() && cwd != "." {
244 return cwd.to_string();
245 }
246 }
247 if let Some(ref cwd) = self.shell_cwd {
248 return cwd.clone();
249 }
250 if let Some(ref root) = self.project_root {
251 return root.clone();
252 }
253 std::env::current_dir()
254 .map(|p| p.to_string_lossy().to_string())
255 .unwrap_or_else(|_| ".".to_string())
256 }
257
258 pub fn update_shell_cwd(&mut self, command: &str) {
262 let base = self.effective_cwd(None);
263 if let Some(new_cwd) = extract_cd_target(command, &base) {
264 let path = std::path::Path::new(&new_cwd);
265 if path.exists() && path.is_dir() {
266 self.shell_cwd = Some(
267 path.canonicalize()
268 .unwrap_or_else(|_| path.to_path_buf())
269 .to_string_lossy()
270 .to_string(),
271 );
272 }
273 }
274 }
275
276 pub fn format_compact(&self) -> String {
277 let duration = self.updated_at - self.started_at;
278 let hours = duration.num_hours();
279 let mins = duration.num_minutes() % 60;
280 let duration_str = if hours > 0 {
281 format!("{hours}h {mins}m")
282 } else {
283 format!("{mins}m")
284 };
285
286 let mut lines = Vec::new();
287 lines.push(format!(
288 "SESSION v{} | {} | {} calls | {} tok saved",
289 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
290 ));
291
292 if let Some(ref task) = self.task {
293 let pct = task
294 .progress_pct
295 .map_or(String::new(), |p| format!(" [{p}%]"));
296 lines.push(format!("Task: {}{pct}", task.description));
297 }
298
299 if let Some(ref root) = self.project_root {
300 lines.push(format!("Root: {}", shorten_path(root)));
301 }
302
303 if !self.findings.is_empty() {
304 let items: Vec<String> = self
305 .findings
306 .iter()
307 .rev()
308 .take(5)
309 .map(|f| {
310 let loc = match (&f.file, f.line) {
311 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
312 (Some(file), None) => shorten_path(file),
313 _ => String::new(),
314 };
315 if loc.is_empty() {
316 f.summary.clone()
317 } else {
318 format!("{loc} \u{2014} {}", f.summary)
319 }
320 })
321 .collect();
322 lines.push(format!(
323 "Findings ({}): {}",
324 self.findings.len(),
325 items.join(" | ")
326 ));
327 }
328
329 if !self.decisions.is_empty() {
330 let items: Vec<&str> = self
331 .decisions
332 .iter()
333 .rev()
334 .take(3)
335 .map(|d| d.summary.as_str())
336 .collect();
337 lines.push(format!("Decisions: {}", items.join(" | ")));
338 }
339
340 if !self.files_touched.is_empty() {
341 let items: Vec<String> = self
342 .files_touched
343 .iter()
344 .rev()
345 .take(10)
346 .map(|f| {
347 let status = if f.modified { "mod" } else { &f.last_mode };
348 let r = f.file_ref.as_deref().unwrap_or("?");
349 format!("[{r} {} {status}]", shorten_path(&f.path))
350 })
351 .collect();
352 lines.push(format!(
353 "Files ({}): {}",
354 self.files_touched.len(),
355 items.join(" ")
356 ));
357 }
358
359 if let Some(ref tests) = self.test_results {
360 lines.push(format!(
361 "Tests: {}/{} pass ({})",
362 tests.passed, tests.total, tests.command
363 ));
364 }
365
366 if !self.next_steps.is_empty() {
367 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
368 }
369
370 lines.join("\n")
371 }
372
373 pub fn build_compaction_snapshot(&self) -> String {
374 const MAX_SNAPSHOT_BYTES: usize = 2048;
375
376 let mut sections: Vec<(u8, String)> = Vec::new();
377
378 if let Some(ref task) = self.task {
379 let pct = task
380 .progress_pct
381 .map_or(String::new(), |p| format!(" [{p}%]"));
382 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
383 }
384
385 if !self.files_touched.is_empty() {
386 let modified: Vec<&str> = self
387 .files_touched
388 .iter()
389 .filter(|f| f.modified)
390 .map(|f| f.path.as_str())
391 .collect();
392 let read_only: Vec<&str> = self
393 .files_touched
394 .iter()
395 .filter(|f| !f.modified)
396 .take(10)
397 .map(|f| f.path.as_str())
398 .collect();
399 let mut files_section = String::new();
400 if !modified.is_empty() {
401 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
402 }
403 if !read_only.is_empty() {
404 if !files_section.is_empty() {
405 files_section.push_str(" | ");
406 }
407 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
408 }
409 sections.push((1, format!("<files>{files_section}</files>")));
410 }
411
412 if !self.decisions.is_empty() {
413 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
414 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
415 }
416
417 if !self.findings.is_empty() {
418 let items: Vec<String> = self
419 .findings
420 .iter()
421 .rev()
422 .take(5)
423 .map(|f| f.summary.clone())
424 .collect();
425 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
426 }
427
428 if !self.progress.is_empty() {
429 let items: Vec<String> = self
430 .progress
431 .iter()
432 .rev()
433 .take(5)
434 .map(|p| {
435 let detail = p.detail.as_deref().unwrap_or("");
436 if detail.is_empty() {
437 p.action.clone()
438 } else {
439 format!("{}: {detail}", p.action)
440 }
441 })
442 .collect();
443 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
444 }
445
446 if let Some(ref tests) = self.test_results {
447 sections.push((
448 3,
449 format!(
450 "<tests>{}/{} pass ({})</tests>",
451 tests.passed, tests.total, tests.command
452 ),
453 ));
454 }
455
456 if !self.next_steps.is_empty() {
457 sections.push((
458 3,
459 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
460 ));
461 }
462
463 sections.push((
464 4,
465 format!(
466 "<stats>calls={} saved={}tok</stats>",
467 self.stats.total_tool_calls, self.stats.total_tokens_saved
468 ),
469 ));
470
471 sections.sort_by_key(|(priority, _)| *priority);
472
473 let mut snapshot = String::from("<session_snapshot>\n");
474 for (_, section) in §ions {
475 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
476 break;
477 }
478 snapshot.push_str(section);
479 snapshot.push('\n');
480 }
481 snapshot.push_str("</session_snapshot>");
482 snapshot
483 }
484
485 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
486 let snapshot = self.build_compaction_snapshot();
487 let dir = sessions_dir().ok_or("cannot determine home directory")?;
488 if !dir.exists() {
489 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
490 }
491 let path = dir.join(format!("{}_snapshot.txt", self.id));
492 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
493 Ok(snapshot)
494 }
495
496 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
497 let dir = sessions_dir()?;
498 let path = dir.join(format!("{session_id}_snapshot.txt"));
499 std::fs::read_to_string(&path).ok()
500 }
501
502 pub fn load_latest_snapshot() -> Option<String> {
503 let dir = sessions_dir()?;
504 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
505 .ok()?
506 .filter_map(|e| e.ok())
507 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
508 .filter_map(|e| {
509 let meta = e.metadata().ok()?;
510 let modified = meta.modified().ok()?;
511 Some((modified, e.path()))
512 })
513 .collect();
514
515 snapshots.sort_by(|a, b| b.0.cmp(&a.0));
516 snapshots
517 .first()
518 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
519 }
520
521 pub fn save(&mut self) -> Result<(), String> {
522 let dir = sessions_dir().ok_or("cannot determine home directory")?;
523 if !dir.exists() {
524 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
525 }
526
527 let path = dir.join(format!("{}.json", self.id));
528 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
529
530 let tmp = dir.join(format!(".{}.json.tmp", self.id));
531 std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
532 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
533
534 let pointer = LatestPointer {
535 id: self.id.clone(),
536 };
537 let pointer_json = serde_json::to_string(&pointer).map_err(|e| e.to_string())?;
538 let latest_path = dir.join("latest.json");
539 let latest_tmp = dir.join(".latest.json.tmp");
540 std::fs::write(&latest_tmp, &pointer_json).map_err(|e| e.to_string())?;
541 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
542
543 self.stats.unsaved_changes = 0;
544 Ok(())
545 }
546
547 pub fn load_latest() -> Option<Self> {
548 let dir = sessions_dir()?;
549 let latest_path = dir.join("latest.json");
550 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
551 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
552 Self::load_by_id(&pointer.id)
553 }
554
555 pub fn load_by_id(id: &str) -> Option<Self> {
556 let dir = sessions_dir()?;
557 let path = dir.join(format!("{id}.json"));
558 let json = std::fs::read_to_string(&path).ok()?;
559 serde_json::from_str(&json).ok()
560 }
561
562 pub fn list_sessions() -> Vec<SessionSummary> {
563 let dir = match sessions_dir() {
564 Some(d) => d,
565 None => return Vec::new(),
566 };
567
568 let mut summaries = Vec::new();
569 if let Ok(entries) = std::fs::read_dir(&dir) {
570 for entry in entries.flatten() {
571 let path = entry.path();
572 if path.extension().and_then(|e| e.to_str()) != Some("json") {
573 continue;
574 }
575 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
576 continue;
577 }
578 if let Ok(json) = std::fs::read_to_string(&path) {
579 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
580 summaries.push(SessionSummary {
581 id: session.id,
582 started_at: session.started_at,
583 updated_at: session.updated_at,
584 version: session.version,
585 task: session.task.as_ref().map(|t| t.description.clone()),
586 tool_calls: session.stats.total_tool_calls,
587 tokens_saved: session.stats.total_tokens_saved,
588 });
589 }
590 }
591 }
592 }
593
594 summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
595 summaries
596 }
597
598 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
599 let dir = match sessions_dir() {
600 Some(d) => d,
601 None => return 0,
602 };
603
604 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
605 let latest = Self::load_latest().map(|s| s.id);
606 let mut removed = 0u32;
607
608 if let Ok(entries) = std::fs::read_dir(&dir) {
609 for entry in entries.flatten() {
610 let path = entry.path();
611 if path.extension().and_then(|e| e.to_str()) != Some("json") {
612 continue;
613 }
614 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
615 if filename == "latest" || filename.starts_with('.') {
616 continue;
617 }
618 if latest.as_deref() == Some(filename) {
619 continue;
620 }
621 if let Ok(json) = std::fs::read_to_string(&path) {
622 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
623 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
624 removed += 1;
625 }
626 }
627 }
628 }
629 }
630
631 removed
632 }
633}
634
635#[derive(Debug, Clone)]
636#[allow(dead_code)]
637pub struct SessionSummary {
638 pub id: String,
639 pub started_at: DateTime<Utc>,
640 pub updated_at: DateTime<Utc>,
641 pub version: u32,
642 pub task: Option<String>,
643 pub tool_calls: u32,
644 pub tokens_saved: u64,
645}
646
647fn sessions_dir() -> Option<PathBuf> {
648 dirs::home_dir().map(|h| h.join(".lean-ctx").join("sessions"))
649}
650
651fn generate_session_id() -> String {
652 let now = Utc::now();
653 let ts = now.format("%Y%m%d-%H%M%S").to_string();
654 let random: u32 = (std::time::SystemTime::now()
655 .duration_since(std::time::UNIX_EPOCH)
656 .unwrap_or_default()
657 .subsec_nanos())
658 % 10000;
659 format!("{ts}-{random:04}")
660}
661
662fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
665 let first_cmd = command
666 .split("&&")
667 .next()
668 .unwrap_or(command)
669 .split(';')
670 .next()
671 .unwrap_or(command)
672 .trim();
673
674 if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
675 return None;
676 }
677
678 let target = first_cmd.strip_prefix("cd")?.trim();
679 if target.is_empty() || target == "~" {
680 return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
681 }
682
683 let target = target.trim_matches('"').trim_matches('\'');
684 let path = std::path::Path::new(target);
685
686 if path.is_absolute() {
687 Some(target.to_string())
688 } else {
689 let base = std::path::Path::new(base_cwd);
690 Some(base.join(target).to_string_lossy().to_string())
691 }
692}
693
694fn shorten_path(path: &str) -> String {
695 let parts: Vec<&str> = path.split('/').collect();
696 if parts.len() <= 2 {
697 return path.to_string();
698 }
699 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
700 format!("…/{}/{}", last_two[1], last_two[0])
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn extract_cd_absolute_path() {
709 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
710 assert_eq!(result, Some("/usr/local/bin".to_string()));
711 }
712
713 #[test]
714 fn extract_cd_relative_path() {
715 let result = extract_cd_target("cd subdir", "/home/user");
716 let expected = std::path::Path::new("/home/user")
717 .join("subdir")
718 .to_string_lossy()
719 .to_string();
720 assert_eq!(result, Some(expected));
721 }
722
723 #[test]
724 fn extract_cd_with_chained_command() {
725 let result = extract_cd_target("cd /tmp && ls", "/home/user");
726 assert_eq!(result, Some("/tmp".to_string()));
727 }
728
729 #[test]
730 fn extract_cd_with_semicolon() {
731 let result = extract_cd_target("cd /tmp; ls", "/home/user");
732 assert_eq!(result, Some("/tmp".to_string()));
733 }
734
735 #[test]
736 fn extract_cd_parent_dir() {
737 let result = extract_cd_target("cd ..", "/home/user/project");
738 let expected = std::path::Path::new("/home/user/project")
739 .join("..")
740 .to_string_lossy()
741 .to_string();
742 assert_eq!(result, Some(expected));
743 }
744
745 #[test]
746 fn extract_cd_no_cd_returns_none() {
747 let result = extract_cd_target("ls -la", "/home/user");
748 assert!(result.is_none());
749 }
750
751 #[test]
752 fn extract_cd_bare_cd_goes_home() {
753 let result = extract_cd_target("cd", "/home/user");
754 assert!(result.is_some());
755 }
756
757 #[test]
758 fn effective_cwd_explicit_takes_priority() {
759 let mut session = SessionState::new();
760 session.project_root = Some("/project".to_string());
761 session.shell_cwd = Some("/project/src".to_string());
762 assert_eq!(session.effective_cwd(Some("/explicit")), "/explicit");
763 }
764
765 #[test]
766 fn effective_cwd_shell_cwd_second_priority() {
767 let mut session = SessionState::new();
768 session.project_root = Some("/project".to_string());
769 session.shell_cwd = Some("/project/src".to_string());
770 assert_eq!(session.effective_cwd(None), "/project/src");
771 }
772
773 #[test]
774 fn effective_cwd_project_root_third_priority() {
775 let mut session = SessionState::new();
776 session.project_root = Some("/project".to_string());
777 assert_eq!(session.effective_cwd(None), "/project");
778 }
779
780 #[test]
781 fn effective_cwd_dot_ignored() {
782 let mut session = SessionState::new();
783 session.project_root = Some("/project".to_string());
784 assert_eq!(session.effective_cwd(Some(".")), "/project");
785 }
786
787 #[test]
788 fn compaction_snapshot_includes_task() {
789 let mut session = SessionState::new();
790 session.set_task("fix auth bug", None);
791 let snapshot = session.build_compaction_snapshot();
792 assert!(snapshot.contains("<task>fix auth bug</task>"));
793 assert!(snapshot.contains("<session_snapshot>"));
794 assert!(snapshot.contains("</session_snapshot>"));
795 }
796
797 #[test]
798 fn compaction_snapshot_includes_files() {
799 let mut session = SessionState::new();
800 session.touch_file("src/auth.rs", None, "full", 500);
801 session.files_touched[0].modified = true;
802 session.touch_file("src/main.rs", None, "map", 100);
803 let snapshot = session.build_compaction_snapshot();
804 assert!(snapshot.contains("auth.rs"));
805 assert!(snapshot.contains("<files>"));
806 }
807
808 #[test]
809 fn compaction_snapshot_includes_decisions() {
810 let mut session = SessionState::new();
811 session.add_decision("Use JWT RS256", None);
812 let snapshot = session.build_compaction_snapshot();
813 assert!(snapshot.contains("JWT RS256"));
814 assert!(snapshot.contains("<decisions>"));
815 }
816
817 #[test]
818 fn compaction_snapshot_respects_size_limit() {
819 let mut session = SessionState::new();
820 session.set_task("a]task", None);
821 for i in 0..100 {
822 session.add_finding(
823 Some(&format!("file{i}.rs")),
824 Some(i),
825 &format!("Finding number {i} with some detail text here"),
826 );
827 }
828 let snapshot = session.build_compaction_snapshot();
829 assert!(snapshot.len() <= 2200);
830 }
831
832 #[test]
833 fn compaction_snapshot_includes_stats() {
834 let mut session = SessionState::new();
835 session.stats.total_tool_calls = 42;
836 session.stats.total_tokens_saved = 10000;
837 let snapshot = session.build_compaction_snapshot();
838 assert!(snapshot.contains("calls=42"));
839 assert!(snapshot.contains("saved=10000"));
840 }
841}