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 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 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 §ions {
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 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
749fn 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}