vtcode_core/utils/
session_archive.rs

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            .unwrap()
421            .with_nanosecond(123_456_000)
422            .unwrap();
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            .unwrap()
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}