Skip to main content

vtcode_core/utils/session_archive/
search.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use regex::RegexBuilder;
6use serde::{Deserialize, Serialize};
7
8use super::list_recent_sessions;
9use crate::llm::provider::{MessageContent, MessageRole};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SearchResult {
13    pub session_id: String,
14    pub session_path: PathBuf,
15    pub timestamp: DateTime<Utc>,
16    pub message_index: usize,
17    pub role: MessageRole,
18    pub content_snippet: String,
19    pub score: f32, // Simple matching score (e.g., term frequency or just 1.0)
20}
21
22/// Search for a query string across recent sessions.
23/// Returns a list of `SearchResult` sorted by relevance (or recency).
24pub async fn search_sessions(query: &str, session_limit: usize, max_results: usize) -> Result<Vec<SearchResult>> {
25    if query.trim().is_empty() || max_results == 0 {
26        return Ok(Vec::new());
27    }
28
29    let listings = list_recent_sessions(session_limit).await?;
30    let matcher = RegexBuilder::new(&regex::escape(query))
31        .case_insensitive(true)
32        .build()
33        .context("failed to compile session search query")?;
34    let mut results = Vec::new();
35
36    for listing in listings {
37        for (idx, msg) in listing.snapshot.messages.iter().enumerate() {
38            let content = match &msg.content {
39                MessageContent::Text(t) => t.as_str(),
40                MessageContent::Parts(_) => continue,
41            };
42
43            if let Some(matched) = matcher.find(content) {
44                let snippet = search_result_snippet(content, matched.start(), matched.end());
45
46                results.push(SearchResult {
47                    session_id: listing.identifier(),
48                    session_path: listing.path.clone(),
49                    timestamp: listing.snapshot.started_at,
50                    message_index: idx,
51                    role: msg.role,
52                    content_snippet: snippet,
53                    score: 1.0,
54                });
55
56                if results.len() >= max_results {
57                    break;
58                }
59            }
60        }
61        if results.len() >= max_results {
62            break;
63        }
64    }
65
66    Ok(results)
67}
68
69fn search_result_snippet(content: &str, match_start: usize, match_end: usize) -> String {
70    const CONTEXT_BYTES: usize = 50;
71
72    let start = floor_char_boundary(content, match_start.saturating_sub(CONTEXT_BYTES));
73    let end = ceil_char_boundary(content, (match_end + CONTEXT_BYTES).min(content.len()));
74
75    let mut snippet = String::new();
76    if start > 0 {
77        snippet.push_str("...");
78    }
79    snippet.push_str(&content[start..end].replace('\n', " "));
80    if end < content.len() {
81        snippet.push_str("...");
82    }
83    snippet
84}
85
86fn floor_char_boundary(content: &str, mut index: usize) -> usize {
87    while index > 0 && !content.is_char_boundary(index) {
88        index -= 1;
89    }
90    index
91}
92
93fn ceil_char_boundary(content: &str, mut index: usize) -> usize {
94    while index < content.len() && !content.is_char_boundary(index) {
95        index += 1;
96    }
97    index
98}