use anyhow::Result;
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::fs::{create_dir_all, File};
use std::hash::{Hash, Hasher};
use std::io::{Read, Write};
use std::path::PathBuf;
use probe_code::models::SearchResult;
pub fn hash_query(query: &str) -> String {
let mut hasher = DefaultHasher::new();
query.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionCache {
pub session_id: String,
pub query_hash: String,
pub block_identifiers: HashSet<String>,
}
impl SessionCache {
pub fn new(session_id: String, query_hash: String) -> Self {
Self {
session_id,
query_hash,
block_identifiers: HashSet::new(),
}
}
pub fn load(session_id: &str, query_hash: &str) -> Result<Self> {
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
let cache_path = Self::get_cache_path(session_id, query_hash);
if !cache_path.exists() {
if debug_mode {
println!("DEBUG: Cache file does not exist at {cache_path:?}, creating new cache");
}
return Ok(Self::new(session_id.to_string(), query_hash.to_string()));
}
if debug_mode {
println!("DEBUG: Loading cache from {cache_path:?}");
}
let mut file = match File::open(&cache_path) {
Ok(f) => f,
Err(e) => {
if debug_mode {
println!("DEBUG: Error opening cache file: {e}");
}
return Ok(Self::new(session_id.to_string(), query_hash.to_string()));
}
};
let mut contents = String::new();
if let Err(e) = file.read_to_string(&mut contents) {
if debug_mode {
println!("DEBUG: Error reading cache file: {e}");
}
return Ok(Self::new(session_id.to_string(), query_hash.to_string()));
}
match serde_json::from_str(&contents) {
Ok(cache) => {
let cache: SessionCache = cache;
if debug_mode {
println!(
"DEBUG: Successfully loaded cache with {} entries",
cache.block_identifiers.len()
);
}
Ok(cache)
}
Err(e) => {
if debug_mode {
println!("DEBUG: Error parsing cache JSON: {e}");
}
Ok(Self::new(session_id.to_string(), query_hash.to_string()))
}
}
}
pub fn save(&self) -> Result<()> {
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
let cache_path = Self::get_cache_path(&self.session_id, &self.query_hash);
if debug_mode {
println!(
"DEBUG: Saving cache with {} entries to {:?}",
self.block_identifiers.len(),
cache_path
);
}
if let Some(parent) = cache_path.parent() {
if let Err(e) = create_dir_all(parent) {
if debug_mode {
println!("DEBUG: Error creating cache directory: {e}");
}
return Err(e.into());
}
}
let json = match serde_json::to_string_pretty(self) {
Ok(j) => j,
Err(e) => {
if debug_mode {
println!("DEBUG: Error serializing cache to JSON: {e}");
}
return Err(e.into());
}
};
match File::create(&cache_path) {
Ok(mut file) => {
if let Err(e) = file.write_all(json.as_bytes()) {
if debug_mode {
println!("DEBUG: Error writing to cache file: {e}");
}
return Err(e.into());
}
}
Err(e) => {
if debug_mode {
println!("DEBUG: Error creating cache file: {e}");
}
return Err(e.into());
}
}
if debug_mode {
println!("DEBUG: Successfully saved cache to disk");
}
Ok(())
}
pub fn is_cached(&self, block_id: &str) -> bool {
self.block_identifiers.contains(block_id)
}
pub fn add_to_cache(&mut self, block_id: String) {
self.block_identifiers.insert(block_id);
}
pub fn get_cache_path(session_id: &str, query_hash: &str) -> PathBuf {
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home_dir
.join(".cache")
.join("probe")
.join("sessions")
.join(format!("{session_id}_{query_hash}.json"))
}
}
fn normalize_path(path: &str) -> String {
let normalized = if let Some(stripped) = path.strip_prefix("./") {
stripped
} else {
path
};
normalized.to_string()
}
pub fn generate_cache_key(result: &SearchResult) -> String {
let normalized_path = normalize_path(&result.file);
format!("{normalized_path}:{}-{}", result.lines.0, result.lines.1)
}
pub fn filter_results_with_cache(
results: &[SearchResult],
session_id: &str,
query: &str,
) -> Result<(Vec<SearchResult>, usize)> {
let query_hash = hash_query(query);
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
let cache_path = SessionCache::get_cache_path(session_id, &query_hash);
let is_new_session = !cache_path.exists();
if is_new_session {
if debug_mode {
println!("DEBUG: New session, not filtering results");
}
return Ok((results.to_vec(), 0));
}
let cache = SessionCache::load(session_id, &query_hash)?;
if cache.block_identifiers.is_empty() {
if debug_mode {
println!("DEBUG: Cache is empty, not filtering results");
}
return Ok((results.to_vec(), 0));
}
if debug_mode {
println!(
"DEBUG: Filtering {} results against {} cached blocks",
results.len(),
cache.block_identifiers.len()
);
}
let mut skipped_count = 0;
let filtered_results: Vec<SearchResult> = results
.iter()
.filter(|result| {
let cache_key = generate_cache_key(result);
let is_cached = cache.is_cached(&cache_key);
if is_cached {
if debug_mode && skipped_count < 5 {
println!("DEBUG: Skipping cached block: {cache_key}");
}
skipped_count += 1;
false
} else {
true
}
})
.cloned()
.collect();
if debug_mode {
println!(
"DEBUG: Filtered out {} cached blocks, returning {} results",
skipped_count,
filtered_results.len()
);
}
Ok((filtered_results, skipped_count))
}
pub fn filter_matched_lines_with_cache(
file_term_map: &mut HashMap<PathBuf, HashMap<usize, HashSet<usize>>>,
session_id: &str,
query: &str,
) -> Result<usize> {
let query_hash = hash_query(query);
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
let cache_path = SessionCache::get_cache_path(session_id, &query_hash);
let is_new_session = !cache_path.exists();
if is_new_session {
if debug_mode {
println!("DEBUG: New session, not filtering matched lines");
}
return Ok(0);
}
let cache = SessionCache::load(session_id, &query_hash)?;
if cache.block_identifiers.is_empty() {
if debug_mode {
println!("DEBUG: Cache is empty, not filtering matched lines");
}
return Ok(0);
}
if debug_mode {
println!(
"DEBUG: Early filtering of matched lines against {} cached blocks",
cache.block_identifiers.len()
);
}
let mut skipped_count = 0;
let mut files_to_remove = Vec::new();
for (file_path, term_map) in file_term_map.iter_mut() {
if term_map.is_empty() {
continue;
}
let mut all_lines = HashSet::new();
for lineset in term_map.values() {
all_lines.extend(lineset.iter());
}
if debug_mode {
println!(
"DEBUG: File {:?} has {} matched lines before filtering",
file_path,
all_lines.len()
);
}
let mut lines_to_remove = HashSet::new();
for &line_num in &all_lines {
let path_str = file_path.to_string_lossy();
let normalized_path = normalize_path(&path_str);
let line_cache_key = format!("{normalized_path}:{line_num}");
let is_cached = cache.block_identifiers.iter().any(|block_id| {
if let Some(colon_pos) = block_id.find(':') {
if let Some(dash_pos) = block_id[colon_pos + 1..].find('-') {
let file_part = &block_id[..colon_pos];
let start_line_str = &block_id[colon_pos + 1..colon_pos + 1 + dash_pos];
let end_line_str = &block_id[colon_pos + 1 + dash_pos + 1..];
if let (Ok(start_line), Ok(end_line)) = (
start_line_str.parse::<usize>(),
end_line_str.parse::<usize>(),
) {
let path_str = file_path.to_string_lossy();
let normalized_path = normalize_path(&path_str);
let normalized_file_part = normalize_path(file_part);
return normalized_file_part == normalized_path
&& line_num >= start_line
&& line_num <= end_line;
}
}
}
false
});
if is_cached {
if debug_mode && skipped_count < 5 {
println!("DEBUG: Skipping cached line: {line_cache_key}");
}
lines_to_remove.insert(line_num);
skipped_count += 1;
}
}
for term_lines in term_map.values_mut() {
for line in &lines_to_remove {
term_lines.remove(line);
}
}
term_map.retain(|_, lines| !lines.is_empty());
if term_map.is_empty() {
files_to_remove.push(file_path.clone());
}
if debug_mode {
let remaining_lines: HashSet<_> =
term_map.values().flat_map(|lines| lines.iter()).collect();
println!(
"DEBUG: File {:?} has {} matched lines after filtering",
file_path,
remaining_lines.len()
);
}
}
for file in files_to_remove {
file_term_map.remove(&file);
}
if debug_mode {
println!(
"DEBUG: Early filtering removed {} cached lines, {} files remain",
skipped_count,
file_term_map.len()
);
}
Ok(skipped_count)
}
pub fn add_results_to_cache(results: &[SearchResult], session_id: &str, query: &str) -> Result<()> {
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
let query_hash = hash_query(query);
let mut cache = SessionCache::load(session_id, &query_hash)?;
if debug_mode {
println!(
"DEBUG: Adding {} results to cache for session {}",
results.len(),
session_id
);
println!(
"DEBUG: Cache had {} entries before update",
cache.block_identifiers.len()
);
}
let mut new_entries = 0;
for result in results {
let cache_key = generate_cache_key(result);
if !cache.is_cached(&cache_key) {
new_entries += 1;
if debug_mode && new_entries <= 5 {
println!("DEBUG: Adding new cache entry: {cache_key}");
}
}
cache.add_to_cache(cache_key);
}
if debug_mode {
println!("DEBUG: Added {new_entries} new entries to cache");
println!(
"DEBUG: Cache now has {} entries",
cache.block_identifiers.len()
);
}
cache.save()?;
Ok(())
}
pub fn debug_print_cache(session_id: &str, query: &str) -> Result<()> {
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
if !debug_mode {
return Ok(());
}
let query_hash = hash_query(query);
let cache = SessionCache::load(session_id, &query_hash)?;
println!("DEBUG: Cache for session {session_id} with query hash {query_hash}");
println!(
"DEBUG: Contains {} cached blocks",
cache.block_identifiers.len()
);
for (i, block_id) in cache.block_identifiers.iter().enumerate().take(10) {
println!("DEBUG: Cached block {i}: {block_id}");
}
if cache.block_identifiers.len() > 10 {
let _remaining = cache.block_identifiers.len() - 10;
println!("DEBUG: ... and {} more", cache.block_identifiers.len() - 10);
}
Ok(())
}
pub fn generate_session_id() -> Result<(&'static str, bool)> {
let debug_mode = std::env::var("DEBUG").unwrap_or_default() == "1";
if (0..10).next().is_some() {
let session_id: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(4)
.map(char::from)
.collect();
let session_id = session_id.to_lowercase();
if debug_mode {
println!("DEBUG: Generated session ID: {session_id}");
}
if debug_mode {
println!("DEBUG: Generated new session ID: {session_id}");
}
let static_id: &'static str = Box::leak(session_id.into_boxed_str());
return Ok((static_id, true));
}
Err(anyhow::anyhow!(
"Failed to generate a unique session ID after multiple attempts"
))
}
#[cfg(test)]
mod tests {
use super::*;
use probe_code::models::SearchResult;
#[test]
fn test_path_normalization() {
assert_eq!(normalize_path("./path/to/file.rs"), "path/to/file.rs");
assert_eq!(normalize_path("path/to/file.rs"), "path/to/file.rs");
}
#[test]
fn test_query_hashing() {
let hash1 = hash_query("query1");
let hash2 = hash_query("query2");
assert_ne!(hash1, hash2);
let hash3 = hash_query("query1");
assert_eq!(hash1, hash3);
}
#[test]
fn test_cache_key_generation_with_different_path_formats() {
let result1 = SearchResult {
file: "./path/to/file.rs".to_string(),
lines: (10, 20),
node_type: "function".to_string(),
code: "".to_string(),
matched_by_filename: None,
rank: None,
score: None,
tfidf_score: None,
bm25_score: None,
tfidf_rank: None,
bm25_rank: None,
new_score: None,
hybrid2_rank: None,
combined_score_rank: None,
file_unique_terms: None,
file_total_matches: None,
file_match_rank: None,
block_unique_terms: None,
block_total_matches: None,
parent_file_id: None,
block_id: None,
matched_keywords: None,
tokenized_content: None,
};
let result2 = SearchResult {
file: "path/to/file.rs".to_string(),
lines: (10, 20),
node_type: "function".to_string(),
code: "".to_string(),
matched_by_filename: None,
rank: None,
score: None,
tfidf_score: None,
bm25_score: None,
tfidf_rank: None,
bm25_rank: None,
new_score: None,
hybrid2_rank: None,
combined_score_rank: None,
file_unique_terms: None,
file_total_matches: None,
file_match_rank: None,
block_unique_terms: None,
block_total_matches: None,
parent_file_id: None,
block_id: None,
matched_keywords: None,
tokenized_content: None,
};
let key1 = generate_cache_key(&result1);
let key2 = generate_cache_key(&result2);
assert_eq!(key1, key2);
assert_eq!(key1, "path/to/file.rs:10-20");
}
#[test]
fn test_session_cache_with_query_hash() {
let session_id = "test_session";
let query1 = "query1";
let query2 = "query2";
let hash1 = hash_query(query1);
let hash2 = hash_query(query2);
let path1 = SessionCache::get_cache_path(session_id, &hash1);
let path2 = SessionCache::get_cache_path(session_id, &hash2);
assert_ne!(path1, path2);
let cache1 = SessionCache::new(session_id.to_string(), hash1);
let cache2 = SessionCache::new(session_id.to_string(), hash2);
assert_ne!(cache1.query_hash, cache2.query_hash);
}
}