Skip to main content

lean_ctx/core/
session.rs

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