Skip to main content

whycodes_session/
search.rs

1//! Full-text search over saved session transcripts.
2
3use whycodes_core::types::{ContentBlock, Message, MessageContent};
4use whycodes_storage::db::Database;
5
6/// Most terms a query may have; extra words are ignored.
7const MAX_TERMS: usize = 8;
8
9/// One matching session for a search.
10#[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    /// Messages that matched every term.
17    pub hits: usize,
18    /// Excerpt around the first term in one matching message.
19    pub snippet: String,
20}
21
22/// Whitespace-separated query words (deduplicated, at most [`MAX_TERMS`]).
23pub 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
36/// Saved sessions whose messages contain every word of `query`.
37pub 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
57/// Readable text of a stored message (falls back to the raw content).
58fn 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
77/// About `width` characters around the first term, whitespace collapsed.
78pub 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;