use rusqlite::Connection;
use serde::Serialize;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SearchScope {
#[default]
All,
PullRequests,
Issues,
Comments,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchHit {
pub key: String,
pub kind: String,
pub repository: Option<String>,
pub title: Option<String>,
pub author: Option<String>,
pub state: Option<String>,
pub snippet: String,
pub rank: f64,
}
#[derive(Debug, Default)]
pub struct SearchOptions {
pub scope: SearchScope,
pub repository: Option<String>,
pub limit: u32,
}
impl SearchOptions {
fn effective_limit(&self) -> u32 {
if self.limit == 0 { 20 } else { self.limit }
}
}
fn fts_quote(query: &str) -> String {
format!("\"{}\"", query.replace('"', "\"\""))
}
pub fn search(
connection: &Connection,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchHit>> {
let quoted = fts_quote(query);
let mut hits: Vec<SearchHit> = Vec::new();
let limit = options.effective_limit();
if matches!(options.scope, SearchScope::All | SearchScope::PullRequests) {
let sql = "SELECT f.pr_key, p.repo_key, p.title, p.author_key, p.state,
snippet(pull_requests_fts, -1, '[', ']', '…', 12), rank
FROM pull_requests_fts f
JOIN fact_pull_requests p ON p.pr_key = f.pr_key
WHERE pull_requests_fts MATCH ?1
AND (?2 IS NULL OR p.repo_key = ?2)
ORDER BY rank LIMIT ?3";
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map(
rusqlite::params![quoted, options.repository, limit],
|row| {
Ok(SearchHit {
key: row.get(0)?,
kind: "pull_request".into(),
repository: row.get(1)?,
title: row.get(2)?,
author: row.get(3)?,
state: row.get(4)?,
snippet: row.get(5)?,
rank: row.get(6)?,
})
},
)?;
for row in rows {
hits.push(row?);
}
}
if matches!(options.scope, SearchScope::All | SearchScope::Issues) {
let sql = "SELECT i.repo_key || '#' || i.number, i.repo_key, i.title, i.author_key,
i.state, snippet(issues_fts, -1, '[', ']', '…', 12), rank
FROM issues_fts f
JOIN issues i ON i.id = f.id
WHERE issues_fts MATCH ?1
AND (?2 IS NULL OR i.repo_key = ?2)
ORDER BY rank LIMIT ?3";
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map(
rusqlite::params![quoted, options.repository, limit],
|row| {
Ok(SearchHit {
key: row.get(0)?,
kind: "issue".into(),
repository: row.get(1)?,
title: row.get(2)?,
author: row.get(3)?,
state: row.get(4)?,
snippet: row.get(5)?,
rank: row.get(6)?,
})
},
)?;
for row in rows {
hits.push(row?);
}
}
if matches!(options.scope, SearchScope::All | SearchScope::Comments) {
let sql = "SELECT c.comment_key, c.pr_key, p.repo_key, p.title, c.author_key,
snippet(review_comments_fts, -1, '[', ']', '…', 12), rank
FROM review_comments_fts f
JOIN fact_review_comments c ON c.comment_key = f.comment_key
JOIN fact_pull_requests p ON p.pr_key = c.pr_key
WHERE review_comments_fts MATCH ?1
AND (?2 IS NULL OR p.repo_key = ?2)
ORDER BY rank LIMIT ?3";
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map(
rusqlite::params![quoted, options.repository, limit],
|row| {
Ok(SearchHit {
key: row.get::<_, String>(1)?, kind: "review_comment".into(),
repository: row.get(2)?,
title: row.get(3)?,
author: row.get(4)?,
state: None,
snippet: row.get(5)?,
rank: row.get(6)?,
})
},
)?;
for row in rows {
hits.push(row?);
}
let sql = "SELECT c.comment_key, c.parent_key, c.author_key,
snippet(issue_comments_fts, -1, '[', ']', '…', 12), rank
FROM issue_comments_fts f
JOIN fact_issue_comments c ON c.comment_key = f.comment_key
WHERE issue_comments_fts MATCH ?1
AND (?2 IS NULL OR c.parent_key LIKE ?2 || '%'
OR c.parent_key IN (SELECT id FROM issues WHERE repo_key = ?2))
ORDER BY rank LIMIT ?3";
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map(
rusqlite::params![quoted, options.repository, limit],
|row| {
Ok(SearchHit {
key: row.get::<_, String>(1)?, kind: "issue_comment".into(),
repository: None,
title: None,
author: row.get(2)?,
state: None,
snippet: row.get(3)?,
rank: row.get(4)?,
})
},
)?;
for row in rows {
hits.push(row?);
}
}
hits.sort_by(|a, b| {
a.rank
.partial_cmp(&b.rank)
.unwrap_or(std::cmp::Ordering::Equal)
});
hits.truncate(limit as usize);
Ok(hits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GithubDW;
fn seed(warehouse: &GithubDW) {
warehouse
.connection()
.execute_batch(
"INSERT INTO dim_entities (entity_key, entity_type, login, is_human, is_bot, name)
VALUES ('user:alice', 'user', 'alice', 1, 0, 'alice');
INSERT INTO dim_repositories (repo_key, owner, name)
VALUES ('octo/alpha', 'octo', 'alpha'), ('octo/beta', 'octo', 'beta');
INSERT INTO dim_date (date_key, year, quarter, month, day_of_month, day_of_week,
is_weekend, week_of_year, week_key, month_key, quarter_key, year_key, half_key)
VALUES ('2026-01-10', 2026, 1, 1, 10, 6, 1, 2, '2026-W02', '2026-01', '2026-Q1', '2026', '2026-H1');
INSERT INTO dim_time (time_key, hour, hour_12, am_pm, time_bucket, is_core_hours)
VALUES ('10:00', 10, 10, 'AM', 'Morning', 1);
INSERT INTO fact_pull_requests (pr_key, number, repo_key, author_key, state, title, body,
created_at, created_date_key, created_time_key)
VALUES ('octo/alpha#1', 1, 'octo/alpha', 'user:alice', 'MERGED',
'Fix connection pool exhaustion', 'Pool ran dry under load',
'2026-01-10T18:00:00Z', '2026-01-10', '10:00'),
('octo/beta#2', 2, 'octo/beta', 'user:alice', 'OPEN',
'Add retry logic', 'Retries with backoff',
'2026-01-10T19:00:00Z', '2026-01-10', '10:00');
INSERT INTO issues (id, repo_key, number, title, body, state, created_at, updated_at)
VALUES ('ISS1', 'octo/alpha', 5, 'Connection timeout on startup', 'Cold pool issue',
'open', '2026-01-10T18:00:00Z', '2026-01-10T18:00:00Z');
INSERT INTO fact_review_comments (comment_key, pr_key, author_key, body,
created_at, created_date_key, created_time_key)
VALUES ('RC1', 'octo/alpha#1', 'user:alice', 'The pool size should be configurable',
'2026-01-10T18:30:00Z', '2026-01-10', '10:00');",
)
.unwrap();
}
#[test]
fn searches_across_corpora_with_snippets() {
let warehouse = GithubDW::open_in_memory().unwrap();
seed(&warehouse);
let hits = search(
warehouse.connection(),
"connection",
&SearchOptions::default(),
)
.unwrap();
assert_eq!(hits.len(), 2, "PR title + issue title");
assert!(hits.iter().any(|hit| hit.kind == "pull_request"));
assert!(hits.iter().any(|hit| hit.kind == "issue"));
assert!(hits.iter().all(|hit| hit.snippet.contains('[')));
}
#[test]
fn substring_search_hits_partial_words() {
let warehouse = GithubDW::open_in_memory().unwrap();
seed(&warehouse);
let hits = search(
warehouse.connection(),
"onnection",
&SearchOptions::default(),
)
.unwrap();
assert!(!hits.is_empty());
}
#[test]
fn scope_and_repository_filters() {
let warehouse = GithubDW::open_in_memory().unwrap();
seed(&warehouse);
let conn = warehouse.connection();
let pr_only = search(
conn,
"pool",
&SearchOptions {
scope: SearchScope::PullRequests,
..Default::default()
},
)
.unwrap();
assert!(pr_only.iter().all(|hit| hit.kind == "pull_request"));
let comments = search(
conn,
"configurable",
&SearchOptions {
scope: SearchScope::Comments,
..Default::default()
},
)
.unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].kind, "review_comment");
assert_eq!(comments[0].key, "octo/alpha#1");
let beta_only = search(
conn,
"retry",
&SearchOptions {
repository: Some("octo/beta".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(beta_only.len(), 1);
let alpha_scoped = search(
conn,
"retry",
&SearchOptions {
repository: Some("octo/alpha".into()),
..Default::default()
},
)
.unwrap();
assert!(alpha_scoped.is_empty());
}
}