Skip to main content

ck_search/mcp/
session.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::{SystemTime, UNIX_EPOCH};
7use tokio::sync::RwLock;
8use tracing::debug;
9use uuid::Uuid;
10
11use ck_core::{SearchOptions, SearchResult};
12
13/// Default session TTL in seconds (5 minutes)
14const DEFAULT_SESSION_TTL: u64 = 300;
15
16/// Maximum number of concurrent sessions
17const MAX_SESSIONS: usize = 100;
18
19/// Default page size for pagination
20const DEFAULT_PAGE_SIZE: usize = 50;
21
22/// Maximum page size to prevent excessive memory usage
23const MAX_PAGE_SIZE: usize = 200;
24
25/// Maximum snippet length to prevent excessive response size
26const MAX_SNIPPET_LENGTH: usize = 2000;
27
28/// Default snippet length
29const DEFAULT_SNIPPET_LENGTH: usize = 500;
30
31/// SearchSession stores cached search results for pagination
32#[derive(Debug, Clone)]
33pub struct SearchSession {
34    #[allow(dead_code)]
35    pub id: Uuid,
36    #[allow(dead_code)]
37    pub search_options: SearchOptions,
38    pub results: Vec<SearchResult>,
39    #[allow(dead_code)]
40    pub created_at: SystemTime,
41    pub last_accessed: SystemTime,
42    pub total_count: usize,
43    #[allow(dead_code)]
44    pub search_completed: bool,
45    pub search_params_hash: String,
46}
47
48/// Cursor for pagination - opaque to clients
49#[derive(Debug, Serialize, Deserialize)]
50pub struct PaginationCursor {
51    pub session_id: Uuid,
52    pub offset: usize,
53    pub search_params_hash: String,
54    pub timestamp: u64,
55    pub version: u32,
56    #[serde(default = "default_page_size")]
57    pub original_page_size: usize,
58}
59
60fn default_page_size() -> usize {
61    50 // Default page size for backward compatibility
62}
63
64/// Page of search results
65#[derive(Debug)]
66pub struct SearchPage {
67    pub matches: Vec<SearchResult>,
68    pub count: usize,
69    pub total_count: Option<usize>,
70    pub has_more: bool,
71    pub truncated: bool,
72    pub next_cursor: Option<String>,
73    pub current_page: usize,
74    pub original_page_size: usize,
75}
76
77/// Configuration for pagination
78#[derive(Debug, Clone)]
79pub struct PaginationConfig {
80    pub page_size: usize,
81    pub include_snippet: bool,
82    pub snippet_length: usize,
83    pub context_lines: usize,
84}
85
86impl Default for PaginationConfig {
87    fn default() -> Self {
88        Self {
89            page_size: DEFAULT_PAGE_SIZE,
90            include_snippet: true,
91            snippet_length: DEFAULT_SNIPPET_LENGTH,
92            context_lines: 0,
93        }
94    }
95}
96
97impl PaginationConfig {
98    /// Validate and clamp configuration values
99    pub fn validate(mut self) -> Self {
100        self.page_size = self.page_size.clamp(1, MAX_PAGE_SIZE);
101        self.snippet_length = self.snippet_length.min(MAX_SNIPPET_LENGTH);
102        self.context_lines = self.context_lines.min(10);
103        self
104    }
105}
106
107/// SessionManager handles search session lifecycle and pagination
108#[derive(Clone)]
109pub struct SessionManager {
110    sessions: Arc<RwLock<HashMap<Uuid, SearchSession>>>,
111    session_ttl: u64,
112}
113
114impl Default for SessionManager {
115    fn default() -> Self {
116        Self::new(DEFAULT_SESSION_TTL)
117    }
118}
119
120impl SessionManager {
121    /// Create a new SessionManager
122    pub fn new(session_ttl: u64) -> Self {
123        Self {
124            sessions: Arc::new(RwLock::new(HashMap::new())),
125            session_ttl,
126        }
127    }
128
129    /// Create a new search session with results
130    pub async fn create_session(
131        &self,
132        search_options: SearchOptions,
133        results: Vec<SearchResult>,
134    ) -> Result<Uuid, String> {
135        let session_id = Uuid::new_v4();
136        let now = SystemTime::now();
137        let search_params_hash = Self::hash_search_options(&search_options);
138
139        let session = SearchSession {
140            id: session_id,
141            search_options,
142            total_count: results.len(),
143            results,
144            created_at: now,
145            last_accessed: now,
146            search_completed: true,
147            search_params_hash,
148        };
149
150        let mut sessions = self.sessions.write().await;
151
152        // Check if we need to evict old sessions
153        if sessions.len() >= MAX_SESSIONS {
154            self.evict_oldest_session(&mut sessions).await;
155        }
156
157        sessions.insert(session_id, session);
158        debug!(
159            "Created search session {} with {} results",
160            session_id,
161            sessions.get(&session_id).unwrap().total_count
162        );
163
164        Ok(session_id)
165    }
166
167    /// Get a page of results from a session
168    pub async fn get_page(
169        &self,
170        session_id: Uuid,
171        offset: usize,
172        config: PaginationConfig,
173    ) -> Result<SearchPage, String> {
174        let mut sessions = self.sessions.write().await;
175
176        let session = sessions
177            .get_mut(&session_id)
178            .ok_or_else(|| "Session not found or expired".to_string())?;
179
180        // Check if session has expired
181        if self.is_session_expired(session) {
182            sessions.remove(&session_id);
183            return Err("Session has expired".to_string());
184        }
185
186        // Update last accessed time
187        session.last_accessed = SystemTime::now();
188
189        // Calculate page bounds
190        let total_results = session.results.len();
191        let end_offset = (offset + config.page_size).min(total_results);
192        let has_more = end_offset < total_results;
193
194        if offset >= total_results {
195            return Ok(SearchPage {
196                matches: vec![],
197                count: 0,
198                total_count: Some(total_results),
199                has_more: false,
200                truncated: false,
201                next_cursor: None,
202                current_page: (offset / config.page_size) + 1,
203                original_page_size: config.page_size,
204            });
205        }
206
207        // Extract the page of results
208        let mut page_results = session.results[offset..end_offset].to_vec();
209
210        // Apply snippet configuration
211        if config.include_snippet {
212            for result in &mut page_results {
213                if result.preview.len() > config.snippet_length {
214                    result.preview.truncate(config.snippet_length);
215                    result.preview.push_str("...");
216                }
217            }
218        } else {
219            for result in &mut page_results {
220                result.preview = "[snippet omitted]".to_string();
221            }
222        }
223
224        // Generate next cursor if there are more results
225        let next_cursor = if has_more {
226            Some(self.create_cursor(
227                session_id,
228                end_offset,
229                &session.search_params_hash,
230                config.page_size,
231            )?)
232        } else {
233            None
234        };
235
236        Ok(SearchPage {
237            matches: page_results,
238            count: end_offset - offset,
239            total_count: Some(total_results),
240            has_more,
241            truncated: false, // TODO: Implement truncation logic
242            next_cursor,
243            current_page: (offset / config.page_size) + 1,
244            original_page_size: config.page_size,
245        })
246    }
247
248    /// Get first page and create session if needed
249    pub async fn get_first_page(
250        &self,
251        search_options: SearchOptions,
252        results: Vec<SearchResult>,
253        config: PaginationConfig,
254    ) -> Result<SearchPage, String> {
255        let session_id = self.create_session(search_options, results).await?;
256        self.get_page(session_id, 0, config).await
257    }
258
259    /// Parse cursor and get the corresponding page
260    pub async fn get_page_by_cursor(
261        &self,
262        cursor: &str,
263        config: PaginationConfig,
264    ) -> Result<SearchPage, String> {
265        let parsed_cursor = self.parse_cursor(cursor)?;
266
267        // Validate cursor timestamp (not too old)
268        let cursor_age = SystemTime::now()
269            .duration_since(UNIX_EPOCH)
270            .map_err(|e| format!("System time error: {e}"))?
271            .as_secs()
272            - parsed_cursor.timestamp;
273
274        if cursor_age > self.session_ttl {
275            return Err("Cursor has expired".to_string());
276        }
277
278        // Use the original page size from the cursor to maintain consistency
279        let mut adjusted_config = config;
280        adjusted_config.page_size = parsed_cursor.original_page_size;
281
282        self.get_page(
283            parsed_cursor.session_id,
284            parsed_cursor.offset,
285            adjusted_config,
286        )
287        .await
288    }
289
290    /// Create a base64-encoded cursor
291    fn create_cursor(
292        &self,
293        session_id: Uuid,
294        offset: usize,
295        search_params_hash: &str,
296        original_page_size: usize,
297    ) -> Result<String, String> {
298        let cursor = PaginationCursor {
299            session_id,
300            offset,
301            search_params_hash: search_params_hash.to_string(),
302            timestamp: SystemTime::now()
303                .duration_since(UNIX_EPOCH)
304                .map_err(|e| format!("System time error: {e}"))?
305                .as_secs(),
306            version: 1,
307            original_page_size,
308        };
309
310        let cursor_json = serde_json::to_string(&cursor)
311            .map_err(|e| format!("Failed to serialize cursor: {e}"))?;
312
313        Ok(BASE64.encode(cursor_json.as_bytes()))
314    }
315
316    /// Parse a base64-encoded cursor
317    fn parse_cursor(&self, cursor: &str) -> Result<PaginationCursor, String> {
318        let cursor_bytes = BASE64
319            .decode(cursor)
320            .map_err(|e| format!("Invalid cursor format: {e}"))?;
321
322        let cursor_json =
323            String::from_utf8(cursor_bytes).map_err(|e| format!("Invalid cursor encoding: {e}"))?;
324
325        let parsed_cursor: PaginationCursor = serde_json::from_str(&cursor_json)
326            .map_err(|e| format!("Invalid cursor structure: {e}"))?;
327
328        // Validate cursor version
329        if parsed_cursor.version != 1 {
330            return Err("Unsupported cursor version".to_string());
331        }
332
333        Ok(parsed_cursor)
334    }
335
336    /// Hash search options to detect parameter changes
337    fn hash_search_options(options: &SearchOptions) -> String {
338        let mut hasher = Sha256::new();
339
340        // Hash the essential search parameters
341        hasher.update(options.query.as_bytes());
342        hasher.update(options.path.to_string_lossy().as_bytes());
343        hasher.update(format!("{:?}", options.mode).as_bytes());
344        hasher.update(format!("{:?}", options.top_k).as_bytes());
345        hasher.update(format!("{:?}", options.threshold).as_bytes());
346        hasher.update(options.case_insensitive.to_string().as_bytes());
347        hasher.update(options.whole_word.to_string().as_bytes());
348        hasher.update(options.context_lines.to_string().as_bytes());
349
350        format!("{:x}", hasher.finalize())
351    }
352
353    /// Check if a session has expired
354    fn is_session_expired(&self, session: &SearchSession) -> bool {
355        let now = SystemTime::now();
356        let session_age = now
357            .duration_since(session.last_accessed)
358            .unwrap_or_default()
359            .as_secs();
360        session_age > self.session_ttl
361    }
362
363    /// Evict the oldest session to make room for new ones
364    async fn evict_oldest_session(&self, sessions: &mut HashMap<Uuid, SearchSession>) {
365        if let Some(oldest_id) = sessions
366            .iter()
367            .min_by_key(|(_, session)| session.last_accessed)
368            .map(|(id, _)| *id)
369        {
370            sessions.remove(&oldest_id);
371            debug!("Evicted oldest session: {}", oldest_id);
372        }
373    }
374
375    /// Clean up expired sessions (should be called periodically)
376    #[allow(dead_code)]
377    pub async fn cleanup_expired_sessions(&self) -> usize {
378        let mut sessions = self.sessions.write().await;
379        let _initial_count = sessions.len();
380
381        let expired_ids: Vec<Uuid> = sessions
382            .iter()
383            .filter(|(_, session)| self.is_session_expired(session))
384            .map(|(id, _)| *id)
385            .collect();
386
387        for id in &expired_ids {
388            sessions.remove(id);
389        }
390
391        let cleaned_count = expired_ids.len();
392        if cleaned_count > 0 {
393            debug!("Cleaned up {} expired sessions", cleaned_count);
394        }
395
396        cleaned_count
397    }
398
399    /// Get session statistics
400    #[allow(dead_code)]
401    pub async fn get_stats(&self) -> SessionStats {
402        let sessions = self.sessions.read().await;
403        let _now = SystemTime::now();
404
405        let mut total_results = 0;
406        let mut expired_count = 0;
407
408        for session in sessions.values() {
409            total_results += session.total_count;
410            if self.is_session_expired(session) {
411                expired_count += 1;
412            }
413        }
414
415        SessionStats {
416            total_sessions: sessions.len(),
417            expired_sessions: expired_count,
418            total_cached_results: total_results,
419            memory_usage_estimate: sessions.len() * std::mem::size_of::<SearchSession>(),
420        }
421    }
422}
423
424/// Statistics about session manager state
425#[derive(Debug)]
426#[allow(dead_code)]
427pub struct SessionStats {
428    pub total_sessions: usize,
429    pub expired_sessions: usize,
430    pub total_cached_results: usize,
431    pub memory_usage_estimate: usize,
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use ck_core::{Language, SearchMode};
438    use std::path::PathBuf;
439
440    fn create_test_search_options() -> SearchOptions {
441        SearchOptions {
442            mode: SearchMode::Semantic,
443            query: "test query".to_string(),
444            path: PathBuf::from("/test/path"),
445            top_k: Some(10),
446            threshold: Some(0.5),
447            case_insensitive: false,
448            whole_word: false,
449            fixed_string: false,
450            line_numbers: false,
451            context_lines: 0,
452            before_context_lines: 0,
453            after_context_lines: 0,
454            recursive: true,
455            json_output: false,
456            jsonl_output: false,
457            no_snippet: false,
458            reindex: false,
459            show_scores: true,
460            show_filenames: true,
461            files_with_matches: false,
462            files_without_matches: false,
463            exclude_patterns: vec![],
464            include_patterns: Vec::new(),
465            respect_gitignore: true,
466            use_ckignore: true,
467            full_section: false,
468            hidden: false,
469            rerank: false,
470            rerank_model: None,
471            embedding_model: None,
472        }
473    }
474
475    fn create_test_results(count: usize) -> Vec<SearchResult> {
476        (0..count)
477            .map(|i| SearchResult {
478                file: PathBuf::from(format!("/test/file_{i}.rs")),
479                preview: format!("Test result {i} content"),
480                span: ck_core::Span {
481                    byte_start: i * 100,
482                    byte_end: (i + 1) * 100,
483                    line_start: i + 1,
484                    line_end: i + 1,
485                },
486                score: 0.8 - (i as f32 * 0.01),
487                lang: Some(Language::Rust),
488                symbol: None,
489                chunk_hash: None,
490                index_epoch: None,
491            })
492            .collect()
493    }
494
495    #[tokio::test]
496    async fn test_create_session() {
497        let manager = SessionManager::default();
498        let options = create_test_search_options();
499        let results = create_test_results(10);
500
501        let session_id = manager.create_session(options, results).await.unwrap();
502        assert!(!session_id.is_nil());
503    }
504
505    #[tokio::test]
506    async fn test_get_first_page() {
507        let manager = SessionManager::default();
508        let options = create_test_search_options();
509        let results = create_test_results(100);
510        let config = PaginationConfig::default();
511
512        let page = manager
513            .get_first_page(options, results, config)
514            .await
515            .unwrap();
516
517        assert_eq!(page.count, DEFAULT_PAGE_SIZE);
518        assert_eq!(page.total_count, Some(100));
519        assert!(page.has_more);
520        assert!(page.next_cursor.is_some());
521    }
522
523    #[tokio::test]
524    async fn test_pagination() {
525        let manager = SessionManager::default();
526        let options = create_test_search_options();
527        let results = create_test_results(75);
528        let config = PaginationConfig::default();
529
530        // Get first page
531        let page1 = manager
532            .get_first_page(options, results, config.clone())
533            .await
534            .unwrap();
535        assert_eq!(page1.count, DEFAULT_PAGE_SIZE);
536        assert!(page1.has_more);
537
538        // Get second page using cursor
539        let cursor = page1.next_cursor.unwrap();
540        let page2 = manager.get_page_by_cursor(&cursor, config).await.unwrap();
541        assert_eq!(page2.count, 25); // 75 - 50 = 25
542        assert!(!page2.has_more);
543        assert!(page2.next_cursor.is_none());
544    }
545
546    #[tokio::test]
547    async fn test_cursor_validation() {
548        let manager = SessionManager::default();
549        let config = PaginationConfig::default();
550
551        // Test invalid cursor
552        let result = manager.get_page_by_cursor("invalid_cursor", config).await;
553        assert!(result.is_err());
554    }
555
556    #[tokio::test]
557    async fn test_session_cleanup() {
558        let manager = SessionManager::new(1); // 1 second TTL
559        let options = create_test_search_options();
560        let results = create_test_results(10);
561
562        let _session_id = manager.create_session(options, results).await.unwrap();
563
564        // Wait for session to expire
565        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
566
567        let cleaned = manager.cleanup_expired_sessions().await;
568        assert_eq!(cleaned, 1);
569    }
570
571    #[tokio::test]
572    async fn test_snippet_truncation() {
573        let manager = SessionManager::default();
574        let options = create_test_search_options();
575
576        // Create results with long content
577        let long_content = "a".repeat(1000);
578        let mut results = create_test_results(1);
579        results[0].preview = long_content;
580
581        let config = PaginationConfig {
582            snippet_length: 100,
583            ..Default::default()
584        };
585
586        let page = manager
587            .get_first_page(options, results, config)
588            .await
589            .unwrap();
590
591        // Should be truncated to 100 chars + "..."
592        assert!(page.matches[0].preview.len() <= 103);
593        assert!(page.matches[0].preview.ends_with("..."));
594    }
595}