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
13const DEFAULT_SESSION_TTL: u64 = 300;
15
16const MAX_SESSIONS: usize = 100;
18
19const DEFAULT_PAGE_SIZE: usize = 50;
21
22const MAX_PAGE_SIZE: usize = 200;
24
25const MAX_SNIPPET_LENGTH: usize = 2000;
27
28const DEFAULT_SNIPPET_LENGTH: usize = 500;
30
31#[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#[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 }
63
64#[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#[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 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#[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 pub fn new(session_ttl: u64) -> Self {
123 Self {
124 sessions: Arc::new(RwLock::new(HashMap::new())),
125 session_ttl,
126 }
127 }
128
129 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 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 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 if self.is_session_expired(session) {
182 sessions.remove(&session_id);
183 return Err("Session has expired".to_string());
184 }
185
186 session.last_accessed = SystemTime::now();
188
189 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 let mut page_results = session.results[offset..end_offset].to_vec();
209
210 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 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, next_cursor,
243 current_page: (offset / config.page_size) + 1,
244 original_page_size: config.page_size,
245 })
246 }
247
248 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 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 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 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 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 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 if parsed_cursor.version != 1 {
330 return Err("Unsupported cursor version".to_string());
331 }
332
333 Ok(parsed_cursor)
334 }
335
336 fn hash_search_options(options: &SearchOptions) -> String {
338 let mut hasher = Sha256::new();
339
340 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 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 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 #[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 #[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#[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 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 let cursor = page1.next_cursor.unwrap();
540 let page2 = manager.get_page_by_cursor(&cursor, config).await.unwrap();
541 assert_eq!(page2.count, 25); 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 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); 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 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 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 assert!(page.matches[0].preview.len() <= 103);
593 assert!(page.matches[0].preview.ends_with("..."));
594 }
595}