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