Complete TUI Phase 3 implementation with all 5 power feature screens: - Who screen: 5 modes (expert/workload/reviews/active/overlap) with mode tabs, input bar, result rendering, and hint bar - Search screen: full-text search with result list and scoring display - Timeline screen: chronological event feed with time-relative display - Trace screen: file provenance chains with expand/collapse, rename tracking, and linked issues/discussions - File History screen: per-file MR timeline with rename chain display and discussion snippets Also includes: - Command palette overlay (fuzzy search) - Bootstrap screen (initial sync flow) - Action layer split from monolithic action.rs to per-screen modules - Entity and render cache infrastructure - Shared who_types module in core crate - All screens wired into view/mod.rs dispatch - 597 tests passing, clippy clean (pedantic + nursery), fmt clean
362 lines
12 KiB
Rust
362 lines
12 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use anyhow::{Context, Result};
|
|
use rusqlite::Connection;
|
|
|
|
use crate::message::{EntityKey, EntityKind, SearchMode, SearchResult};
|
|
use crate::state::search::SearchCapabilities;
|
|
|
|
/// Probe the database to detect available search indexes.
|
|
///
|
|
/// Checks for FTS5 documents and embedding metadata. Returns capabilities
|
|
/// that the UI uses to gate available search modes.
|
|
pub fn fetch_search_capabilities(conn: &Connection) -> Result<SearchCapabilities> {
|
|
// FTS: check if documents_fts has rows via the docsize shadow table
|
|
// (B-tree, not virtual table scan).
|
|
let has_fts = conn
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM documents_fts_docsize LIMIT 1)",
|
|
[],
|
|
|r| r.get::<_, bool>(0),
|
|
)
|
|
.unwrap_or(false);
|
|
|
|
// Embeddings: count rows in embedding_metadata.
|
|
let embedding_count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM embedding_metadata", [], |r| r.get(0))
|
|
.unwrap_or(0);
|
|
|
|
let has_embeddings = embedding_count > 0;
|
|
|
|
// Coverage: embeddings / documents percentage.
|
|
let doc_count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
|
.unwrap_or(0);
|
|
|
|
let embedding_coverage_pct = if doc_count > 0 {
|
|
(embedding_count as f32 / doc_count as f32 * 100.0).min(100.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
Ok(SearchCapabilities {
|
|
has_fts,
|
|
has_embeddings,
|
|
embedding_coverage_pct,
|
|
})
|
|
}
|
|
|
|
/// Execute a search query against the local database.
|
|
///
|
|
/// Dispatches to the correct search backend based on mode:
|
|
/// - Lexical: FTS5 only (documents_fts)
|
|
/// - Hybrid: FTS5 + vector merge via RRF
|
|
/// - Semantic: vector cosine similarity only
|
|
///
|
|
/// Returns results sorted by score descending.
|
|
pub fn execute_search(
|
|
conn: &Connection,
|
|
query: &str,
|
|
mode: SearchMode,
|
|
limit: usize,
|
|
) -> Result<Vec<SearchResult>> {
|
|
if query.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
match mode {
|
|
SearchMode::Lexical => execute_fts_search(conn, query, limit),
|
|
SearchMode::Hybrid | SearchMode::Semantic => {
|
|
// Hybrid and Semantic require the full search pipeline from the
|
|
// core crate (async, Ollama client). For now, fall back to FTS
|
|
// for Hybrid and return empty for Semantic-only.
|
|
// TODO: Wire up async search dispatch when core search is integrated.
|
|
if mode == SearchMode::Hybrid {
|
|
execute_fts_search(conn, query, limit)
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// FTS5 full-text search against the documents table.
|
|
fn execute_fts_search(conn: &Connection, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
|
|
// Sanitize the query for FTS5 (escape special chars, wrap terms in quotes).
|
|
let safe_query = sanitize_fts_query(query);
|
|
if safe_query.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Resolve project_path via JOIN through projects table.
|
|
// Resolve iid via JOIN through the source entity table (issues or merge_requests).
|
|
// snippet column 1 = content_text (column 0 is title).
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT d.source_type, d.source_id, d.title, d.project_id,
|
|
p.path_with_namespace,
|
|
snippet(documents_fts, 1, '>>>', '<<<', '...', 32) AS snip,
|
|
bm25(documents_fts) AS score,
|
|
COALESCE(i.iid, mr.iid) AS entity_iid
|
|
FROM documents_fts
|
|
JOIN documents d ON documents_fts.rowid = d.id
|
|
JOIN projects p ON p.id = d.project_id
|
|
LEFT JOIN issues i ON d.source_type = 'issue' AND i.id = d.source_id
|
|
LEFT JOIN merge_requests mr ON d.source_type = 'merge_request' AND mr.id = d.source_id
|
|
WHERE documents_fts MATCH ?1
|
|
ORDER BY score
|
|
LIMIT ?2",
|
|
)
|
|
.context("preparing FTS search query")?;
|
|
|
|
let rows = stmt
|
|
.query_map(rusqlite::params![safe_query, limit as i64], |row| {
|
|
let source_type: String = row.get(0)?;
|
|
let _source_id: i64 = row.get(1)?;
|
|
let title: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
|
|
let project_id: i64 = row.get(3)?;
|
|
let project_path: String = row.get::<_, Option<String>>(4)?.unwrap_or_default();
|
|
let snippet: String = row.get::<_, Option<String>>(5)?.unwrap_or_default();
|
|
let score: f64 = row.get(6)?;
|
|
let entity_iid: Option<i64> = row.get(7)?;
|
|
Ok((
|
|
source_type,
|
|
project_id,
|
|
title,
|
|
project_path,
|
|
snippet,
|
|
score,
|
|
entity_iid,
|
|
))
|
|
})
|
|
.context("executing FTS search")?;
|
|
|
|
let mut results = Vec::new();
|
|
for row in rows {
|
|
let (source_type, project_id, title, project_path, snippet, score, entity_iid) =
|
|
row.context("reading FTS search row")?;
|
|
|
|
let kind = match source_type.as_str() {
|
|
"issue" => EntityKind::Issue,
|
|
"merge_request" | "mr" => EntityKind::MergeRequest,
|
|
_ => continue, // Skip unknown source types (discussion, note).
|
|
};
|
|
|
|
// Skip if we couldn't resolve the entity's iid (orphaned document).
|
|
let Some(iid) = entity_iid else {
|
|
continue;
|
|
};
|
|
|
|
let key = EntityKey {
|
|
project_id,
|
|
iid,
|
|
kind,
|
|
};
|
|
|
|
results.push(SearchResult {
|
|
key,
|
|
title,
|
|
score: score.abs(), // bm25 returns negative scores; lower = better.
|
|
snippet,
|
|
project_path,
|
|
});
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Sanitize a user query for FTS5 MATCH syntax.
|
|
///
|
|
/// Wraps individual terms in double quotes to prevent FTS5 syntax errors
|
|
/// from user-typed operators (AND, OR, NOT, *, etc.).
|
|
fn sanitize_fts_query(query: &str) -> String {
|
|
query
|
|
.split_whitespace()
|
|
.map(|term| {
|
|
// Strip any existing quotes and re-wrap.
|
|
let clean = term.replace('"', "");
|
|
if clean.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("\"{clean}\"")
|
|
}
|
|
})
|
|
.filter(|s| !s.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Create the minimal schema needed for search queries.
|
|
fn create_dashboard_schema(conn: &Connection) {
|
|
conn.execute_batch(
|
|
"
|
|
CREATE TABLE projects (
|
|
id INTEGER PRIMARY KEY,
|
|
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
|
path_with_namespace TEXT NOT NULL
|
|
);
|
|
CREATE TABLE issues (
|
|
id INTEGER PRIMARY KEY,
|
|
gitlab_id INTEGER UNIQUE NOT NULL,
|
|
project_id INTEGER NOT NULL,
|
|
iid INTEGER NOT NULL,
|
|
title TEXT,
|
|
state TEXT NOT NULL,
|
|
author_username TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
last_seen_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE merge_requests (
|
|
id INTEGER PRIMARY KEY,
|
|
gitlab_id INTEGER UNIQUE NOT NULL,
|
|
project_id INTEGER NOT NULL,
|
|
iid INTEGER NOT NULL,
|
|
title TEXT,
|
|
state TEXT,
|
|
author_username TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
last_seen_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE discussions (
|
|
id INTEGER PRIMARY KEY,
|
|
gitlab_discussion_id TEXT NOT NULL,
|
|
project_id INTEGER NOT NULL,
|
|
noteable_type TEXT NOT NULL,
|
|
last_seen_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE notes (
|
|
id INTEGER PRIMARY KEY,
|
|
gitlab_id INTEGER UNIQUE NOT NULL,
|
|
discussion_id INTEGER NOT NULL,
|
|
project_id INTEGER NOT NULL,
|
|
is_system INTEGER NOT NULL DEFAULT 0,
|
|
author_username TEXT,
|
|
body TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
last_seen_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE documents (
|
|
id INTEGER PRIMARY KEY,
|
|
source_type TEXT NOT NULL,
|
|
source_id INTEGER NOT NULL,
|
|
project_id INTEGER NOT NULL,
|
|
content_text TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL
|
|
);
|
|
CREATE TABLE embedding_metadata (
|
|
document_id INTEGER NOT NULL,
|
|
chunk_index INTEGER NOT NULL DEFAULT 0,
|
|
model TEXT NOT NULL,
|
|
dims INTEGER NOT NULL,
|
|
document_hash TEXT NOT NULL,
|
|
chunk_hash TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY(document_id, chunk_index)
|
|
);
|
|
CREATE TABLE sync_runs (
|
|
id INTEGER PRIMARY KEY,
|
|
started_at INTEGER NOT NULL,
|
|
heartbeat_at INTEGER NOT NULL,
|
|
finished_at INTEGER,
|
|
status TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
error TEXT
|
|
);
|
|
",
|
|
)
|
|
.expect("create dashboard schema");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts_query_wraps_terms() {
|
|
let result = sanitize_fts_query("hello world");
|
|
assert_eq!(result, r#""hello" "world""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts_query_strips_quotes() {
|
|
let result = sanitize_fts_query(r#""hello" "world""#);
|
|
assert_eq!(result, r#""hello" "world""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts_query_empty() {
|
|
assert_eq!(sanitize_fts_query(""), "");
|
|
assert_eq!(sanitize_fts_query(" "), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts_query_special_chars() {
|
|
// FTS5 operators should be safely wrapped in quotes.
|
|
let result = sanitize_fts_query("NOT AND OR");
|
|
assert_eq!(result, r#""NOT" "AND" "OR""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_search_capabilities_no_tables() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
create_dashboard_schema(&conn);
|
|
|
|
let caps = fetch_search_capabilities(&conn).unwrap();
|
|
assert!(!caps.has_fts);
|
|
assert!(!caps.has_embeddings);
|
|
assert!(!caps.has_any_index());
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_search_capabilities_with_fts() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
create_dashboard_schema(&conn);
|
|
// Create FTS table and its shadow table.
|
|
conn.execute_batch(
|
|
"CREATE VIRTUAL TABLE documents_fts USING fts5(content);
|
|
INSERT INTO documents_fts(content) VALUES ('test document');",
|
|
)
|
|
.unwrap();
|
|
|
|
let caps = fetch_search_capabilities(&conn).unwrap();
|
|
assert!(caps.has_fts);
|
|
assert!(!caps.has_embeddings);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_search_capabilities_with_embeddings() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
create_dashboard_schema(&conn);
|
|
// Insert a document so coverage calculation works.
|
|
conn.execute_batch(
|
|
"INSERT INTO documents(id, source_type, source_id, project_id, content_text, content_hash)
|
|
VALUES (1, 'issue', 1, 1, 'body text', 'abc');
|
|
INSERT INTO embedding_metadata(document_id, chunk_index, model, dims, document_hash, chunk_hash, created_at)
|
|
VALUES (1, 0, 'test', 384, 'abc', 'def', 1700000000);",
|
|
)
|
|
.unwrap();
|
|
|
|
let caps = fetch_search_capabilities(&conn).unwrap();
|
|
assert!(!caps.has_fts);
|
|
assert!(caps.has_embeddings);
|
|
assert!(caps.embedding_coverage_pct > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_execute_search_empty_query_returns_empty() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
let results = execute_search(&conn, "", SearchMode::Lexical, 10).unwrap();
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_execute_search_whitespace_only_returns_empty() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
let results = execute_search(&conn, " ", SearchMode::Lexical, 10).unwrap();
|
|
assert!(results.is_empty());
|
|
}
|
|
}
|