whycodes_session/
search.rs1use whycodes_core::types::{ContentBlock, Message, MessageContent};
4use whycodes_storage::db::Database;
5
6const MAX_TERMS: usize = 8;
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct SessionSearchHit {
12 pub id: String,
13 pub title: String,
14 pub updated_at: String,
15 pub project_path: String,
16 pub hits: usize,
18 pub snippet: String,
20}
21
22pub fn query_terms(query: &str) -> Vec<String> {
24 let mut terms: Vec<String> = Vec::new();
25 for word in query.split_whitespace() {
26 if terms.len() == MAX_TERMS {
27 break;
28 }
29 if !terms.iter().any(|t| t.eq_ignore_ascii_case(word)) {
30 terms.push(word.to_string());
31 }
32 }
33 terms
34}
35
36pub fn search_sessions(
38 db: &Database,
39 query: &str,
40 limit: usize,
41) -> crate::Result<Vec<SessionSearchHit>> {
42 let terms = query_terms(query);
43 let rows = db.search_sessions_text(&terms, limit)?;
44 Ok(rows
45 .into_iter()
46 .map(|row| SessionSearchHit {
47 snippet: snippet(&row.sample, &terms, 120),
48 id: row.session.id,
49 title: row.session.title,
50 updated_at: row.session.updated_at,
51 project_path: row.session.project_path,
52 hits: row.hits,
53 })
54 .collect())
55}
56
57fn message_text(content_json: &str) -> String {
59 let Ok(msg) = serde_json::from_str::<Message>(content_json) else {
60 return content_json.to_string();
61 };
62 match msg.content {
63 MessageContent::Text(text) => text,
64 MessageContent::Blocks(blocks) => blocks
65 .into_iter()
66 .filter_map(|b| match b {
67 ContentBlock::Text { text } => Some(text),
68 ContentBlock::ToolResult { content, .. } => Some(content),
69 ContentBlock::ToolUse { input, .. } => Some(input.to_string()),
70 _ => None,
71 })
72 .collect::<Vec<_>>()
73 .join(" "),
74 }
75}
76
77pub fn snippet(content_json: &str, terms: &[String], width: usize) -> String {
79 let text = message_text(content_json)
80 .split_whitespace()
81 .collect::<Vec<_>>()
82 .join(" ");
83 let chars: Vec<char> = text.chars().collect();
84 let lower: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
85 let at = terms
86 .iter()
87 .filter_map(|t| {
88 let needle: Vec<char> = t.chars().map(|c| c.to_ascii_lowercase()).collect();
89 lower
90 .windows(needle.len().max(1))
91 .position(|w| w == needle.as_slice())
92 })
93 .min()
94 .unwrap_or(0);
95 let start = at.saturating_sub(width / 3);
96 let end = (start + width).min(chars.len());
97 let mut out: String = chars[start..end].iter().collect();
98 if start > 0 {
99 out.insert(0, '…');
100 }
101 if end < chars.len() {
102 out.push('…');
103 }
104 out
105}
106
107#[cfg(test)]
108#[path = "search_tests.rs"]
109mod tests;