use super::Session;
use crate::pricing::Provider;
use rusqlite::{Connection, OpenFlags, params};
use std::io::{BufRead, BufReader};
use std::path::Path;
const MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024;
const SNIPPET_PAD: usize = 48;
const SNIPPET_CHARS: usize = 160;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub snippet: String,
}
#[derive(Debug, Clone)]
pub struct Target {
pub key: String,
pub provider: Provider,
pub session_id: String,
pub data_file: Option<std::path::PathBuf>,
pub running: bool,
}
impl Target {
pub fn of(session: &Session) -> Target {
Target {
key: session.key(),
provider: session.provider,
session_id: session.session_id.clone(),
data_file: session.data_file.clone(),
running: session.is_running(),
}
}
}
pub fn find(session: &Target, needle: &str) -> Option<Hit> {
if needle.is_empty() {
return None;
}
let file = session.data_file.as_deref()?;
match session.provider {
Provider::Claude => super::transcript_files(file)
.iter()
.find_map(|path| scan_file(path, needle)),
Provider::Codex | Provider::Cursor | Provider::Gemini | Provider::Pi => {
scan_file(file, needle)
}
Provider::OpenCode => scan_opencode(file, &session.session_id, needle),
Provider::Windsurf => scan_windsurf(file, &session.session_id, needle),
}
}
fn scan_file(path: &Path, needle: &str) -> Option<Hit> {
let file = std::fs::File::open(path).ok()?;
let mut reader = BufReader::new(file);
let mut line = String::new();
let mut read = 0u64;
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => return None,
Ok(n) => read += n as u64,
Err(_) => {
read += 1;
if read >= MAX_SCAN_BYTES {
return None;
}
continue;
}
}
if let Some(hit) = find_in(&line, needle) {
return Some(hit);
}
if read >= MAX_SCAN_BYTES {
return None;
}
}
}
fn readonly(path: &Path) -> rusqlite::Result<Connection> {
Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
}
fn scan_opencode(path: &Path, session_id: &str, needle: &str) -> Option<Hit> {
let db = readonly(path).ok()?;
for sql in [
"SELECT data FROM part WHERE session_id = ?1 ORDER BY id",
"SELECT data FROM message WHERE session_id = ?1 ORDER BY time_created, id",
] {
let Ok(mut stmt) = db.prepare(sql) else {
continue;
};
let Ok(rows) = stmt.query_map(params![session_id], |row| row.get::<_, String>(0)) else {
continue;
};
for raw in rows.flatten() {
if let Some(hit) = find_in(&raw, needle) {
return Some(hit);
}
}
}
None
}
fn scan_windsurf(path: &Path, session_id: &str, needle: &str) -> Option<Hit> {
let db = readonly(path).ok()?;
let data = super::windsurf::chat_data(&db)?;
let tab = super::windsurf::tabs(&data)
.iter()
.find(|tab| super::windsurf::tab_id(tab).as_deref() == Some(session_id))?;
find_in(&tab.to_string(), needle)
}
fn find_in(haystack: &str, needle: &str) -> Option<Hit> {
let lower = haystack.to_ascii_lowercase();
let at = lower.find(needle)?;
let chars_before = haystack[..at].chars().count();
let start = chars_before.saturating_sub(SNIPPET_PAD);
let window: String = haystack
.chars()
.skip(start)
.take(SNIPPET_PAD * 2 + needle.chars().count())
.collect();
Some(Hit {
snippet: clean(&window),
})
}
fn clean(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut space = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('n' | 't' | 'r') => {
chars.next();
space = true;
continue;
}
Some(&escaped @ ('"' | '\\' | '/')) => {
chars.next();
if space && !out.is_empty() {
out.push(' ');
}
space = false;
out.push(escaped);
continue;
}
_ => {}
}
}
if c.is_whitespace() || c.is_control() {
space = true;
continue;
}
if space && !out.is_empty() {
out.push(' ');
}
space = false;
out.push(c);
}
crate::util::truncate(out.trim(), SNIPPET_CHARS)
}
#[cfg(test)]
fn is_meaningful(snippet: &str) -> bool {
snippet.chars().any(char::is_alphanumeric)
}
#[cfg(test)]
fn as_text(value: &serde_json::Value) -> String {
value.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn target(provider: Provider, path: &std::path::Path) -> Target {
let mut s = Session::new(provider, "a".into());
s.data_file = Some(path.to_path_buf());
Target::of(&s)
}
fn temp(name: &str, body: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("cctop-search-{name}"));
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(body.as_bytes()).expect("write");
path
}
#[test]
fn a_word_only_in_the_transcript_is_found() {
let path = temp(
"claude.jsonl",
&format!(
"{}\n{}\n",
as_text(&json!({"type": "user", "text": "unrelated chatter"})),
as_text(&json!({"type": "user", "text": "please fix the flywheel"})),
),
);
let s = target(Provider::Claude, &path);
let hit = find(&s, "flywheel").expect("match");
assert!(hit.snippet.contains("flywheel"), "{}", hit.snippet);
assert!(is_meaningful(&hit.snippet));
assert_eq!(find(&s, "kingfisher"), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn matching_ignores_case() {
let path = temp("case.jsonl", "{\"text\":\"Refactor The Loader\"}\n");
let s = target(Provider::Codex, &path);
assert!(find(&s, "refactor the loader").is_some());
let _ = std::fs::remove_file(path);
}
#[test]
fn a_session_without_a_transcript_matches_nothing() {
let s = Target::of(&Session::new(Provider::Claude, "a".into()));
assert_eq!(find(&s, "anything"), None);
}
#[test]
fn an_empty_query_matches_nothing() {
let path = temp("empty-query.jsonl", "text\n");
let s = target(Provider::Codex, &path);
assert_eq!(find(&s, ""), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn a_snippet_is_a_single_printable_line() {
assert_eq!(clean("a\\nb\tc d"), "a b c d");
assert_eq!(clean(" padded "), "padded");
assert_eq!(clean("x\u{7}y"), "x y");
assert_eq!(clean(r#"rusqlite = \"0.37\""#), r#"rusqlite = "0.37""#);
assert_eq!(clean(r"C:\\src"), r"C:\src");
}
#[test]
fn a_snippet_survives_multibyte_neighbours() {
let hit = find_in("héllo → flywheel ← wörld", "flywheel").expect("match");
assert!(hit.snippet.contains('→'), "{}", hit.snippet);
assert!(hit.snippet.contains("flywheel"));
}
#[test]
fn a_snippet_at_either_edge_is_still_produced() {
assert!(find_in("flywheel at the start", "flywheel").is_some());
assert!(find_in("at the end is flywheel", "flywheel").is_some());
}
#[test]
fn a_snippet_is_bounded() {
let long = format!("{}flywheel{}", "x".repeat(5000), "y".repeat(5000));
let hit = find_in(&long, "flywheel").expect("match");
assert!(
hit.snippet.chars().count() <= SNIPPET_CHARS,
"{} chars",
hit.snippet.chars().count()
);
}
}