use rusqlite::{params, Connection, Result as SqliteResult};
use crate::models::Event;
#[derive(Debug, Clone)]
pub struct SearchResult {
pub entity_type: String,
pub entity_id: String,
pub title: String,
pub snippet: String,
}
pub fn search(
conn: &Connection,
query: &str,
entity_type: Option<&str>,
) -> SqliteResult<Vec<SearchResult>> {
let mut results = Vec::new();
let sanitized_query = sanitize_fts_query(query);
let should_search = |et: &str| entity_type.is_none() || entity_type == Some(et);
if should_search("problem") {
let fts_query = format!("entity_type:problem AND ({})", sanitized_query);
let mut stmt = conn.prepare(
"SELECT p.id, p.title, p.description
FROM problems p
WHERE p.id IN (
SELECT entity_id FROM fts WHERE fts MATCH ?1
)",
)?;
let rows = stmt.query_map(params![fts_query], |row| {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
let description: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let snippet = create_snippet(&description, "", &title, query);
Ok(SearchResult {
entity_type: "problem".to_string(),
entity_id: id,
title,
snippet,
})
})?;
for result in rows {
results.push(result?);
}
}
if should_search("solution") {
let fts_query = format!("entity_type:solution AND ({})", sanitized_query);
let mut stmt = conn.prepare(
"SELECT s.id, s.title, s.approach
FROM solutions s
WHERE s.id IN (
SELECT entity_id FROM fts WHERE fts MATCH ?1
)",
)?;
let rows = stmt.query_map(params![fts_query], |row| {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
let approach: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let snippet = create_snippet(&approach, "", &title, query);
Ok(SearchResult {
entity_type: "solution".to_string(),
entity_id: id,
title,
snippet,
})
})?;
for result in rows {
results.push(result?);
}
}
if should_search("critique") {
let fts_query = format!("entity_type:critique AND ({})", sanitized_query);
let mut stmt = conn.prepare(
"SELECT c.id, c.title, c.argument
FROM critiques c
WHERE c.id IN (
SELECT entity_id FROM fts WHERE fts MATCH ?1
)",
)?;
let rows = stmt.query_map(params![fts_query], |row| {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
let argument: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let snippet = create_snippet(&argument, "", &title, query);
Ok(SearchResult {
entity_type: "critique".to_string(),
entity_id: id,
title,
snippet,
})
})?;
for result in rows {
results.push(result?);
}
}
if should_search("milestone") {
let fts_query = format!("entity_type:milestone AND ({})", sanitized_query);
let mut stmt = conn.prepare(
"SELECT m.id, m.title, m.description
FROM milestones m
WHERE m.id IN (
SELECT entity_id FROM fts WHERE fts MATCH ?1
)",
)?;
let rows = stmt.query_map(params![fts_query], |row| {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
let description: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let snippet = create_snippet(&description, "", &title, query);
Ok(SearchResult {
entity_type: "milestone".to_string(),
entity_id: id,
title,
snippet,
})
})?;
for result in rows {
results.push(result?);
}
}
results.truncate(50);
Ok(results)
}
fn sanitize_fts_query(query: &str) -> String {
let words: Vec<String> = query
.split_whitespace()
.map(|w| {
let escaped = w.replace('"', "\"\"");
format!("\"{}\"", escaped)
})
.collect();
if words.is_empty() {
"\"\"".to_string()
} else {
words.join(" ")
}
}
fn create_snippet(primary: &str, secondary: &str, fallback: &str, query: &str) -> String {
let first_word = query
.split_whitespace()
.next()
.unwrap_or(query)
.to_lowercase();
let text = if primary.to_lowercase().contains(&first_word) {
primary
} else if secondary.to_lowercase().contains(&first_word) {
secondary
} else if fallback.to_lowercase().contains(&first_word) {
fallback
} else if !primary.is_empty() {
primary
} else if !secondary.is_empty() {
secondary
} else {
fallback
};
if text.chars().count() > 200 {
let truncated: String = text.chars().take(197).collect();
format!("{}...", truncated)
} else {
text.to_string()
}
}
pub fn search_events(conn: &Connection, query: &str) -> SqliteResult<Vec<Event>> {
let escaped = query.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{}%", escaped);
let mut stmt = conn.prepare(
"SELECT id, timestamp, event_type, entity_id, actor, rationale, refs, extra
FROM events
WHERE rationale LIKE ?1 ESCAPE '\\'
ORDER BY timestamp DESC
LIMIT 50",
)?;
let rows = stmt.query_map(params![pattern], row_to_event)?;
rows.collect()
}
#[derive(Debug, Clone)]
pub struct SimilarityResult {
pub entity_type: String,
pub entity_id: String,
pub title: String,
pub similarity: f32,
}
pub fn similarity_search(
conn: &Connection,
query_embedding: &[f32],
entity_type: Option<&str>,
exclude_id: Option<&str>,
top_k: usize,
) -> SqliteResult<Vec<SimilarityResult>> {
use crate::db::embeddings::list_embeddings;
use crate::embeddings::cosine_similarity;
let embeddings = list_embeddings(conn, entity_type)?;
let mut results: Vec<SimilarityResult> = embeddings
.into_iter()
.filter(|e| {
exclude_id.is_none_or(|id| e.entity_id != id)
})
.map(|e| {
let similarity = cosine_similarity(query_embedding, &e.embedding);
SimilarityResult {
entity_type: e.entity_type,
entity_id: e.entity_id,
title: String::new(), similarity,
}
})
.collect();
results.sort_by(|a, b| {
b.similarity
.partial_cmp(&a.similarity)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(top_k);
for result in &mut results {
result.title = get_entity_title(conn, &result.entity_type, &result.entity_id)?;
}
Ok(results)
}
pub fn find_similar(
conn: &Connection,
entity_type: &str,
entity_id: &str,
filter_type: Option<&str>,
top_k: usize,
) -> SqliteResult<Vec<SimilarityResult>> {
use crate::db::embeddings::load_embedding;
let embedding = load_embedding(conn, entity_type, entity_id)?;
match embedding {
Some(record) => {
similarity_search(conn, &record.embedding, filter_type, Some(entity_id), top_k)
}
None => Ok(Vec::new()),
}
}
pub fn merge_with_rrf(
fts_results: Vec<SearchResult>,
semantic_results: Vec<SimilarityResult>,
k: usize,
) -> Vec<SearchResult> {
use std::collections::HashMap;
let mut scores: HashMap<(String, String), f32> = HashMap::new();
let mut titles: HashMap<(String, String), String> = HashMap::new();
let mut snippets: HashMap<(String, String), String> = HashMap::new();
for (rank, result) in fts_results.iter().enumerate() {
let key = (result.entity_type.clone(), result.entity_id.clone());
let rrf_score = 1.0 / (k as f32 + rank as f32 + 1.0);
*scores.entry(key.clone()).or_insert(0.0) += rrf_score;
titles.insert(key.clone(), result.title.clone());
if !result.snippet.is_empty() {
snippets
.entry(key)
.or_insert_with(|| result.snippet.clone());
}
}
for (rank, result) in semantic_results.iter().enumerate() {
let key = (result.entity_type.clone(), result.entity_id.clone());
let rrf_score = 1.0 / (k as f32 + rank as f32 + 1.0);
*scores.entry(key.clone()).or_insert(0.0) += rrf_score;
titles.entry(key).or_insert_with(|| result.title.clone());
}
let mut merged: Vec<_> = scores.into_iter().collect();
merged.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
merged
.into_iter()
.map(|((entity_type, entity_id), _score)| {
let title = titles
.get(&(entity_type.clone(), entity_id.clone()))
.cloned()
.unwrap_or_default();
let snippet = snippets
.get(&(entity_type.clone(), entity_id.clone()))
.cloned()
.unwrap_or_default();
SearchResult {
entity_type,
entity_id,
title,
snippet,
}
})
.collect()
}
fn get_entity_title(conn: &Connection, entity_type: &str, entity_id: &str) -> SqliteResult<String> {
let sql = match entity_type {
"problem" => "SELECT title FROM problems WHERE id = ?1",
"solution" => "SELECT title FROM solutions WHERE id = ?1",
"critique" => "SELECT title FROM critiques WHERE id = ?1",
"milestone" => "SELECT title FROM milestones WHERE id = ?1",
_ => return Ok(String::new()),
};
conn.query_row(sql, params![entity_id], |row| row.get(0))
.or(Ok(String::new()))
}
fn row_to_event(row: &rusqlite::Row) -> SqliteResult<Event> {
use crate::models::EventExtra;
use chrono::{DateTime, Utc};
let timestamp_str: String = row.get(1)?;
let event_type_str: String = row.get(2)?;
let refs_json: String = row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "[]".to_string());
let extra_json: String = row
.get::<_, Option<String>>(7)?
.unwrap_or_else(|| "{}".to_string());
let event_type = parse_event_type(&event_type_str);
let refs: Vec<String> = serde_json::from_str(&refs_json).unwrap_or_default();
let extra: EventExtra = serde_json::from_str(&extra_json).unwrap_or_default();
Ok(Event {
when: DateTime::parse_from_rfc3339(×tamp_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
event_type,
entity: row.get(3)?,
by: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
rationale: row.get(5)?,
refs,
extra,
})
}
fn parse_event_type(s: &str) -> crate::models::EventType {
use crate::models::EventType;
match s {
"problem_created" => EventType::ProblemCreated,
"problem_solved" => EventType::ProblemSolved,
"problem_dissolved" => EventType::ProblemDissolved,
"problem_reopened" => EventType::ProblemReopened,
"solution_created" => EventType::SolutionCreated,
"solution_submitted" => EventType::SolutionSubmitted,
"solution_approved" => EventType::SolutionApproved,
"solution_withdrawn" => EventType::SolutionWithdrawn,
"critique_raised" => EventType::CritiqueRaised,
"critique_addressed" => EventType::CritiqueAddressed,
"critique_dismissed" => EventType::CritiqueDismissed,
"critique_validated" => EventType::CritiqueValidated,
"critique_replied" => EventType::CritiqueReplied,
"milestone_created" => EventType::MilestoneCreated,
"milestone_completed" => EventType::MilestoneCompleted,
other => {
eprintln!("Warning: unknown event type '{}', skipping", other);
EventType::ProblemCreated
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::entities::{upsert_problem, upsert_solution};
use crate::db::sync::rebuild_fts;
use crate::db::Database;
use crate::models::{Problem, Solution};
#[test]
fn test_fts_search() {
let db = Database::open_in_memory().expect("Failed to open database");
let conn = db.conn();
let mut p1 = Problem::new("p1".to_string(), "Fix login authentication".to_string());
p1.description =
"Users cannot login when using OAuth. Need to fix the authentication flow.".to_string();
upsert_problem(conn, &p1).expect("Failed to insert problem");
let mut p2 = Problem::new(
"p2".to_string(),
"Performance issues on dashboard".to_string(),
);
p2.description = "The dashboard loads slowly due to N+1 queries.".to_string();
upsert_problem(conn, &p2).expect("Failed to insert problem");
let mut s1 = Solution::new(
"s1".to_string(),
"Implement OAuth2 login flow".to_string(),
"p1".to_string(),
);
s1.approach = "Use the standard OAuth2 flow with refresh tokens.".to_string();
upsert_solution(conn, &s1).expect("Failed to insert solution");
rebuild_fts(&db).expect("Failed to rebuild FTS");
let results = search(conn, "login", None).expect("Failed to search");
assert_eq!(
results.len(),
2,
"Expected 2 results for 'login', got {}",
results.len()
);
let entity_ids: Vec<&str> = results.iter().map(|r| r.entity_id.as_str()).collect();
assert!(
entity_ids.contains(&"p1"),
"Expected p1 in results: {:?}",
entity_ids
);
assert!(
entity_ids.contains(&"s1"),
"Expected s1 in results: {:?}",
entity_ids
);
let p1_result = results.iter().find(|r| r.entity_id == "p1").unwrap();
assert_eq!(p1_result.entity_type, "problem");
assert_eq!(p1_result.title, "Fix login authentication");
let s1_result = results.iter().find(|r| r.entity_id == "s1").unwrap();
assert_eq!(s1_result.entity_type, "solution");
assert_eq!(s1_result.title, "Implement OAuth2 login flow");
let results = search(conn, "login", Some("problem")).expect("Failed to search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_id, "p1");
assert_eq!(results[0].entity_type, "problem");
let results = search(conn, "nonexistent", None).expect("Failed to search");
assert_eq!(results.len(), 0);
let results = search(conn, "dashboard", None).expect("Failed to search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_id, "p2");
assert_eq!(results[0].title, "Performance issues on dashboard");
}
#[test]
fn test_search_events() {
use crate::db::events::insert_event;
use crate::models::{Event, EventType};
let db = Database::open_in_memory().expect("Failed to open database");
let conn = db.conn();
let event1 = Event::new(
EventType::ProblemCreated,
"p1".to_string(),
"alice".to_string(),
)
.with_rationale("Identified login issue during security audit");
insert_event(conn, &event1).expect("Failed to insert event");
let event2 = Event::new(
EventType::SolutionApproved,
"s1".to_string(),
"bob".to_string(),
)
.with_rationale("This approach handles edge cases correctly");
insert_event(conn, &event2).expect("Failed to insert event");
let event3 = Event::new(
EventType::ProblemCreated,
"p2".to_string(),
"charlie".to_string(),
)
.with_rationale("Performance regression after last deploy");
insert_event(conn, &event3).expect("Failed to insert event");
let results = search_events(conn, "login").expect("Failed to search events");
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity, "p1");
assert!(results[0].rationale.as_ref().unwrap().contains("login"));
let results = search_events(conn, "correctly").expect("Failed to search events");
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity, "s1");
let results = search_events(conn, "nonexistent").expect("Failed to search events");
assert_eq!(results.len(), 0);
}
#[test]
fn test_fts_snippet_truncation() {
let db = Database::open_in_memory().expect("Failed to open database");
let conn = db.conn();
let mut problem = Problem::new("p1".to_string(), "Complex issue".to_string());
problem.description = "A".repeat(500); problem.description.push_str(" authentication test ");
problem.description.push_str(&"B".repeat(500));
upsert_problem(conn, &problem).expect("Failed to insert problem");
rebuild_fts(&db).expect("Failed to rebuild FTS");
let results = search(conn, "authentication", None).expect("Failed to search");
assert_eq!(results.len(), 1);
assert!(
results[0].snippet.len() <= 203,
"Snippet should be truncated: len={}",
results[0].snippet.len()
);
}
#[test]
fn test_similarity_search() {
use crate::db::embeddings::upsert_embedding;
let db = Database::open_in_memory().expect("Failed to open database");
let conn = db.conn();
let p1 = Problem::new("p1".to_string(), "Auth problem".to_string());
upsert_problem(conn, &p1).expect("Failed to insert");
upsert_embedding(conn, "problem", "p1", "test", &[1.0, 0.0, 0.0]).expect("Failed");
let p2 = Problem::new("p2".to_string(), "Similar auth issue".to_string());
upsert_problem(conn, &p2).expect("Failed to insert");
upsert_embedding(conn, "problem", "p2", "test", &[0.9, 0.1, 0.0]).expect("Failed");
let p3 = Problem::new("p3".to_string(), "Unrelated problem".to_string());
upsert_problem(conn, &p3).expect("Failed to insert");
upsert_embedding(conn, "problem", "p3", "test", &[0.0, 0.0, 1.0]).expect("Failed");
let results = similarity_search(conn, &[1.0, 0.0, 0.0], None, Some("p1"), 10)
.expect("Failed to search");
assert_eq!(results.len(), 2);
assert_eq!(results[0].entity_id, "p2");
assert_eq!(results[1].entity_id, "p3");
assert!(results[0].similarity > results[1].similarity);
}
#[test]
fn test_find_similar() {
use crate::db::embeddings::upsert_embedding;
let db = Database::open_in_memory().expect("Failed to open database");
let conn = db.conn();
let p1 = Problem::new("p1".to_string(), "Problem one".to_string());
upsert_problem(conn, &p1).expect("Failed to insert");
upsert_embedding(conn, "problem", "p1", "test", &[1.0, 0.0]).expect("Failed");
let p2 = Problem::new("p2".to_string(), "Problem two".to_string());
upsert_problem(conn, &p2).expect("Failed to insert");
upsert_embedding(conn, "problem", "p2", "test", &[0.8, 0.2]).expect("Failed");
let results = find_similar(conn, "problem", "p1", None, 10).expect("Failed");
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_id, "p2");
}
#[test]
fn test_merge_with_rrf() {
let fts_results = vec![
SearchResult {
entity_type: "problem".to_string(),
entity_id: "p1".to_string(),
title: "First".to_string(),
snippet: "".to_string(),
},
SearchResult {
entity_type: "problem".to_string(),
entity_id: "p2".to_string(),
title: "Second".to_string(),
snippet: "".to_string(),
},
];
let semantic_results = vec![
SimilarityResult {
entity_type: "problem".to_string(),
entity_id: "p2".to_string(),
title: "Second".to_string(),
similarity: 0.9,
},
SimilarityResult {
entity_type: "problem".to_string(),
entity_id: "p3".to_string(),
title: "Third".to_string(),
similarity: 0.8,
},
];
let merged = merge_with_rrf(fts_results, semantic_results, 60);
assert_eq!(merged.len(), 3);
assert_eq!(merged[0].entity_id, "p2");
}
}