1use crate::llm::provider::{Message, MessageRole};
2use crate::utils::dot_config::DotManager;
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::env;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process;
10
11const SESSION_FILE_PREFIX: &str = "session";
12const SESSION_FILE_EXTENSION: &str = "json";
13pub const SESSION_DIR_ENV: &str = "VT_SESSION_DIR";
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SessionArchiveMetadata {
17 pub workspace_label: String,
18 pub workspace_path: String,
19 pub model: String,
20 pub provider: String,
21 pub theme: String,
22 pub reasoning_effort: String,
23}
24
25impl SessionArchiveMetadata {
26 pub fn new(
27 workspace_label: impl Into<String>,
28 workspace_path: impl Into<String>,
29 model: impl Into<String>,
30 provider: impl Into<String>,
31 theme: impl Into<String>,
32 reasoning_effort: impl Into<String>,
33 ) -> Self {
34 Self {
35 workspace_label: workspace_label.into(),
36 workspace_path: workspace_path.into(),
37 model: model.into(),
38 provider: provider.into(),
39 theme: theme.into(),
40 reasoning_effort: reasoning_effort.into(),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct SessionMessage {
47 pub role: MessageRole,
48 pub content: String,
49 #[serde(default)]
50 pub tool_call_id: Option<String>,
51}
52
53impl SessionMessage {
54 pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
55 Self {
56 role,
57 content: content.into(),
58 tool_call_id: None,
59 }
60 }
61
62 pub fn with_tool_call_id(
63 role: MessageRole,
64 content: impl Into<String>,
65 tool_call_id: Option<String>,
66 ) -> Self {
67 Self {
68 role,
69 content: content.into(),
70 tool_call_id,
71 }
72 }
73}
74
75impl From<&Message> for SessionMessage {
76 fn from(message: &Message) -> Self {
77 Self {
78 role: message.role.clone(),
79 content: message.content.clone(),
80 tool_call_id: message.tool_call_id.clone(),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct SessionSnapshot {
87 pub metadata: SessionArchiveMetadata,
88 pub started_at: DateTime<Utc>,
89 pub ended_at: DateTime<Utc>,
90 pub total_messages: usize,
91 pub distinct_tools: Vec<String>,
92 pub transcript: Vec<String>,
93 #[serde(default)]
94 pub messages: Vec<SessionMessage>,
95}
96
97#[derive(Debug, Clone)]
98pub struct SessionListing {
99 pub path: PathBuf,
100 pub snapshot: SessionSnapshot,
101}
102
103impl SessionListing {
104 pub fn identifier(&self) -> String {
105 self.path
106 .file_stem()
107 .and_then(|value| value.to_str())
108 .map(|value| value.to_string())
109 .unwrap_or_else(|| self.path.display().to_string())
110 }
111
112 pub fn first_prompt_preview(&self) -> Option<String> {
113 self.preview_for_role(MessageRole::User)
114 }
115
116 pub fn first_reply_preview(&self) -> Option<String> {
117 self.preview_for_role(MessageRole::Assistant)
118 }
119
120 fn preview_for_role(&self, role: MessageRole) -> Option<String> {
121 self.snapshot
122 .messages
123 .iter()
124 .find(|message| message.role == role && !message.content.trim().is_empty())
125 .and_then(|message| {
126 message
127 .content
128 .lines()
129 .find_map(|line| {
130 let trimmed = line.trim();
131 if trimmed.is_empty() {
132 None
133 } else {
134 Some(trimmed)
135 }
136 })
137 .map(|line| truncate_preview(line, 80))
138 })
139 }
140}
141
142fn generate_unique_archive_path(
143 sessions_dir: &Path,
144 metadata: &SessionArchiveMetadata,
145 started_at: DateTime<Utc>,
146) -> PathBuf {
147 let sanitized_label = sanitize_component(&metadata.workspace_label);
148 let timestamp = started_at.format("%Y%m%dT%H%M%SZ").to_string();
149 let micros = started_at.timestamp_subsec_micros();
150 let pid = process::id();
151 let mut attempt = 0u32;
152
153 loop {
154 let suffix = if attempt == 0 {
155 String::new()
156 } else {
157 format!("-{:02}", attempt)
158 };
159 let file_name = format!(
160 "{}-{}-{}_{:06}-{:05}{}.{}",
161 SESSION_FILE_PREFIX,
162 sanitized_label,
163 timestamp,
164 micros,
165 pid,
166 suffix,
167 SESSION_FILE_EXTENSION
168 );
169 let candidate = sessions_dir.join(file_name);
170 if !candidate.exists() {
171 return candidate;
172 }
173 attempt = attempt.wrapping_add(1);
174 }
175}
176
177#[derive(Debug, Clone)]
178pub struct SessionArchive {
179 path: PathBuf,
180 metadata: SessionArchiveMetadata,
181 started_at: DateTime<Utc>,
182}
183
184impl SessionArchive {
185 pub fn new(metadata: SessionArchiveMetadata) -> Result<Self> {
186 let sessions_dir = resolve_sessions_dir()?;
187 let started_at = Utc::now();
188 let path = generate_unique_archive_path(&sessions_dir, &metadata, started_at);
189
190 Ok(Self {
191 path,
192 metadata,
193 started_at,
194 })
195 }
196
197 pub fn finalize(
198 &self,
199 transcript: Vec<String>,
200 total_messages: usize,
201 distinct_tools: Vec<String>,
202 messages: Vec<SessionMessage>,
203 ) -> Result<PathBuf> {
204 let snapshot = SessionSnapshot {
205 metadata: self.metadata.clone(),
206 started_at: self.started_at,
207 ended_at: Utc::now(),
208 total_messages,
209 distinct_tools,
210 transcript,
211 messages,
212 };
213
214 let payload = serde_json::to_string_pretty(&snapshot)
215 .context("failed to serialize session snapshot")?;
216 if let Some(parent) = self.path.parent() {
217 fs::create_dir_all(parent).with_context(|| {
218 format!("failed to create session directory: {}", parent.display())
219 })?;
220 }
221 fs::write(&self.path, payload)
222 .with_context(|| format!("failed to write session archive: {}", self.path.display()))?;
223
224 Ok(self.path.clone())
225 }
226
227 pub fn path(&self) -> &Path {
228 &self.path
229 }
230}
231
232pub fn list_recent_sessions(limit: usize) -> Result<Vec<SessionListing>> {
233 let sessions_dir = match resolve_sessions_dir() {
234 Ok(dir) => dir,
235 Err(_) => return Ok(Vec::new()),
236 };
237
238 if !sessions_dir.exists() {
239 return Ok(Vec::new());
240 }
241
242 let mut listings = Vec::new();
243 for entry in fs::read_dir(&sessions_dir).with_context(|| {
244 format!(
245 "failed to read session directory: {}",
246 sessions_dir.display()
247 )
248 })? {
249 let entry = entry.with_context(|| {
250 format!("failed to read session entry in {}", sessions_dir.display())
251 })?;
252 let path = entry.path();
253 if !is_session_file(&path) {
254 continue;
255 }
256
257 let data = fs::read_to_string(&path)
258 .with_context(|| format!("failed to read session file: {}", path.display()))?;
259 let snapshot: SessionSnapshot = match serde_json::from_str(&data) {
260 Ok(snapshot) => snapshot,
261 Err(_) => continue,
262 };
263 listings.push(SessionListing { path, snapshot });
264 }
265
266 listings.sort_by(|a, b| b.snapshot.ended_at.cmp(&a.snapshot.ended_at));
267 if limit > 0 && listings.len() > limit {
268 listings.truncate(limit);
269 }
270
271 Ok(listings)
272}
273
274fn resolve_sessions_dir() -> Result<PathBuf> {
275 if let Some(custom) = env::var_os(SESSION_DIR_ENV) {
276 let path = PathBuf::from(custom);
277 fs::create_dir_all(&path)
278 .with_context(|| format!("failed to create custom session dir: {}", path.display()))?;
279 return Ok(path);
280 }
281
282 let manager = DotManager::new().context("failed to load VTCode dot manager")?;
283 manager
284 .initialize()
285 .context("failed to initialize VTCode dot directory structure")?;
286 let dir = manager.sessions_dir();
287 fs::create_dir_all(&dir)
288 .with_context(|| format!("failed to create session directory: {}", dir.display()))?;
289 Ok(dir)
290}
291
292fn truncate_preview(input: &str, max_chars: usize) -> String {
293 if input.chars().count() <= max_chars {
294 return input.to_string();
295 }
296
297 let mut truncated = String::new();
298 for ch in input.chars().take(max_chars.saturating_sub(1)) {
299 truncated.push(ch);
300 }
301 truncated.push('…');
302 truncated
303}
304
305fn sanitize_component(value: &str) -> String {
306 let mut normalized = String::new();
307 let mut last_was_separator = false;
308 for ch in value.chars() {
309 if ch.is_ascii_alphanumeric() {
310 normalized.push(ch.to_ascii_lowercase());
311 last_was_separator = false;
312 } else if matches!(ch, '-' | '_') {
313 if !last_was_separator {
314 normalized.push(ch);
315 last_was_separator = true;
316 }
317 } else if !last_was_separator {
318 normalized.push('-');
319 last_was_separator = true;
320 }
321 }
322
323 let trimmed = normalized.trim_matches(|c| c == '-' || c == '_');
324 if trimmed.is_empty() {
325 "workspace".to_string()
326 } else {
327 trimmed.to_string()
328 }
329}
330
331fn is_session_file(path: &Path) -> bool {
332 path.extension()
333 .and_then(|ext| ext.to_str())
334 .map(|ext| ext.eq_ignore_ascii_case(SESSION_FILE_EXTENSION))
335 .unwrap_or(false)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use chrono::{TimeZone, Timelike};
342 use std::time::Duration;
343
344 struct EnvGuard {
345 key: &'static str,
346 }
347
348 impl EnvGuard {
349 fn set(key: &'static str, value: &Path) -> Self {
350 unsafe {
351 env::set_var(key, value);
352 }
353 Self { key }
354 }
355 }
356
357 impl Drop for EnvGuard {
358 fn drop(&mut self) {
359 unsafe {
360 env::remove_var(self.key);
361 }
362 }
363 }
364
365 #[test]
366 fn session_archive_persists_snapshot() -> Result<()> {
367 let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
368 let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
369
370 let metadata = SessionArchiveMetadata::new(
371 "ExampleWorkspace",
372 "/tmp/example",
373 "model-x",
374 "provider-y",
375 "dark",
376 "medium",
377 );
378 let archive = SessionArchive::new(metadata.clone())?;
379 let transcript = vec!["line one".to_string(), "line two".to_string()];
380 let messages = vec![
381 SessionMessage::new(MessageRole::User, "Hello world"),
382 SessionMessage::new(MessageRole::Assistant, "Hi there"),
383 ];
384 let path = archive.finalize(
385 transcript.clone(),
386 4,
387 vec!["tool_a".to_string()],
388 messages.clone(),
389 )?;
390
391 let stored = fs::read_to_string(&path)
392 .with_context(|| format!("failed to read stored session: {}", path.display()))?;
393 let snapshot: SessionSnapshot =
394 serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
395
396 assert_eq!(snapshot.metadata, metadata);
397 assert_eq!(snapshot.transcript, transcript);
398 assert_eq!(snapshot.total_messages, 4);
399 assert_eq!(snapshot.distinct_tools, vec!["tool_a".to_string()]);
400 assert_eq!(snapshot.messages, messages);
401 Ok(())
402 }
403
404 #[test]
405 fn session_archive_path_collision_adds_suffix() -> Result<()> {
406 let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
407 let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
408
409 let metadata = SessionArchiveMetadata::new(
410 "ExampleWorkspace",
411 "/tmp/example",
412 "model-x",
413 "provider-y",
414 "dark",
415 "medium",
416 );
417
418 let started_at = Utc
419 .with_ymd_and_hms(2025, 9, 25, 10, 15, 30)
420 .expect("valid datetime")
421 .with_nanosecond(123_456_000)
422 .expect("nanosecond set");
423
424 let first_path = generate_unique_archive_path(temp_dir.path(), &metadata, started_at);
425 fs::write(&first_path, "{}").context("failed to create sentinel file")?;
426
427 let second_path = generate_unique_archive_path(temp_dir.path(), &metadata, started_at);
428
429 assert_ne!(first_path, second_path);
430 let second_name = second_path
431 .file_name()
432 .and_then(|name| name.to_str())
433 .expect("file name");
434 assert!(second_name.contains("-01"));
435
436 Ok(())
437 }
438
439 #[test]
440 fn session_archive_filename_includes_microseconds_and_pid() -> Result<()> {
441 let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
442 let metadata = SessionArchiveMetadata::new(
443 "ExampleWorkspace",
444 "/tmp/example",
445 "model-x",
446 "provider-y",
447 "dark",
448 "medium",
449 );
450
451 let started_at = Utc
452 .with_ymd_and_hms(2025, 9, 25, 10, 15, 30)
453 .expect("valid datetime")
454 .with_nanosecond(654_321_000)
455 .expect("nanosecond set");
456
457 let path = generate_unique_archive_path(temp_dir.path(), &metadata, started_at);
458 let name = path
459 .file_name()
460 .and_then(|value| value.to_str())
461 .expect("file name string");
462
463 assert!(name.contains("20250925T101530Z_654321"));
464 let pid_fragment = format!("{:05}", process::id());
465 assert!(name.contains(&pid_fragment));
466
467 Ok(())
468 }
469
470 #[test]
471 fn list_recent_sessions_orders_entries() -> Result<()> {
472 let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
473 let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
474
475 let first_metadata = SessionArchiveMetadata::new(
476 "First",
477 "/tmp/first",
478 "model-a",
479 "provider-a",
480 "light",
481 "medium",
482 );
483 let first_archive = SessionArchive::new(first_metadata.clone())?;
484 first_archive.finalize(
485 vec!["first".to_string()],
486 1,
487 Vec::new(),
488 vec![SessionMessage::new(MessageRole::User, "First")],
489 )?;
490
491 std::thread::sleep(Duration::from_millis(10));
492
493 let second_metadata = SessionArchiveMetadata::new(
494 "Second",
495 "/tmp/second",
496 "model-b",
497 "provider-b",
498 "dark",
499 "high",
500 );
501 let second_archive = SessionArchive::new(second_metadata.clone())?;
502 second_archive.finalize(
503 vec!["second".to_string()],
504 2,
505 vec!["tool_b".to_string()],
506 vec![SessionMessage::new(MessageRole::User, "Second")],
507 )?;
508
509 let listings = list_recent_sessions(10)?;
510 assert_eq!(listings.len(), 2);
511 assert_eq!(listings[0].snapshot.metadata, second_metadata);
512 assert_eq!(listings[1].snapshot.metadata, first_metadata);
513 Ok(())
514 }
515
516 #[test]
517 fn listing_previews_return_first_non_empty_lines() {
518 let metadata = SessionArchiveMetadata::new(
519 "Workspace",
520 "/tmp/ws",
521 "model",
522 "provider",
523 "dark",
524 "medium",
525 );
526 let long_response = "response snippet ".repeat(6);
527 let snapshot = SessionSnapshot {
528 metadata,
529 started_at: Utc::now(),
530 ended_at: Utc::now(),
531 total_messages: 2,
532 distinct_tools: Vec::new(),
533 transcript: Vec::new(),
534 messages: vec![
535 SessionMessage::new(MessageRole::System, ""),
536 SessionMessage::new(MessageRole::User, " prompt line\nsecond"),
537 SessionMessage::new(MessageRole::Assistant, long_response.clone()),
538 ],
539 };
540 let listing = SessionListing {
541 path: PathBuf::from("session-workspace.json"),
542 snapshot,
543 };
544
545 assert_eq!(
546 listing.first_prompt_preview(),
547 Some("prompt line".to_string())
548 );
549 let expected = super::truncate_preview(&long_response, 80);
550 assert_eq!(listing.first_reply_preview(), Some(expected));
551 }
552}