Skip to main content

lean_ctx/core/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::core::graph_context;
6use crate::core::intent_protocol::{IntentRecord, IntentSource};
7
8const MAX_FINDINGS: usize = 20;
9const MAX_DECISIONS: usize = 10;
10const MAX_FILES: usize = 50;
11const MAX_EVIDENCE: usize = 500;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14/// Persistent session state tracking task, findings, files, decisions, and stats.
15#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct SessionState {
17    pub id: String,
18    pub version: u32,
19    pub started_at: DateTime<Utc>,
20    pub updated_at: DateTime<Utc>,
21    pub project_root: Option<String>,
22    #[serde(default)]
23    pub shell_cwd: Option<String>,
24    pub task: Option<TaskInfo>,
25    pub findings: Vec<Finding>,
26    pub decisions: Vec<Decision>,
27    pub files_touched: Vec<FileTouched>,
28    pub test_results: Option<TestSnapshot>,
29    pub progress: Vec<ProgressEntry>,
30    pub next_steps: Vec<String>,
31    #[serde(default)]
32    pub evidence: Vec<EvidenceRecord>,
33    #[serde(default)]
34    pub intents: Vec<IntentRecord>,
35    #[serde(default)]
36    pub active_structured_intent: Option<crate::core::intent_engine::StructuredIntent>,
37    pub stats: SessionStats,
38    /// When true, resume / compaction prompts encourage concise model replies.
39    #[serde(default)]
40    pub terse_mode: bool,
41    /// Unified compression level label (off/lite/standard/max).
42    #[serde(default)]
43    pub compression_level: String,
44}
45
46/// Description of the current task being worked on, with optional progress tracking.
47#[derive(Serialize, Deserialize, Clone, Debug)]
48pub struct TaskInfo {
49    pub description: String,
50    pub intent: Option<String>,
51    pub progress_pct: Option<u8>,
52}
53
54/// A discovery or observation recorded during the session.
55#[derive(Serialize, Deserialize, Clone, Debug)]
56pub struct Finding {
57    pub file: Option<String>,
58    pub line: Option<u32>,
59    pub summary: String,
60    pub timestamp: DateTime<Utc>,
61}
62
63/// A design or implementation decision made during the session.
64#[derive(Serialize, Deserialize, Clone, Debug)]
65pub struct Decision {
66    pub summary: String,
67    pub rationale: Option<String>,
68    pub timestamp: DateTime<Utc>,
69}
70
71/// A file that was read or modified during the session.
72#[derive(Serialize, Deserialize, Clone, Debug)]
73pub struct FileTouched {
74    pub path: String,
75    pub file_ref: Option<String>,
76    pub read_count: u32,
77    pub modified: bool,
78    pub last_mode: String,
79    pub tokens: usize,
80    #[serde(default)]
81    pub stale: bool,
82    #[serde(default)]
83    pub context_item_id: Option<String>,
84}
85
86/// Snapshot of a test run with pass/fail counts.
87#[derive(Serialize, Deserialize, Clone, Debug)]
88pub struct TestSnapshot {
89    pub command: String,
90    pub passed: u32,
91    pub failed: u32,
92    pub total: u32,
93    pub timestamp: DateTime<Utc>,
94}
95
96/// A timestamped progress entry describing an action taken.
97#[derive(Serialize, Deserialize, Clone, Debug)]
98pub struct ProgressEntry {
99    pub action: String,
100    pub detail: Option<String>,
101    pub timestamp: DateTime<Utc>,
102}
103
104/// Source of an evidence record: automatic tool call or manual agent entry.
105#[derive(Serialize, Deserialize, Clone, Debug)]
106#[serde(rename_all = "snake_case")]
107pub enum EvidenceKind {
108    ToolCall,
109    Manual,
110}
111
112/// An auditable record of a tool invocation or manual observation.
113#[derive(Serialize, Deserialize, Clone, Debug)]
114pub struct EvidenceRecord {
115    pub kind: EvidenceKind,
116    pub key: String,
117    pub value: Option<String>,
118    pub tool: Option<String>,
119    pub input_md5: Option<String>,
120    pub output_md5: Option<String>,
121    pub agent_id: Option<String>,
122    pub client_name: Option<String>,
123    pub timestamp: DateTime<Utc>,
124}
125
126/// Aggregate counters for the session: tool calls, token savings, cache hits.
127#[derive(Serialize, Deserialize, Clone, Debug, Default)]
128#[serde(default)]
129pub struct SessionStats {
130    pub total_tool_calls: u32,
131    pub total_tokens_saved: u64,
132    pub total_tokens_input: u64,
133    pub cache_hits: u32,
134    pub files_read: u32,
135    pub commands_run: u32,
136    pub intents_inferred: u32,
137    pub intents_explicit: u32,
138    pub unsaved_changes: u32,
139}
140
141#[derive(Serialize, Deserialize, Clone, Debug)]
142struct LatestPointer {
143    id: String,
144}
145
146/// Pre-serialized session data ready for background disk I/O.
147/// Created by `SessionState::prepare_save()` while holding the write lock,
148/// then written via `write_to_disk()` after the lock is released.
149pub struct PreparedSave {
150    dir: PathBuf,
151    id: String,
152    json: String,
153    pointer_json: String,
154    compaction_snapshot: Option<String>,
155}
156
157impl PreparedSave {
158    /// Writes the pre-serialized session data, latest pointer, and compaction
159    /// snapshot to disk atomically.
160    pub fn write_to_disk(self) -> Result<(), String> {
161        if !self.dir.exists() {
162            std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
163        }
164        let path = self.dir.join(format!("{}.json", self.id));
165        let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
166        std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
167        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
168
169        let latest_path = self.dir.join("latest.json");
170        let latest_tmp = self.dir.join(".latest.json.tmp");
171        std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
172        std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
173
174        if let Some(snapshot) = self.compaction_snapshot {
175            let snap_path = self.dir.join(format!("{}_snapshot.txt", self.id));
176            let _ = std::fs::write(&snap_path, &snapshot);
177        }
178        Ok(())
179    }
180}
181
182impl Default for SessionState {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl SessionState {
189    /// Creates a new session with a unique ID and current timestamp.
190    pub fn new() -> Self {
191        let now = Utc::now();
192        Self {
193            id: generate_session_id(),
194            version: 0,
195            started_at: now,
196            updated_at: now,
197            project_root: None,
198            shell_cwd: None,
199            task: None,
200            findings: Vec::new(),
201            decisions: Vec::new(),
202            files_touched: Vec::new(),
203            test_results: None,
204            progress: Vec::new(),
205            next_steps: Vec::new(),
206            evidence: Vec::new(),
207            intents: Vec::new(),
208            active_structured_intent: None,
209            stats: SessionStats::default(),
210            terse_mode: false,
211            compression_level: String::new(),
212        }
213        .with_compression_from_config()
214    }
215
216    fn with_compression_from_config(mut self) -> Self {
217        let level = if let Some(env_level) = crate::core::config::CompressionLevel::from_env() {
218            env_level
219        } else {
220            let profile = crate::core::profiles::active_profile();
221            let terse = profile.compression.terse_mode_effective();
222            let density = profile.compression.output_density_effective();
223            let od = match density {
224                "ultra" => crate::core::config::OutputDensity::Ultra,
225                "terse" => crate::core::config::OutputDensity::Terse,
226                _ => crate::core::config::OutputDensity::Normal,
227            };
228            let ta = if terse {
229                crate::core::config::TerseAgent::Full
230            } else {
231                crate::core::config::TerseAgent::Off
232            };
233            crate::core::config::CompressionLevel::from_legacy(&ta, &od)
234        };
235        self.compression_level = level.label().to_string();
236        self.terse_mode = level.is_active();
237        self
238    }
239
240    /// Bumps the version counter and marks the session as dirty.
241    pub fn increment(&mut self) {
242        self.version += 1;
243        self.updated_at = Utc::now();
244        self.stats.unsaved_changes += 1;
245    }
246
247    /// Returns `true` if enough changes have accumulated to warrant a disk save.
248    pub fn should_save(&self) -> bool {
249        self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
250    }
251
252    /// Sets the active task and infers a structured intent from the description.
253    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
254        self.task = Some(TaskInfo {
255            description: description.to_string(),
256            intent: intent.map(std::string::ToString::to_string),
257            progress_pct: None,
258        });
259
260        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
261        let si = if touched.is_empty() {
262            crate::core::intent_engine::StructuredIntent::from_query(description)
263        } else {
264            crate::core::intent_engine::StructuredIntent::from_query_with_session(
265                description,
266                &touched,
267            )
268        };
269        if si.confidence >= 0.7 {
270            self.active_structured_intent = Some(si);
271        }
272
273        self.increment();
274    }
275
276    /// Records a finding (discovery or observation) in the session log.
277    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
278        self.findings.push(Finding {
279            file: file.map(std::string::ToString::to_string),
280            line,
281            summary: summary.to_string(),
282            timestamp: Utc::now(),
283        });
284        while self.findings.len() > MAX_FINDINGS {
285            self.findings.remove(0);
286        }
287        self.increment();
288    }
289
290    /// Records a design or implementation decision with optional rationale.
291    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
292        self.decisions.push(Decision {
293            summary: summary.to_string(),
294            rationale: rationale.map(std::string::ToString::to_string),
295            timestamp: Utc::now(),
296        });
297        while self.decisions.len() > MAX_DECISIONS {
298            self.decisions.remove(0);
299        }
300        self.increment();
301    }
302
303    /// Records a file read/access in the session, incrementing its read count.
304    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
305        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
306            existing.read_count += 1;
307            existing.last_mode = mode.to_string();
308            existing.tokens = tokens;
309            if let Some(r) = file_ref {
310                existing.file_ref = Some(r.to_string());
311            }
312        } else {
313            let item_id = crate::core::context_field::ContextItemId::from_file(path);
314            self.files_touched.push(FileTouched {
315                path: path.to_string(),
316                file_ref: file_ref.map(std::string::ToString::to_string),
317                read_count: 1,
318                modified: false,
319                last_mode: mode.to_string(),
320                tokens,
321                stale: false,
322                context_item_id: Some(item_id.to_string()),
323            });
324            while self.files_touched.len() > MAX_FILES {
325                self.files_touched.remove(0);
326            }
327        }
328        self.stats.files_read += 1;
329        self.increment();
330    }
331
332    /// Marks a previously touched file as modified (written to).
333    pub fn mark_modified(&mut self, path: &str) {
334        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
335            existing.modified = true;
336        }
337        self.increment();
338    }
339
340    /// Increments the tool call counter and accumulates token savings.
341    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
342        self.stats.total_tool_calls += 1;
343        self.stats.total_tokens_saved += tokens_saved;
344        self.stats.total_tokens_input += tokens_input;
345    }
346
347    /// Records an inferred or explicit intent, coalescing consecutive duplicates.
348    pub fn record_intent(&mut self, mut intent: IntentRecord) {
349        if intent.occurrences == 0 {
350            intent.occurrences = 1;
351        }
352
353        if let Some(last) = self.intents.last_mut() {
354            if last.fingerprint() == intent.fingerprint() {
355                last.occurrences = last.occurrences.saturating_add(intent.occurrences);
356                last.timestamp = intent.timestamp;
357                match intent.source {
358                    IntentSource::Inferred => self.stats.intents_inferred += 1,
359                    IntentSource::Explicit => self.stats.intents_explicit += 1,
360                }
361                self.increment();
362                return;
363            }
364        }
365
366        match intent.source {
367            IntentSource::Inferred => self.stats.intents_inferred += 1,
368            IntentSource::Explicit => self.stats.intents_explicit += 1,
369        }
370
371        self.intents.push(intent);
372        while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
373            self.intents.remove(0);
374        }
375        self.increment();
376    }
377
378    /// Appends an auditable evidence record for a tool invocation.
379    pub fn record_tool_receipt(
380        &mut self,
381        tool: &str,
382        action: Option<&str>,
383        input_md5: &str,
384        output_md5: &str,
385        agent_id: Option<&str>,
386        client_name: Option<&str>,
387    ) {
388        let now = Utc::now();
389        let mut push = |key: String| {
390            self.evidence.push(EvidenceRecord {
391                kind: EvidenceKind::ToolCall,
392                key,
393                value: None,
394                tool: Some(tool.to_string()),
395                input_md5: Some(input_md5.to_string()),
396                output_md5: Some(output_md5.to_string()),
397                agent_id: agent_id.map(std::string::ToString::to_string),
398                client_name: client_name.map(std::string::ToString::to_string),
399                timestamp: now,
400            });
401        };
402
403        push(format!("tool:{tool}"));
404        if let Some(a) = action {
405            push(format!("tool:{tool}:{a}"));
406        }
407        while self.evidence.len() > MAX_EVIDENCE {
408            self.evidence.remove(0);
409        }
410        self.increment();
411    }
412
413    /// Appends a manual (non-tool) evidence record to the audit log.
414    pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
415        self.evidence.push(EvidenceRecord {
416            kind: EvidenceKind::Manual,
417            key: key.to_string(),
418            value: value.map(std::string::ToString::to_string),
419            tool: None,
420            input_md5: None,
421            output_md5: None,
422            agent_id: None,
423            client_name: None,
424            timestamp: Utc::now(),
425        });
426        while self.evidence.len() > MAX_EVIDENCE {
427            self.evidence.remove(0);
428        }
429        self.increment();
430    }
431
432    /// Returns `true` if an evidence record with the given key exists.
433    pub fn has_evidence_key(&self, key: &str) -> bool {
434        self.evidence.iter().any(|e| e.key == key)
435    }
436
437    /// Increments the session-level cache hit counter.
438    pub fn record_cache_hit(&mut self) {
439        self.stats.cache_hits += 1;
440    }
441
442    /// Increments the session-level command counter.
443    pub fn record_command(&mut self) {
444        self.stats.commands_run += 1;
445    }
446
447    /// Returns the effective working directory for shell commands.
448    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd.
449    /// Explicit CWD and stored shell_cwd are jail-checked against the project root
450    /// to prevent MCP clients from escaping the workspace.
451    pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
452        let root = self.project_root.as_deref().unwrap_or(".");
453        if let Some(cwd) = explicit_cwd {
454            if !cwd.is_empty() && cwd != "." {
455                return Self::jail_cwd(cwd, root);
456            }
457        }
458        if let Some(ref cwd) = self.shell_cwd {
459            return cwd.clone();
460        }
461        if let Some(ref r) = self.project_root {
462            return r.clone();
463        }
464        std::env::current_dir()
465            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
466    }
467
468    /// Verifies that `candidate` is within the project jail.
469    /// Falls back to `fallback_root` if the candidate escapes.
470    fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
471        let p = std::path::Path::new(candidate);
472        match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
473            Ok(jailed) => jailed.to_string_lossy().to_string(),
474            Err(_) => fallback_root.to_string(),
475        }
476    }
477
478    /// Updates shell_cwd by detecting `cd` in the command.
479    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
480    /// `cd ..`, and chained commands like `cd foo && ...`.
481    /// The new CWD is jail-checked against the project root.
482    pub fn update_shell_cwd(&mut self, command: &str) {
483        let base = self.effective_cwd(None);
484        if let Some(new_cwd) = extract_cd_target(command, &base) {
485            let path = std::path::Path::new(&new_cwd);
486            if path.exists() && path.is_dir() {
487                let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
488                    .to_string_lossy()
489                    .to_string();
490                let root = self.project_root.as_deref().unwrap_or(".");
491                if crate::core::pathjail::jail_path(
492                    std::path::Path::new(&canonical),
493                    std::path::Path::new(root),
494                )
495                .is_ok()
496                {
497                    self.shell_cwd = Some(canonical);
498                }
499            }
500        }
501    }
502
503    /// Formats the session state as a compact multi-line summary for agent context.
504    pub fn format_compact(&self) -> String {
505        let duration = self.updated_at - self.started_at;
506        let hours = duration.num_hours();
507        let mins = duration.num_minutes() % 60;
508        let duration_str = if hours > 0 {
509            format!("{hours}h {mins}m")
510        } else {
511            format!("{mins}m")
512        };
513
514        let mut lines = Vec::new();
515        lines.push(format!(
516            "SESSION v{} | {} | {} calls | {} tok saved",
517            self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
518        ));
519
520        if let Some(ref task) = self.task {
521            let pct = task
522                .progress_pct
523                .map_or(String::new(), |p| format!(" [{p}%]"));
524            lines.push(format!("Task: {}{pct}", task.description));
525        }
526
527        if let Some(ref root) = self.project_root {
528            lines.push(format!("Root: {}", shorten_path(root)));
529        }
530
531        if !self.findings.is_empty() {
532            let items: Vec<String> = self
533                .findings
534                .iter()
535                .rev()
536                .take(5)
537                .map(|f| {
538                    let loc = match (&f.file, f.line) {
539                        (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
540                        (Some(file), None) => shorten_path(file),
541                        _ => String::new(),
542                    };
543                    if loc.is_empty() {
544                        f.summary.clone()
545                    } else {
546                        format!("{loc} \u{2014} {}", f.summary)
547                    }
548                })
549                .collect();
550            lines.push(format!(
551                "Findings ({}): {}",
552                self.findings.len(),
553                items.join(" | ")
554            ));
555        }
556
557        if !self.decisions.is_empty() {
558            let items: Vec<&str> = self
559                .decisions
560                .iter()
561                .rev()
562                .take(3)
563                .map(|d| d.summary.as_str())
564                .collect();
565            lines.push(format!("Decisions: {}", items.join(" | ")));
566        }
567
568        if !self.files_touched.is_empty() {
569            let items: Vec<String> = self
570                .files_touched
571                .iter()
572                .rev()
573                .take(10)
574                .map(|f| {
575                    let status = if f.modified { "mod" } else { &f.last_mode };
576                    let r = f.file_ref.as_deref().unwrap_or("?");
577                    format!("[{r} {} {status}]", shorten_path(&f.path))
578                })
579                .collect();
580            lines.push(format!(
581                "Files ({}): {}",
582                self.files_touched.len(),
583                items.join(" ")
584            ));
585        }
586
587        if let Some(ref tests) = self.test_results {
588            lines.push(format!(
589                "Tests: {}/{} pass ({})",
590                tests.passed, tests.total, tests.command
591            ));
592        }
593
594        if !self.next_steps.is_empty() {
595            lines.push(format!("Next: {}", self.next_steps.join(" | ")));
596        }
597
598        lines.join("\n")
599    }
600
601    /// Builds a size-limited XML snapshot of session state for context compaction.
602    pub fn build_compaction_snapshot(&self) -> String {
603        const MAX_SNAPSHOT_BYTES: usize = 2048;
604
605        let mut sections: Vec<(u8, String)> = Vec::new();
606
607        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
608            .unwrap_or_default();
609        if let Some(tag) = crate::core::terse::agent_prompts::session_context_tag(&level) {
610            sections.push((0, tag));
611        }
612
613        if let Some(ref task) = self.task {
614            let pct = task
615                .progress_pct
616                .map_or(String::new(), |p| format!(" [{p}%]"));
617            sections.push((1, format!("<task>{}{pct}</task>", task.description)));
618        }
619
620        if !self.files_touched.is_empty() {
621            let modified: Vec<&str> = self
622                .files_touched
623                .iter()
624                .filter(|f| f.modified)
625                .map(|f| f.path.as_str())
626                .collect();
627            let read_only: Vec<&str> = self
628                .files_touched
629                .iter()
630                .filter(|f| !f.modified)
631                .take(10)
632                .map(|f| f.path.as_str())
633                .collect();
634            let mut files_section = String::new();
635            if !modified.is_empty() {
636                files_section.push_str(&format!("Modified: {}", modified.join(", ")));
637            }
638            if !read_only.is_empty() {
639                if !files_section.is_empty() {
640                    files_section.push_str(" | ");
641                }
642                files_section.push_str(&format!("Read: {}", read_only.join(", ")));
643            }
644            sections.push((1, format!("<files>{files_section}</files>")));
645        }
646
647        if !self.decisions.is_empty() {
648            let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
649            sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
650        }
651
652        if !self.findings.is_empty() {
653            let items: Vec<String> = self
654                .findings
655                .iter()
656                .rev()
657                .take(5)
658                .map(|f| f.summary.clone())
659                .collect();
660            sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
661        }
662
663        if !self.progress.is_empty() {
664            let items: Vec<String> = self
665                .progress
666                .iter()
667                .rev()
668                .take(5)
669                .map(|p| {
670                    let detail = p.detail.as_deref().unwrap_or("");
671                    if detail.is_empty() {
672                        p.action.clone()
673                    } else {
674                        format!("{}: {detail}", p.action)
675                    }
676                })
677                .collect();
678            sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
679        }
680
681        if let Some(ref tests) = self.test_results {
682            sections.push((
683                3,
684                format!(
685                    "<tests>{}/{} pass ({})</tests>",
686                    tests.passed, tests.total, tests.command
687                ),
688            ));
689        }
690
691        if !self.next_steps.is_empty() {
692            sections.push((
693                3,
694                format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
695            ));
696        }
697
698        sections.push((
699            4,
700            format!(
701                "<stats>calls={} saved={}tok</stats>",
702                self.stats.total_tool_calls, self.stats.total_tokens_saved
703            ),
704        ));
705
706        sections.sort_by_key(|(priority, _)| *priority);
707
708        const SNAPSHOT_HARD_CAP: usize = 2200;
709        const CLOSE_TAG: &str = "</session_snapshot>";
710        let open_len = "<session_snapshot>\n".len();
711        let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
712
713        let mut snapshot = String::from("<session_snapshot>\n");
714        for (_, section) in &sections {
715            if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
716                break;
717            }
718            snapshot.push_str(section);
719            snapshot.push('\n');
720        }
721
722        let used = snapshot.len().saturating_sub(open_len);
723        let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
724        if suffix_budget > 64 {
725            let suffix = self.build_compaction_structured_suffix(suffix_budget);
726            if !suffix.is_empty() {
727                snapshot.push_str(&suffix);
728                if !suffix.ends_with('\n') {
729                    snapshot.push('\n');
730                }
731            }
732        }
733
734        snapshot.push_str(CLOSE_TAG);
735        snapshot
736    }
737
738    /// Structured recovery hints (search/read/knowledge/graph) appended after legacy snapshot lines.
739    fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
740        if max_bytes <= 64 {
741            return String::new();
742        }
743
744        let mut recovery_queries: Vec<String> = Vec::new();
745        for ft in self.files_touched.iter().rev().take(12) {
746            let path_esc = escape_xml_attr(&ft.path);
747            let mode = if ft.last_mode.is_empty() {
748                "map".to_string()
749            } else {
750                escape_xml_attr(&ft.last_mode)
751            };
752            recovery_queries.push(format!(
753                r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
754            ));
755            let pattern = file_stem_search_pattern(&ft.path);
756            if !pattern.is_empty() {
757                let search_dir = parent_dir_slash(&ft.path);
758                let pat_esc = escape_xml_attr(&pattern);
759                let dir_esc = escape_xml_attr(&search_dir);
760                recovery_queries.push(format!(
761                    r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
762                ));
763            }
764        }
765
766        let mut parts: Vec<String> = Vec::new();
767        if !recovery_queries.is_empty() {
768            parts.push(format!(
769                "<recovery_queries>\n{}\n</recovery_queries>",
770                recovery_queries.join("\n")
771            ));
772        }
773
774        let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
775        if knowledge_ok {
776            if let Some(q) = self.knowledge_recall_query_stem() {
777                let q_esc = escape_xml_attr(&q);
778                parts.push(format!(
779                    "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
780                ));
781            }
782        }
783
784        if let Some(root) = self
785            .project_root
786            .as_deref()
787            .filter(|r| !r.trim().is_empty())
788        {
789            let root_trim = root.trim_end_matches('/');
790            let mut cluster_lines: Vec<String> = Vec::new();
791            for ft in self.files_touched.iter().rev().take(3) {
792                let primary_esc = escape_xml_attr(&ft.path);
793                let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
794                let related_csv =
795                    graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
796                        .map(|s| escape_xml_attr(&s))
797                        .unwrap_or_default();
798                if related_csv.is_empty() {
799                    continue;
800                }
801                cluster_lines.push(format!(
802                    r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
803                ));
804            }
805            if !cluster_lines.is_empty() {
806                parts.push(format!(
807                    "<graph_context>\n{}\n</graph_context>",
808                    cluster_lines.join("\n")
809                ));
810            }
811        }
812
813        Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
814    }
815
816    fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
817        let mut out = parts.join("\n");
818        while out.len() > max_bytes && !parts.is_empty() {
819            parts.pop();
820            out = parts.join("\n");
821        }
822        if out.len() <= max_bytes {
823            return out;
824        }
825        if let Some(idx) = parts
826            .iter()
827            .position(|p| p.starts_with("<recovery_queries>"))
828        {
829            let mut lines: Vec<String> = parts[idx]
830                .lines()
831                .filter(|l| l.starts_with("<query "))
832                .map(str::to_string)
833                .collect();
834            while !lines.is_empty() && out.len() > max_bytes {
835                if lines.len() == 1 {
836                    parts.remove(idx);
837                    out = parts.join("\n");
838                    break;
839                }
840                lines.truncate(lines.len().saturating_sub(2));
841                parts[idx] = format!(
842                    "<recovery_queries>\n{}\n</recovery_queries>",
843                    lines.join("\n")
844                );
845                out = parts.join("\n");
846            }
847        }
848        if out.len() > max_bytes {
849            return String::new();
850        }
851        out
852    }
853
854    fn knowledge_recall_query_stem(&self) -> Option<String> {
855        let mut bits: Vec<String> = Vec::new();
856        if let Some(ref t) = self.task {
857            bits.push(Self::task_keyword_stem(&t.description));
858        }
859        if bits.iter().all(std::string::String::is_empty) {
860            if let Some(f) = self.findings.last() {
861                bits.push(Self::task_keyword_stem(&f.summary));
862            } else if let Some(d) = self.decisions.last() {
863                bits.push(Self::task_keyword_stem(&d.summary));
864            }
865        }
866        let q = bits.join(" ").trim().to_string();
867        if q.is_empty() {
868            None
869        } else {
870            Some(q)
871        }
872    }
873
874    fn task_keyword_stem(text: &str) -> String {
875        const STOP: &[&str] = &[
876            "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
877            "be", "this", "that", "it", "as", "at", "by", "from",
878        ];
879        text.split_whitespace()
880            .filter_map(|w| {
881                let w = w.trim_matches(|c: char| !c.is_alphanumeric());
882                if w.len() < 3 {
883                    return None;
884                }
885                let lower = w.to_lowercase();
886                if STOP.contains(&lower.as_str()) {
887                    return None;
888                }
889                Some(w.to_string())
890            })
891            .take(8)
892            .collect::<Vec<_>>()
893            .join(" ")
894    }
895
896    /// Writes the compaction snapshot to disk and returns the snapshot string.
897    pub fn save_compaction_snapshot(&self) -> Result<String, String> {
898        let snapshot = self.build_compaction_snapshot();
899        let dir = sessions_dir().ok_or("cannot determine home directory")?;
900        if !dir.exists() {
901            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
902        }
903        let path = dir.join(format!("{}_snapshot.txt", self.id));
904        std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
905        Ok(snapshot)
906    }
907
908    /// Loads a previously saved compaction snapshot by session ID.
909    pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
910        let dir = sessions_dir()?;
911        let path = dir.join(format!("{session_id}_snapshot.txt"));
912        std::fs::read_to_string(&path).ok()
913    }
914
915    /// Loads the most recently modified compaction snapshot from disk.
916    ///
917    /// When a project root can be derived from CWD, only snapshots whose
918    /// embedded session data matches the project root are considered. This
919    /// prevents cross-project snapshot leakage.
920    pub fn load_latest_snapshot() -> Option<String> {
921        let dir = sessions_dir()?;
922        let project_root = std::env::current_dir()
923            .ok()
924            .map(|p| p.to_string_lossy().to_string());
925
926        let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
927            .ok()?
928            .filter_map(std::result::Result::ok)
929            .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
930            .filter_map(|e| {
931                let meta = e.metadata().ok()?;
932                let modified = meta.modified().ok()?;
933
934                if let Some(ref root) = project_root {
935                    let content = std::fs::read_to_string(e.path()).ok()?;
936                    if !content.contains(root) {
937                        return None;
938                    }
939                }
940
941                Some((modified, e.path()))
942            })
943            .collect();
944
945        snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
946        snapshots
947            .first()
948            .and_then(|(_, path)| std::fs::read_to_string(path).ok())
949    }
950
951    /// Build a compact resume block for post-compaction injection.
952    /// Max ~500 tokens. Includes task, decisions, files, and archive references.
953    pub fn build_resume_block(&self) -> String {
954        let mut parts: Vec<String> = Vec::new();
955
956        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
957            .unwrap_or_default();
958        if let Some(hint) = crate::core::terse::agent_prompts::resume_block_hint(&level) {
959            parts.push(hint);
960        }
961
962        if let Some(ref root) = self.project_root {
963            let short = root.rsplit('/').next().unwrap_or(root);
964            parts.push(format!("Project: {short}"));
965        }
966
967        if let Some(ref task) = self.task {
968            let pct = task
969                .progress_pct
970                .map_or(String::new(), |p| format!(" [{p}%]"));
971            parts.push(format!("Task: {}{pct}", task.description));
972        }
973
974        if !self.decisions.is_empty() {
975            let items: Vec<&str> = self
976                .decisions
977                .iter()
978                .rev()
979                .take(5)
980                .map(|d| d.summary.as_str())
981                .collect();
982            parts.push(format!("Decisions: {}", items.join("; ")));
983        }
984
985        if !self.files_touched.is_empty() {
986            let modified: Vec<&str> = self
987                .files_touched
988                .iter()
989                .filter(|f| f.modified)
990                .take(10)
991                .map(|f| f.path.as_str())
992                .collect();
993            if !modified.is_empty() {
994                parts.push(format!("Modified: {}", modified.join(", ")));
995            }
996        }
997
998        if !self.next_steps.is_empty() {
999            let steps: Vec<&str> = self
1000                .next_steps
1001                .iter()
1002                .take(3)
1003                .map(std::string::String::as_str)
1004                .collect();
1005            parts.push(format!("Next: {}", steps.join("; ")));
1006        }
1007
1008        let archives = super::archive::list_entries(Some(&self.id));
1009        if !archives.is_empty() {
1010            let hints: Vec<String> = archives
1011                .iter()
1012                .take(5)
1013                .map(|a| format!("{}({})", a.id, a.tool))
1014                .collect();
1015            parts.push(format!("Archives: {}", hints.join(", ")));
1016        }
1017
1018        parts.push(format!(
1019            "Stats: {} calls, {} tok saved",
1020            self.stats.total_tool_calls, self.stats.total_tokens_saved
1021        ));
1022
1023        format!(
1024            "--- SESSION RESUME (post-compaction) ---\n{}\n---",
1025            parts.join("\n")
1026        )
1027    }
1028
1029    /// Serializes and writes the session state to disk synchronously.
1030    pub fn save(&mut self) -> Result<(), String> {
1031        let prepared = self.prepare_save()?;
1032        match prepared.write_to_disk() {
1033            Ok(()) => Ok(()),
1034            Err(e) => {
1035                self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
1036                Err(e)
1037            }
1038        }
1039    }
1040
1041    /// Serialize session state while holding the lock (CPU-only), reset the
1042    /// unsaved counter, and return a `PreparedSave` whose I/O can be deferred
1043    /// to a background thread via `write_to_disk()`.
1044    pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
1045        let dir = sessions_dir().ok_or("cannot determine home directory")?;
1046        let compaction_snapshot = if self.stats.total_tool_calls > 0 {
1047            Some(self.build_compaction_snapshot())
1048        } else {
1049            None
1050        };
1051        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
1052        let pointer_json = serde_json::to_string(&LatestPointer {
1053            id: self.id.clone(),
1054        })
1055        .map_err(|e| e.to_string())?;
1056        self.stats.unsaved_changes = 0;
1057        Ok(PreparedSave {
1058            dir,
1059            id: self.id.clone(),
1060            json,
1061            pointer_json,
1062            compaction_snapshot,
1063        })
1064    }
1065
1066    /// Loads the most recent session from disk.
1067    ///
1068    /// Prefers the session matching the current working directory's project root.
1069    /// Falls back to the global `latest.json` pointer only if no project-scoped
1070    /// match is found. This prevents cross-project session leakage.
1071    pub fn load_latest() -> Option<Self> {
1072        if let Some(project_root) = std::env::current_dir()
1073            .ok()
1074            .map(|p| p.to_string_lossy().to_string())
1075        {
1076            if let Some(session) = Self::load_latest_for_project_root(&project_root) {
1077                return Some(session);
1078            }
1079        }
1080        let dir = sessions_dir()?;
1081        let latest_path = dir.join("latest.json");
1082        let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
1083        let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
1084        Self::load_by_id(&pointer.id)
1085    }
1086
1087    /// Loads the most recent session matching a specific project root.
1088    pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
1089        let dir = sessions_dir()?;
1090        let target_root =
1091            crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
1092        let mut latest_match: Option<Self> = None;
1093
1094        for entry in std::fs::read_dir(&dir).ok()?.flatten() {
1095            let path = entry.path();
1096            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1097                continue;
1098            }
1099            if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1100                continue;
1101            }
1102
1103            let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
1104                continue;
1105            };
1106            let Some(session) = Self::load_by_id(id) else {
1107                continue;
1108            };
1109
1110            if !session_matches_project_root(&session, &target_root) {
1111                continue;
1112            }
1113
1114            if latest_match
1115                .as_ref()
1116                .is_none_or(|existing| session.updated_at > existing.updated_at)
1117            {
1118                latest_match = Some(session);
1119            }
1120        }
1121
1122        latest_match
1123    }
1124
1125    /// Loads a specific session from disk by its unique ID.
1126    pub fn load_by_id(id: &str) -> Option<Self> {
1127        let dir = sessions_dir()?;
1128        let path = dir.join(format!("{id}.json"));
1129        let json = std::fs::read_to_string(&path).ok()?;
1130        let session: Self = serde_json::from_str(&json).ok()?;
1131        Some(normalize_loaded_session(session))
1132    }
1133
1134    /// Lists all saved sessions as summaries, sorted by most recently updated.
1135    pub fn list_sessions() -> Vec<SessionSummary> {
1136        let Some(dir) = sessions_dir() else {
1137            return Vec::new();
1138        };
1139
1140        let mut summaries = Vec::new();
1141        if let Ok(entries) = std::fs::read_dir(&dir) {
1142            for entry in entries.flatten() {
1143                let path = entry.path();
1144                if path.extension().and_then(|e| e.to_str()) != Some("json") {
1145                    continue;
1146                }
1147                if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1148                    continue;
1149                }
1150                if let Ok(json) = std::fs::read_to_string(&path) {
1151                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1152                        summaries.push(SessionSummary {
1153                            id: session.id,
1154                            started_at: session.started_at,
1155                            updated_at: session.updated_at,
1156                            version: session.version,
1157                            task: session.task.as_ref().map(|t| t.description.clone()),
1158                            tool_calls: session.stats.total_tool_calls,
1159                            tokens_saved: session.stats.total_tokens_saved,
1160                        });
1161                    }
1162                }
1163            }
1164        }
1165
1166        summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
1167        summaries
1168    }
1169
1170    /// Deletes sessions older than `max_age_days`, preserving the latest. Returns count removed.
1171    pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
1172        let Some(dir) = sessions_dir() else { return 0 };
1173
1174        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
1175        let latest = Self::load_latest().map(|s| s.id);
1176        let mut removed = 0u32;
1177
1178        if let Ok(entries) = std::fs::read_dir(&dir) {
1179            for entry in entries.flatten() {
1180                let path = entry.path();
1181                if path.extension().and_then(|e| e.to_str()) != Some("json") {
1182                    continue;
1183                }
1184                let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
1185                if filename == "latest" || filename.starts_with('.') {
1186                    continue;
1187                }
1188                if latest.as_deref() == Some(filename) {
1189                    continue;
1190                }
1191                if let Ok(json) = std::fs::read_to_string(&path) {
1192                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1193                        if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
1194                            removed += 1;
1195                        }
1196                    }
1197                }
1198            }
1199        }
1200
1201        removed
1202    }
1203}
1204
1205/// Lightweight summary of a session for listing purposes.
1206#[derive(Debug, Clone)]
1207pub struct SessionSummary {
1208    pub id: String,
1209    pub started_at: DateTime<Utc>,
1210    pub updated_at: DateTime<Utc>,
1211    pub version: u32,
1212    pub task: Option<String>,
1213    pub tool_calls: u32,
1214    pub tokens_saved: u64,
1215}
1216
1217fn escape_xml_attr(value: &str) -> String {
1218    value
1219        .replace('&', "&amp;")
1220        .replace('<', "&lt;")
1221        .replace('>', "&gt;")
1222        .replace('"', "&quot;")
1223}
1224
1225fn file_stem_search_pattern(path: &str) -> String {
1226    Path::new(path)
1227        .file_stem()
1228        .and_then(|s| s.to_str())
1229        .map(str::trim)
1230        .filter(|s| !s.is_empty() && s.chars().any(char::is_alphanumeric))
1231        .unwrap_or("")
1232        .to_string()
1233}
1234
1235fn parent_dir_slash(path: &str) -> String {
1236    Path::new(path)
1237        .parent()
1238        .and_then(|p| p.to_str())
1239        .map_or_else(
1240            || "./".to_string(),
1241            |p| {
1242                let norm = p.replace('\\', "/");
1243                let trimmed = norm.trim_end_matches('/');
1244                if trimmed.is_empty() {
1245                    "./".to_string()
1246                } else {
1247                    format!("{trimmed}/")
1248                }
1249            },
1250        )
1251}
1252
1253fn sessions_dir() -> Option<PathBuf> {
1254    crate::core::data_dir::lean_ctx_data_dir()
1255        .ok()
1256        .map(|d| d.join("sessions"))
1257}
1258
1259fn generate_session_id() -> String {
1260    static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1261    let now = Utc::now();
1262    let ts = now.format("%Y%m%d-%H%M%S").to_string();
1263    let nanos = now.timestamp_subsec_micros();
1264    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1265    format!("{ts}-{nanos:06}s{seq}")
1266}
1267
1268/// Extracts the `cd` target from a command string.
1269/// Handles patterns like `cd /foo`, `cd foo && bar`, `cd ../dir; cmd`, etc.
1270fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
1271    let first_cmd = command
1272        .split("&&")
1273        .next()
1274        .unwrap_or(command)
1275        .split(';')
1276        .next()
1277        .unwrap_or(command)
1278        .trim();
1279
1280    if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
1281        return None;
1282    }
1283
1284    let target = first_cmd.strip_prefix("cd")?.trim();
1285    if target.is_empty() || target == "~" {
1286        return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
1287    }
1288
1289    let target = target.trim_matches('"').trim_matches('\'');
1290    let path = std::path::Path::new(target);
1291
1292    if path.is_absolute() {
1293        Some(target.to_string())
1294    } else {
1295        let base = std::path::Path::new(base_cwd);
1296        let joined = base.join(target).to_string_lossy().to_string();
1297        Some(joined.replace('\\', "/"))
1298    }
1299}
1300
1301fn shorten_path(path: &str) -> String {
1302    let parts: Vec<&str> = path.split('/').collect();
1303    if parts.len() <= 2 {
1304        return path.to_string();
1305    }
1306    let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
1307    format!("…/{}/{}", last_two[1], last_two[0])
1308}
1309
1310fn normalize_loaded_session(mut session: SessionState) -> SessionState {
1311    if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
1312        session.project_root = None;
1313    }
1314    if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
1315        session.shell_cwd = None;
1316    }
1317
1318    // Heal stale project_root caused by agent/temp working directories.
1319    // If project_root doesn't look like a real project root but shell_cwd does, prefer shell_cwd.
1320    if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
1321        let root_p = std::path::Path::new(root);
1322        let cwd_p = std::path::Path::new(cwd);
1323        let root_looks_real = has_project_marker(root_p);
1324        let cwd_looks_real = has_project_marker(cwd_p);
1325
1326        if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
1327            session.project_root = Some(cwd.clone());
1328        }
1329    }
1330
1331    // Upgrade compression_level from config if session was created before unified levels.
1332    if session.compression_level.is_empty() {
1333        if session.terse_mode {
1334            session.compression_level = "lite".to_string();
1335        } else if let Some(env_level) = crate::core::config::CompressionLevel::from_env() {
1336            session.compression_level = env_level.label().to_string();
1337            session.terse_mode = env_level.is_active();
1338        } else {
1339            let profile = crate::core::profiles::active_profile();
1340            if profile.compression.terse_mode_effective() {
1341                session.compression_level = "lite".to_string();
1342                session.terse_mode = true;
1343            } else {
1344                session.compression_level = "off".to_string();
1345            }
1346        }
1347    } else if !session.terse_mode {
1348        let level =
1349            crate::core::config::CompressionLevel::from_str_label(&session.compression_level)
1350                .unwrap_or_default();
1351        session.terse_mode = level.is_active();
1352    }
1353
1354    session
1355}
1356
1357fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
1358    if let Some(root) = session.project_root.as_deref() {
1359        let root_path =
1360            crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
1361        if root_path == target_root {
1362            return true;
1363        }
1364        if has_project_marker(&root_path) {
1365            return false;
1366        }
1367    }
1368
1369    if let Some(cwd) = session.shell_cwd.as_deref() {
1370        let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
1371        return cwd_path == target_root || cwd_path.starts_with(target_root);
1372    }
1373
1374    false
1375}
1376
1377fn has_project_marker(dir: &std::path::Path) -> bool {
1378    const MARKERS: &[&str] = &[
1379        ".git",
1380        ".lean-ctx.toml",
1381        "Cargo.toml",
1382        "package.json",
1383        "go.mod",
1384        "pyproject.toml",
1385        ".planning",
1386    ];
1387    MARKERS.iter().any(|m| dir.join(m).exists())
1388}
1389
1390fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1391    let s = dir.to_string_lossy();
1392    s.contains("/.claude")
1393        || s.contains("/.codex")
1394        || s.contains("/var/folders/")
1395        || s.contains("/tmp/")
1396        || s.contains("\\.claude")
1397        || s.contains("\\.codex")
1398        || s.contains("\\AppData\\Local\\Temp")
1399        || s.contains("\\Temp\\")
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405
1406    #[test]
1407    fn extract_cd_absolute_path() {
1408        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1409        assert_eq!(result, Some("/usr/local/bin".to_string()));
1410    }
1411
1412    #[test]
1413    fn extract_cd_relative_path() {
1414        let result = extract_cd_target("cd subdir", "/home/user");
1415        assert_eq!(result, Some("/home/user/subdir".to_string()));
1416    }
1417
1418    #[test]
1419    fn extract_cd_with_chained_command() {
1420        let result = extract_cd_target("cd /tmp && ls", "/home/user");
1421        assert_eq!(result, Some("/tmp".to_string()));
1422    }
1423
1424    #[test]
1425    fn extract_cd_with_semicolon() {
1426        let result = extract_cd_target("cd /tmp; ls", "/home/user");
1427        assert_eq!(result, Some("/tmp".to_string()));
1428    }
1429
1430    #[test]
1431    fn extract_cd_parent_dir() {
1432        let result = extract_cd_target("cd ..", "/home/user/project");
1433        assert_eq!(result, Some("/home/user/project/..".to_string()));
1434    }
1435
1436    #[test]
1437    fn extract_cd_no_cd_returns_none() {
1438        let result = extract_cd_target("ls -la", "/home/user");
1439        assert!(result.is_none());
1440    }
1441
1442    #[test]
1443    fn extract_cd_bare_cd_goes_home() {
1444        let result = extract_cd_target("cd", "/home/user");
1445        assert!(result.is_some());
1446    }
1447
1448    #[test]
1449    fn effective_cwd_explicit_takes_priority() {
1450        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
1451        let sub = tmp.join("sub");
1452        let _ = std::fs::create_dir_all(&sub);
1453        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1454            .to_string_lossy()
1455            .to_string();
1456        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
1457            .to_string_lossy()
1458            .to_string();
1459
1460        let mut session = SessionState::new();
1461        session.project_root = Some(root_canon);
1462        let result = session.effective_cwd(Some(&sub_canon));
1463        assert_eq!(result, sub_canon);
1464        let _ = std::fs::remove_dir_all(&tmp);
1465    }
1466
1467    #[test]
1468    fn effective_cwd_explicit_outside_root_is_jailed() {
1469        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
1470        let _ = std::fs::create_dir_all(&tmp);
1471        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1472            .to_string_lossy()
1473            .to_string();
1474
1475        let mut session = SessionState::new();
1476        session.project_root = Some(root_canon.clone());
1477        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
1478        assert_eq!(result, root_canon);
1479        let _ = std::fs::remove_dir_all(&tmp);
1480    }
1481
1482    #[test]
1483    fn effective_cwd_shell_cwd_second_priority() {
1484        let mut session = SessionState::new();
1485        session.project_root = Some("/project".to_string());
1486        session.shell_cwd = Some("/project/src".to_string());
1487        assert_eq!(session.effective_cwd(None), "/project/src");
1488    }
1489
1490    #[test]
1491    fn effective_cwd_project_root_third_priority() {
1492        let mut session = SessionState::new();
1493        session.project_root = Some("/project".to_string());
1494        assert_eq!(session.effective_cwd(None), "/project");
1495    }
1496
1497    #[test]
1498    fn effective_cwd_dot_ignored() {
1499        let mut session = SessionState::new();
1500        session.project_root = Some("/project".to_string());
1501        assert_eq!(session.effective_cwd(Some(".")), "/project");
1502    }
1503
1504    #[test]
1505    fn compaction_snapshot_includes_compression_config_when_enabled() {
1506        let mut session = SessionState::new();
1507        session.compression_level = "standard".to_string();
1508        session.terse_mode = true;
1509        session.set_task("x", None);
1510        let snapshot = session.build_compaction_snapshot();
1511        assert!(snapshot.contains("<config compression=\"standard\" />"));
1512    }
1513
1514    #[test]
1515    fn resume_block_prefixes_compression_hint_when_enabled() {
1516        let mut session = SessionState::new();
1517        session.compression_level = "lite".to_string();
1518        session.terse_mode = true;
1519        let block = session.build_resume_block();
1520        assert!(block.contains("[COMPRESSION: lite]"));
1521    }
1522
1523    #[test]
1524    fn compaction_snapshot_includes_task() {
1525        let mut session = SessionState::new();
1526        session.set_task("fix auth bug", None);
1527        let snapshot = session.build_compaction_snapshot();
1528        assert!(snapshot.contains("<task>fix auth bug</task>"));
1529        assert!(snapshot.contains("<session_snapshot>"));
1530        assert!(snapshot.contains("</session_snapshot>"));
1531    }
1532
1533    #[test]
1534    fn compaction_snapshot_includes_files() {
1535        let mut session = SessionState::new();
1536        session.touch_file("src/auth.rs", None, "full", 500);
1537        session.files_touched[0].modified = true;
1538        session.touch_file("src/main.rs", None, "map", 100);
1539        let snapshot = session.build_compaction_snapshot();
1540        assert!(snapshot.contains("auth.rs"));
1541        assert!(snapshot.contains("<files>"));
1542    }
1543
1544    #[test]
1545    fn compaction_snapshot_includes_decisions() {
1546        let mut session = SessionState::new();
1547        session.add_decision("Use JWT RS256", None);
1548        let snapshot = session.build_compaction_snapshot();
1549        assert!(snapshot.contains("JWT RS256"));
1550        assert!(snapshot.contains("<decisions>"));
1551    }
1552
1553    #[test]
1554    fn compaction_snapshot_respects_size_limit() {
1555        let mut session = SessionState::new();
1556        session.set_task("a]task", None);
1557        for i in 0..100 {
1558            session.add_finding(
1559                Some(&format!("file{i}.rs")),
1560                Some(i),
1561                &format!("Finding number {i} with some detail text here"),
1562            );
1563        }
1564        let snapshot = session.build_compaction_snapshot();
1565        assert!(snapshot.len() <= 2200);
1566    }
1567
1568    #[test]
1569    fn compaction_snapshot_includes_stats() {
1570        let mut session = SessionState::new();
1571        session.stats.total_tool_calls = 42;
1572        session.stats.total_tokens_saved = 10000;
1573        let snapshot = session.build_compaction_snapshot();
1574        assert!(snapshot.contains("calls=42"));
1575        assert!(snapshot.contains("saved=10000"));
1576    }
1577}