Skip to main content

lean_ctx/core/
session.rs

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    /// Returns the effective working directory for shell commands.
240    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd
241    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    /// Updates shell_cwd by detecting `cd` in the command.
259    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
260    /// `cd ..`, and chained commands like `cd foo && ...`.
261    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 &sections {
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
662/// Extracts the `cd` target from a command string.
663/// Handles patterns like `cd /foo`, `cd foo && bar`, `cd ../dir; cmd`, etc.
664fn 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}