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