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(),
}
}
}
#[derive(Debug, Clone)]
pub struct Query {
terms: Vec<Term>,
}
#[derive(Debug, Clone)]
struct Term {
text: String,
folded: String,
fuzz: usize,
}
fn fuzz_for(len: usize) -> usize {
match len {
0..=4 => 0,
5..=7 => 1,
_ => 2,
}
}
impl Query {
pub fn parse(raw: &str) -> Query {
let terms = raw
.split_whitespace()
.map(|word| {
let text = word.to_ascii_lowercase();
let folded: String = text.chars().filter(char::is_ascii_alphanumeric).collect();
let fuzz = fuzz_for(folded.len());
Term { text, folded, fuzz }
})
.filter(|t| !t.text.is_empty())
.collect::<Vec<_>>();
Query { terms }
}
pub fn is_empty(&self) -> bool {
self.terms.is_empty()
}
}
struct Progress<'q> {
query: &'q Query,
found: Vec<Option<String>>,
remaining: usize,
}
impl<'q> Progress<'q> {
fn new(query: &'q Query) -> Progress<'q> {
Progress {
query,
found: vec![None; query.terms.len()],
remaining: query.terms.len(),
}
}
fn feed(&mut self, line: &str) -> bool {
if self.remaining == 0 {
return true;
}
let lower = line.to_ascii_lowercase();
let mut shape: Option<Shape> = None;
for i in 0..self.query.terms.len() {
if self.found[i].is_some() {
continue;
}
let term = &self.query.terms[i];
if let Some(at) = lower.find(&term.text) {
self.found[i] = Some(window(line, at, term.text.len()));
self.remaining -= 1;
continue;
}
if term.folded.is_empty() {
continue;
}
let shape = shape.get_or_insert_with(|| Shape::of(&lower));
if let Some((at, span)) = shape.find_folded(&term.folded) {
self.found[i] = Some(window(line, at, span));
self.remaining -= 1;
continue;
}
if term.fuzz > 0
&& let Some((at, span)) = shape.find_fuzzy(&lower, &term.folded, term.fuzz)
{
self.found[i] = Some(window(line, at, span));
self.remaining -= 1;
}
}
self.remaining == 0
}
fn finish(self) -> Option<Hit> {
if self.remaining > 0 {
return None;
}
let mut parts: Vec<String> = self.found.into_iter().flatten().collect();
parts.dedup();
Some(Hit {
snippet: crate::util::truncate(&parts.join(" … "), SNIPPET_CHARS),
})
}
}
struct Shape {
folded: String,
at: Vec<usize>,
words: Vec<(usize, usize)>,
}
impl Shape {
fn of(lower: &str) -> Shape {
let bytes = lower.as_bytes();
let mut folded = String::with_capacity(bytes.len());
let mut at = Vec::with_capacity(bytes.len());
let mut words = Vec::new();
let mut start = None;
for (i, &b) in bytes.iter().enumerate() {
if b.is_ascii_alphanumeric() {
folded.push(b as char);
at.push(i);
start.get_or_insert(i);
} else if let Some(s) = start.take() {
words.push((s, i));
}
}
if let Some(s) = start {
words.push((s, bytes.len()));
}
Shape { folded, at, words }
}
fn find_folded(&self, needle: &str) -> Option<(usize, usize)> {
let at = self.folded.find(needle)?;
let start = *self.at.get(at)?;
let end = self
.at
.get(at + needle.len())
.copied()
.unwrap_or_else(|| self.at.last().map_or(start, |&b| b + 1));
Some((start, end.saturating_sub(start)))
}
fn find_fuzzy(&self, lower: &str, needle: &str, fuzz: usize) -> Option<(usize, usize)> {
let first = *needle.as_bytes().first()?;
for &(s, e) in &self.words {
let word = &lower[s..e];
if word.len().abs_diff(needle.len()) > fuzz {
continue;
}
if word.as_bytes().first() != Some(&first) {
continue;
}
if within(word, needle, fuzz) {
return Some((s, e - s));
}
}
None
}
}
fn within(a: &str, b: &str, k: usize) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len().abs_diff(b.len()) > k {
return false;
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
let mut best = i;
for j in 1..=b.len() {
let sub = prev[j - 1] + usize::from(a[i - 1] != b[j - 1]);
cur[j] = sub.min(prev[j] + 1).min(cur[j - 1] + 1);
best = best.min(cur[j]);
}
if best > k {
return false;
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()] <= k
}
pub fn find(session: &Target, needle: &str) -> Option<Hit> {
find_query(session, &Query::parse(needle))
}
pub fn find_query(session: &Target, query: &Query) -> Option<Hit> {
if query.is_empty() {
return None;
}
let file = session.data_file.as_deref()?;
let mut progress = Progress::new(query);
match session.provider {
Provider::Claude => {
for path in super::transcript_files(file) {
if scan_file(&path, &mut progress) {
break;
}
}
}
Provider::Codex | Provider::Cursor | Provider::Devin | Provider::Gemini | Provider::Pi => {
scan_file(file, &mut progress);
}
Provider::OpenCode => scan_opencode(file, &session.session_id, &mut progress),
Provider::Windsurf => scan_windsurf(file, &session.session_id, &mut progress),
}
progress.finish()
}
fn scan_file(path: &Path, progress: &mut Progress) -> bool {
let Ok(file) = std::fs::File::open(path) else {
return false;
};
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 false,
Ok(n) => read += n as u64,
Err(_) => {
read += 1;
if read >= MAX_SCAN_BYTES {
return false;
}
continue;
}
}
if progress.feed(&line) {
return true;
}
if read >= MAX_SCAN_BYTES {
return false;
}
}
}
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, progress: &mut Progress) {
let Ok(db) = readonly(path) else {
return;
};
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 progress.feed(&raw) {
return;
}
}
}
}
fn scan_windsurf(path: &Path, session_id: &str, progress: &mut Progress) {
let Ok(db) = readonly(path) else {
return;
};
let Some(data) = super::windsurf::chat_data(&db) else {
return;
};
let tabs = super::windsurf::tabs(&data);
let Some(tab) = tabs
.iter()
.find(|tab| super::windsurf::tab_id(tab).as_deref() == Some(session_id))
else {
return;
};
progress.feed(&tab.to_string());
}
fn window(haystack: &str, at: usize, span: usize) -> String {
let chars_before = haystack[..at].chars().count();
let span_chars = haystack[at..(at + span).min(haystack.len())]
.chars()
.count();
let start = chars_before.saturating_sub(SNIPPET_PAD);
let text: String = haystack
.chars()
.skip(start)
.take(SNIPPET_PAD * 2 + span_chars)
.collect();
clean(&text)
}
#[cfg(test)]
fn find_in(haystack: &str, needle: &str) -> Option<Hit> {
let query = Query::parse(needle);
let mut progress = Progress::new(&query);
progress.feed(haystack);
progress.finish()
}
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 two_misspelled_names_far_apart_still_find_their_session() {
let mut body = String::new();
body.push_str(&as_text(
&json!({"type": "user", "text": "spun up a box on vast.ai for the weekend"}),
));
body.push('\n');
for i in 0..400 {
body.push_str(&as_text(
&json!({"type": "user", "text": format!("filler {i}")}),
));
body.push('\n');
}
body.push_str(&as_text(
&json!({"type": "user", "text": "nemotron-70b was the one that fit"}),
));
body.push('\n');
let path = temp("far-apart.jsonl", &body);
let s = target(Provider::Claude, &path);
let hit = find(&s, "vastai nemotrn").expect("both terms");
assert!(hit.snippet.contains("vast.ai"), "{}", hit.snippet);
assert!(hit.snippet.contains("nemotron"), "{}", hit.snippet);
assert_eq!(find(&s, "vastai nemotrn kingfisher"), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn terms_match_in_any_order() {
let path = temp("order.jsonl", "{\"text\":\"first alpha then omega\"}\n");
let s = target(Provider::Codex, &path);
assert!(find(&s, "alpha omega").is_some());
assert!(find(&s, "omega alpha").is_some());
let _ = std::fs::remove_file(path);
}
#[test]
fn separators_do_not_have_to_be_typed() {
let path = temp(
"folded.jsonl",
"{\"text\":\"deployed on vast.ai with gpt-4\"}\n",
);
let s = target(Provider::Codex, &path);
assert!(find(&s, "vastai").is_some());
assert!(find(&s, "gpt4").is_some());
let plain = temp("plain.jsonl", "{\"text\":\"deployed on vastai\"}\n");
let t = target(Provider::Codex, &plain);
assert!(find(&t, "vast.ai").is_some());
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(plain);
}
#[test]
fn short_terms_are_not_fuzzy() {
assert_eq!(fuzz_for(3), 0);
assert_eq!(fuzz_for(4), 0);
assert_eq!(fuzz_for(6), 1);
assert_eq!(fuzz_for(12), 2);
let path = temp("short.jsonl", "{\"text\":\"the dog sat\"}\n");
let s = target(Provider::Codex, &path);
assert_eq!(find(&s, "log"), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn a_typo_is_corrected_except_in_the_first_letter() {
let path = temp("firstletter.jsonl", "{\"text\":\"about nemotron today\"}\n");
let s = target(Provider::Codex, &path);
assert!(find(&s, "nemotrn").is_some(), "dropped letter");
assert!(find(&s, "nemotronn").is_some(), "doubled letter");
assert_eq!(find(&s, "wemotron"), None, "first letter is not corrected");
let _ = std::fs::remove_file(path);
}
#[test]
fn edit_distance_respects_its_budget() {
assert!(within("nemotron", "nemotrn", 1));
assert!(!within("nemotron", "nemotrn", 0));
assert!(within("kitten", "sitting", 3));
assert!(!within("kitten", "sitting", 2));
assert!(within("same", "same", 0));
assert!(!within("a", "abcdefgh", 2));
}
#[test]
fn a_snippet_shows_each_term_it_matched() {
let hit =
find_in("alpha is here and omega is way over there", "alpha omega").expect("match");
assert!(hit.snippet.contains("alpha"), "{}", hit.snippet);
assert!(hit.snippet.contains("omega"), "{}", hit.snippet);
assert!(hit.snippet.chars().count() <= SNIPPET_CHARS);
}
#[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()
);
}
}