use crate::extras::session_db::{SearchResult, SessionDb};
fn contains_cjk(query: &str) -> bool {
query.chars().any(|c| {
let cp = c as u32;
(0x4E00..=0x9FFF).contains(&cp) || (0x3400..=0x4DBF).contains(&cp) || (0x20000..=0x2A6DF).contains(&cp) || (0x3000..=0x303F).contains(&cp) || (0x3040..=0x309F).contains(&cp) || (0x30A0..=0x30FF).contains(&cp) || (0xAC00..=0xD7AF).contains(&cp) })
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DiscoveryHit {
pub session_id: String,
pub root_session_id: String,
pub source: String,
pub model: String,
pub title: String,
pub started_at: String,
pub snippet: String,
pub bookend_start: Vec<MessagePreview>,
pub bookend_end: Vec<MessagePreview>,
pub messages: Vec<MessagePreview>,
pub anchor_index: usize,
pub before: usize,
pub after: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MessagePreview {
pub id: i64,
pub role: String,
pub content_preview: String,
pub timestamp: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScrollResult {
pub session_id: String,
pub messages: Vec<MessagePreview>,
pub anchor_index: usize,
pub before: usize,
pub after: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BrowseSession {
pub id: String,
pub root_id: String,
pub source: String,
pub model: String,
pub title: String,
pub started_at: String,
pub last_active: String,
pub message_count: i64,
}
const MAX_PREVIEW_LEN: usize = 300;
const BOOKEND_COUNT: usize = 3;
const DEFAULT_WINDOW: usize = 5;
const MAX_DISCOVERY_RESULTS: usize = 10;
pub struct SessionSearch {
db: SessionDb,
current_session_id: Option<String>,
}
impl SessionSearch {
pub fn new(db: SessionDb) -> Self {
SessionSearch {
db,
current_session_id: None,
}
}
pub fn with_current_session(mut self, id: &str) -> Self {
self.current_session_id = Some(id.to_string());
self
}
pub fn discover(&self, query: &str) -> Result<Vec<DiscoveryHit>, String> {
let sanitized = crate::extras::fts::sanitize_query(query);
if sanitized.is_empty() {
return Ok(Vec::new());
}
let results = if contains_cjk(&sanitized) {
self.db.search_messages_trigram(&sanitized, None)?
} else {
self.db.search_messages(&sanitized, None)?
};
if results.is_empty() {
return Ok(Vec::new());
}
let mut hits: Vec<DiscoveryHit> = Vec::new();
let mut seen_roots = std::collections::HashSet::new();
for result in &results {
let root_id = self.db.resolve_parent(&result.session_id)?;
if !seen_roots.insert(root_id.clone()) {
continue;
}
if let Some(ref current) = self.current_session_id {
let current_root = self.db.resolve_parent(current)?;
if current_root == root_id {
continue;
}
}
match self.build_discovery_hit(result, &root_id) {
Ok(hit) => hits.push(hit),
Err(e) => {
tracing::warn!(
target: "dirge::session_search",
session_id = %result.session_id,
error = %e,
"Failed to build discovery hit"
);
}
}
if hits.len() >= MAX_DISCOVERY_RESULTS {
break;
}
}
Ok(hits)
}
fn build_discovery_hit(
&self,
result: &SearchResult,
root_id: &str,
) -> Result<DiscoveryHit, String> {
let session_meta = self.get_session_meta(&result.session_id)?;
let anchor_id = self.find_message_id_near(&result.session_id, &result.timestamp)?;
let view = self
.db
.get_anchored_view(&result.session_id, anchor_id, DEFAULT_WINDOW)?;
let bookend_start = self.get_bookends(&result.session_id, true)?;
let bookend_end = self.get_bookends(&result.session_id, false)?;
let messages: Vec<MessagePreview> = view
.messages
.into_iter()
.map(|m| MessagePreview {
id: m.id,
role: m.role,
content_preview: truncate_content(&m.content, MAX_PREVIEW_LEN),
timestamp: m.timestamp,
})
.collect();
Ok(DiscoveryHit {
session_id: result.session_id.clone(),
root_session_id: root_id.to_string(),
source: session_meta.0,
model: session_meta.1,
title: session_meta.2,
started_at: session_meta.3,
snippet: truncate_content(&result.content, MAX_PREVIEW_LEN),
bookend_start,
bookend_end,
messages,
anchor_index: view.anchor_index,
before: view.before,
after: view.after,
})
}
pub fn scroll(
&self,
session_id: &str,
around_message_id: i64,
window: usize,
) -> Result<ScrollResult, String> {
let actual_session = self.find_message_session(session_id, around_message_id)?;
let view = self
.db
.get_anchored_view(&actual_session, around_message_id, window)?;
let messages: Vec<MessagePreview> = view
.messages
.into_iter()
.map(|m| MessagePreview {
id: m.id,
role: m.role,
content_preview: truncate_content(&m.content, MAX_PREVIEW_LEN),
timestamp: m.timestamp,
})
.collect();
Ok(ScrollResult {
session_id: actual_session,
messages,
anchor_index: view.anchor_index,
before: view.before,
after: view.after,
})
}
pub fn browse(&self) -> Result<Vec<BrowseSession>, String> {
let sessions = self.db.list_sessions_rich(Some(&["review-fork"]))?;
let mut result = Vec::new();
let mut seen_roots = std::collections::HashSet::new();
for s in sessions {
let root_id = self.db.resolve_parent(&s.id)?;
if !seen_roots.insert(root_id.clone()) {
continue;
}
if let Some(ref current) = self.current_session_id {
let current_root = self.db.resolve_parent(current)?;
if current_root == root_id {
continue;
}
}
result.push(BrowseSession {
id: s.id,
root_id,
source: s.source,
model: s.model,
title: s.title,
started_at: s.started_at,
last_active: s.last_active,
message_count: s.message_count,
});
}
Ok(result)
}
fn get_session_meta(
&self,
session_id: &str,
) -> Result<(String, String, String, String), String> {
self.db
.get_anchored_view(session_id, 0, 0)
.map(|_v| {
(String::new(), String::new(), String::new(), String::new())
})
.map_err(|_| format!("Session '{}' not found", session_id))?;
let all = self.db.list_sessions_rich(None)?;
for s in &all {
if s.id == session_id {
return Ok((
s.source.clone(),
s.model.clone(),
s.title.clone(),
s.started_at.clone(),
));
}
}
Ok((String::new(), String::new(), String::new(), String::new()))
}
fn find_message_id_near(&self, session_id: &str, timestamp: &str) -> Result<i64, String> {
let view = self.db.get_anchored_view(session_id, 1, 0)?;
if view.messages.is_empty() {
return Err(format!("No messages in session '{}'", session_id));
}
for m in &view.messages {
if *m.timestamp >= *timestamp {
return Ok(m.id);
}
}
Ok(view.messages.last().map(|m| m.id).unwrap_or(1))
}
fn get_bookends(&self, session_id: &str, start: bool) -> Result<Vec<MessagePreview>, String> {
let view = self.db.get_anchored_view(session_id, 1, BOOKEND_COUNT)?;
let messages: Vec<MessagePreview> = view
.messages
.into_iter()
.map(|m| MessagePreview {
id: m.id,
role: m.role,
content_preview: truncate_content(&m.content, MAX_PREVIEW_LEN),
timestamp: m.timestamp,
})
.collect();
if start {
Ok(messages)
} else {
let total_view = self.db.get_anchored_view(session_id, 1, 100_000)?;
let total = total_view.messages.len();
if total <= BOOKEND_COUNT {
return Ok(messages);
}
let last_id = total_view.messages.last().map(|m| m.id).unwrap_or(1);
let end_view = self
.db
.get_anchored_view(session_id, last_id, BOOKEND_COUNT)?;
Ok(end_view
.messages
.into_iter()
.map(|m| MessagePreview {
id: m.id,
role: m.role,
content_preview: truncate_content(&m.content, MAX_PREVIEW_LEN),
timestamp: m.timestamp,
})
.collect())
}
}
fn find_message_session(&self, session_id: &str, message_id: i64) -> Result<String, String> {
if self.db.get_anchored_view(session_id, message_id, 0).is_ok() {
return Ok(session_id.to_string());
}
let all = self.db.list_sessions_rich(None)?;
let root_id = self.db.resolve_parent(session_id)?;
for s in &all {
let s_root = self.db.resolve_parent(&s.id)?;
if s_root == root_id && self.db.get_anchored_view(&s.id, message_id, 0).is_ok() {
return Ok(s.id.clone());
}
}
Ok(session_id.to_string())
}
}
fn truncate_content(content: &str, max_len: usize) -> String {
if content.len() <= max_len {
return content.to_string();
}
format!(
"{}…[{} more chars]",
crate::text::head(content, max_len.saturating_sub(20)),
content.len() - max_len
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_search() -> (SessionSearch, std::path::PathBuf) {
let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("dirge-search-test-{}-{}", std::process::id(), n));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("state.db");
let db = SessionDb::open(&path).unwrap();
let search = SessionSearch::new(db);
(search, dir)
}
fn seed_session(db: &SessionDb, id: &str, source: &str) {
db.insert_session(id, source, "gpt-5", "openai", "2025-01-15T10:00:00Z")
.unwrap();
for i in 0..5 {
db.insert_message(
id,
if i % 2 == 0 { "user" } else { "assistant" },
&format!("message {} in {}", i, id),
None,
None,
None,
&format!("2025-01-15T10:{:02}:00Z", i),
)
.unwrap();
}
}
#[test]
fn browse_returns_recent_sessions() {
let (search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
seed_session(&search.db, "sess-2", "subagent");
let sessions = search.browse().unwrap();
assert!(!sessions.is_empty());
let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"sess-1"));
assert!(ids.contains(&"sess-2"));
}
#[test]
fn browse_excludes_review_fork() {
let (search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
seed_session(&search.db, "review-1", "review-fork");
let sessions = search.browse().unwrap();
let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"sess-1"));
assert!(!ids.contains(&"review-1"));
}
#[test]
fn browse_excludes_current_session() {
let (mut search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
seed_session(&search.db, "sess-2", "cli");
search.current_session_id = Some("sess-1".to_string());
let sessions = search.browse().unwrap();
let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
assert!(
!ids.contains(&"sess-1"),
"current session should be excluded"
);
assert!(ids.contains(&"sess-2"));
}
#[test]
fn discover_finds_matching_sessions() {
let (search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
search
.db
.insert_message(
"sess-1",
"user",
"how do we handle database migrations with rusqlite",
None,
None,
None,
"2025-01-15T10:01:00Z",
)
.unwrap();
let hits = search.discover("database migrations").unwrap();
assert!(!hits.is_empty());
}
#[test]
fn discover_empty_for_no_match() {
let (search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
let hits = search.discover("zzzzz_nonexistent_query_xyz").unwrap();
assert!(hits.is_empty());
}
#[test]
fn discover_excludes_current_session() {
let (mut search, _dir) = temp_search();
seed_session(&search.db, "current", "cli");
seed_session(&search.db, "other", "cli");
search
.db
.insert_message(
"current",
"user",
"database migration in current session",
None,
None,
None,
"2025-01-15T10:01:00Z",
)
.unwrap();
search
.db
.insert_message(
"other",
"user",
"database migration in other session",
None,
None,
None,
"2025-01-15T11:01:00Z",
)
.unwrap();
search.current_session_id = Some("current".to_string());
let hits = search.discover("database migration").unwrap();
assert!(!hits.is_empty());
for hit in &hits {
assert_ne!(hit.session_id, "current");
}
}
#[test]
fn discover_dedupes_by_lineage() {
let (search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
seed_session(&search.db, "child-1", "cli");
search.db.set_parent_session("child-1", "sess-1").unwrap();
search
.db
.insert_message(
"sess-1",
"user",
"unique term: ziggurat construction",
None,
None,
None,
"2025-01-15T10:01:00Z",
)
.unwrap();
search
.db
.insert_message(
"child-1",
"user",
"unique term: ziggurat construction continued",
None,
None,
None,
"2025-01-15T11:01:00Z",
)
.unwrap();
let hits = search.discover("ziggurat").unwrap();
assert_eq!(hits.len(), 1);
}
#[test]
fn scroll_returns_window_around_anchor() {
let (search, _dir) = temp_search();
search
.db
.insert_session("sess-1", "cli", "gpt-5", "openai", "2025-01-15T10:00:00Z")
.unwrap();
for i in 0..20 {
search
.db
.insert_message(
"sess-1",
if i % 2 == 0 { "user" } else { "assistant" },
&format!("message {}", i),
None,
None,
None,
&format!("2025-01-15T10:{:02}:00Z", i),
)
.unwrap();
}
let result = search.scroll("sess-1", 10, 3).unwrap();
assert!(!result.messages.is_empty());
assert_eq!(result.before, 3);
assert_eq!(result.after, 3);
}
#[test]
fn truncate_preserves_short_content() {
let result = truncate_content("hello", 300);
assert_eq!(result, "hello");
}
#[test]
fn truncate_shortens_long_content() {
let long = "a".repeat(500);
let result = truncate_content(&long, 200);
assert!(result.len() < 300);
assert!(result.ends_with("more chars]"));
}
#[test]
fn browse_dedupes_by_lineage() {
let (search, _dir) = temp_search();
seed_session(&search.db, "sess-1", "cli");
seed_session(&search.db, "child-1", "cli");
search.db.set_parent_session("child-1", "sess-1").unwrap();
let sessions = search.browse().unwrap();
let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids.len(), 1, "should dedupe by lineage");
}
#[test]
fn find_message_session_falls_back_to_given() {
let (search, _dir) = temp_search();
search
.db
.insert_session("sess-1", "cli", "gpt-5", "openai", "2025-01-15T10:00:00Z")
.unwrap();
let session = search.find_message_session("sess-1", 999).unwrap();
assert_eq!(session, "sess-1");
}
}