use super::error::{QueryError, Result};
use super::tokenizer::codenexus_tokenize;
use super::SearchResult;
use crate::model::NodeLabel;
use crate::storage::schema::{escape_cypher_string, escape_identifier, node_table_columns};
use crate::storage::StorageConnection;
use tracing::warn;
const SYMBOL_LABELS: &[NodeLabel] = &[
NodeLabel::Module,
NodeLabel::Class,
NodeLabel::Struct,
NodeLabel::Enum,
NodeLabel::Trait,
NodeLabel::Impl,
NodeLabel::Function,
NodeLabel::Method,
NodeLabel::Variable,
NodeLabel::GlobalVar,
NodeLabel::Const,
NodeLabel::Static,
NodeLabel::Macro,
NodeLabel::TypeAlias,
NodeLabel::Typedef,
NodeLabel::Namespace,
];
const FTS_NAME_INDEXES: &[(&str, NodeLabel)] = &[
("fts_function_name", NodeLabel::Function),
("fts_class_name", NodeLabel::Class),
("fts_method_name", NodeLabel::Method),
("fts_struct_name", NodeLabel::Struct),
("fts_enum_name", NodeLabel::Enum),
("fts_trait_name", NodeLabel::Trait),
("fts_macro_name", NodeLabel::Macro),
("fts_typedef_name", NodeLabel::Typedef),
("fts_namespace_name", NodeLabel::Namespace),
("fts_module_name", NodeLabel::Module),
("fts_variable_name", NodeLabel::Variable),
("fts_globalvar_name", NodeLabel::GlobalVar),
("fts_const_name", NodeLabel::Const),
("fts_static_name", NodeLabel::Static),
("fts_typealias_name", NodeLabel::TypeAlias),
];
pub struct FullTextSearcher<'a> {
conn: &'a StorageConnection,
}
impl<'a> FullTextSearcher<'a> {
#[must_use]
pub fn new(conn: &'a StorageConnection) -> Self {
Self { conn }
}
pub fn search(
&self,
text: &str,
project: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
if text.trim().is_empty() {
return Err(QueryError::InvalidQuery(
"fulltext query must not be empty".to_string(),
));
}
match self.try_fts_search(text, project, limit) {
Ok(results) => Ok(results),
Err(e) if is_fts_unsupported_error(&e) => {
warn!(error = %e, "FTS not supported, falling back to CONTAINS scan");
self.fallback_contains_search(text, project, limit)
}
Err(e) => Err(e),
}
}
fn try_fts_search(
&self,
text: &str,
project: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
let tokenized = codenexus_tokenize(text);
if tokenized.is_empty() {
return Err(QueryError::InvalidQuery(
"fulltext query tokenized to empty".to_string(),
));
}
let fts_query = tokenized.join(" ");
let escaped = escape_cypher_string(&fts_query);
let mut all_results = Vec::new();
for (index_name, label) in FTS_NAME_INDEXES {
let line_expr = if node_table_columns(*label).contains(&"startLine") {
"node.startLine"
} else {
"NULL"
};
let cypher = match project {
Some(p) => format!(
"CALL fts_search('{index_name}', '{escaped}') YIELD node \
WHERE node.project = '{}' \
RETURN node.name AS name, node.qualifiedName AS qn, \
node.filePath AS filePath, {line_expr} AS line;",
escape_cypher_string(p),
),
None => format!(
"CALL fts_search('{index_name}', '{escaped}') YIELD node \
RETURN node.name AS name, node.qualifiedName AS qn, \
node.filePath AS filePath, {line_expr} AS line;",
),
};
match self.conn.query(&cypher) {
Ok(rows) => {
all_results.extend(rows_to_search_results(rows, *label, text));
}
Err(e) => return Err(QueryError::from(e)),
}
}
sort_and_truncate(&mut all_results, limit);
Ok(all_results)
}
fn fallback_contains_search(
&self,
text: &str,
project: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
let tokens = codenexus_tokenize(text);
if tokens.is_empty() {
return Err(QueryError::InvalidQuery(
"fulltext query tokenized to empty".to_string(),
));
}
let or_clauses: Vec<String> = tokens
.iter()
.map(|t| {
format!(
"toLower(n.name) CONTAINS toLower('{}')",
escape_cypher_string(t)
)
})
.collect();
let where_inner = or_clauses.join(" OR ");
let mut results = Vec::new();
for &label in SYMBOL_LABELS {
let table = escape_identifier(label.table_name());
let line_expr = if node_table_columns(label).contains(&"startLine") {
"n.startLine"
} else {
"NULL"
};
let cypher = match project {
Some(p) => format!(
"MATCH (n:{table}) WHERE ({where_inner}) AND n.project = '{}' \
RETURN n.name AS name, n.qualifiedName AS qn, \
n.filePath AS filePath, {line_expr} AS line;",
escape_cypher_string(p),
),
None => format!(
"MATCH (n:{table}) WHERE ({where_inner}) \
RETURN n.name AS name, n.qualifiedName AS qn, \
n.filePath AS filePath, {line_expr} AS line;",
),
};
match self.conn.query(&cypher) {
Ok(rows) => results.extend(rows_to_search_results(rows, label, text)),
Err(_) => continue,
}
}
sort_and_truncate(&mut results, limit);
Ok(results)
}
}
fn rows_to_search_results(
rows: Vec<Vec<serde_json::Value>>,
label: NodeLabel,
query: &str,
) -> Vec<SearchResult> {
let label_str = label.to_string();
rows.into_iter()
.filter_map(|row| {
let name = row.first().and_then(|v| v.as_str())?.to_string();
let qualified_name = row.get(1).and_then(|v| v.as_str()).map(String::from);
let file_path = row.get(2).and_then(|v| v.as_str()).map(String::from);
let start_line = row
.get(3)
.and_then(|v| v.as_i64())
.and_then(|i| u32::try_from(i).ok());
let (score, reason) = relevance_score_with_reason(&name, query);
Some(SearchResult {
name,
label: label_str.clone(),
file_path,
start_line,
qualified_name,
score,
match_reason: reason.to_string(),
degree: 0,
})
})
.collect()
}
#[allow(dead_code)]
fn relevance_score(name: &str, query: &str) -> f64 {
relevance_score_with_reason(name, query).0
}
fn relevance_score_with_reason(name: &str, query: &str) -> (f64, &'static str) {
let name_lower = name.to_ascii_lowercase();
let query_lower = query.to_ascii_lowercase();
if name_lower == query_lower {
return (1.0, "exact name match");
}
if name_lower.starts_with(&query_lower) {
return (0.8, "prefix match");
}
let query_tokens = codenexus_tokenize(&query_lower);
let name_tokens = codenexus_tokenize(&name_lower);
if !query_tokens.is_empty() && !name_tokens.is_empty() {
let all_match = query_tokens
.iter()
.all(|qt| name_tokens.iter().any(|nt| nt == qt));
if all_match {
return (0.7, "token-aligned match");
}
}
if name_lower.contains(&query_lower) {
return (0.5, "substring match");
}
(0.3, "no match")
}
fn sort_and_truncate(results: &mut Vec<SearchResult>, limit: usize) {
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
});
if limit < results.len() {
results.truncate(limit);
}
}
fn is_fts_unsupported_error(err: &QueryError) -> bool {
let msg = err.to_string().to_ascii_lowercase();
msg.contains("not supported")
|| msg.contains("parser exception")
|| msg.contains("does not exist")
|| msg.contains("already exists")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Language, Node, NodeLabel};
use crate::storage::Repository;
fn fresh_repo() -> Repository {
Repository::in_memory().expect("in_memory repository")
}
fn sample_function(
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
) -> Node {
Node::builder(NodeLabel::Function, name, qn)
.id(id)
.project(project)
.file_path(file)
.start_line(line)
.end_line(line + 10)
.language(Language::Rust)
.signature("fn x()")
.build()
}
#[test]
fn search_falls_back_to_contains_when_fts_unavailable() {
let repo = fresh_repo();
repo.save_nodes(
&[
sample_function("f1", "demo", "parse_file", "demo.parse_file", "/a.rs", 1),
sample_function("f2", "demo", "parse_line", "demo.parse_line", "/b.rs", 1),
sample_function("f3", "demo", "read_input", "demo.read_input", "/a.rs", 10),
],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 100).expect("search");
let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"parse_file"));
assert!(names.contains(&"parse_line"));
assert!(!names.contains(&"read_input"));
}
#[test]
fn search_returns_results_sorted_by_relevance() {
let repo = fresh_repo();
repo.save_nodes(
&[
sample_function("f1", "demo", "parse", "demo.parse", "/a.rs", 1),
sample_function("f2", "demo", "parse_file", "demo.parse_file", "/a.rs", 5),
sample_function(
"f3",
"demo",
"my_parse_helper",
"demo.my_parse_helper",
"/b.rs",
1,
),
],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 100).expect("search");
assert!(!results.is_empty());
assert_eq!(results[0].name, "parse");
assert!(results[0].score >= results[1].score);
}
#[test]
fn search_filters_by_project() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"alpha",
"parse",
"alpha.parse",
"/a.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes alpha");
repo.save_nodes(
&[sample_function(
"f2",
"beta",
"parse",
"beta.parse",
"/b.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes beta");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher
.search("parse", Some("alpha"), 100)
.expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].qualified_name.as_deref(), Some("alpha.parse"));
}
#[test]
fn search_respects_limit() {
let repo = fresh_repo();
let mut nodes = Vec::new();
for i in 0..10 {
nodes.push(sample_function(
&format!("f{i}"),
"demo",
&format!("parse_{i}"),
&format!("demo.parse_{i}"),
"/a.rs",
i + 1,
));
}
repo.save_nodes(&nodes, NodeLabel::Function)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 3).expect("search");
assert_eq!(results.len(), 3);
}
#[test]
fn search_rejects_empty_query() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let err = searcher
.search("", None, 10)
.expect_err("empty query should error");
assert!(err.is_invalid_query());
}
#[test]
fn search_rejects_query_that_tokenizes_to_empty() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let err = searcher
.search("...", None, 10)
.expect_err("punctuation-only query should error");
assert!(err.is_invalid_query());
}
#[test]
fn search_returns_empty_when_no_match() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"demo",
"main",
"demo.main",
"/a.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("nonexistent", None, 10).expect("search");
assert!(results.is_empty());
}
#[test]
fn search_populates_search_result_fields() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"demo",
"parse",
"demo.parse",
"/src/main.rs",
42,
)],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 100).expect("search");
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.name, "parse");
assert_eq!(r.label, "Function");
assert_eq!(r.file_path.as_deref(), Some("/src/main.rs"));
assert_eq!(r.start_line, Some(42));
assert_eq!(r.qualified_name.as_deref(), Some("demo.parse"));
assert!(r.score > 0.0);
}
#[test]
fn relevance_score_exact_match() {
assert_eq!(relevance_score("parse", "parse"), 1.0);
assert_eq!(relevance_score("PARSE", "parse"), 1.0);
}
#[test]
fn relevance_score_prefix_match() {
assert_eq!(relevance_score("parse_file", "parse"), 0.8);
}
#[test]
fn relevance_score_token_match() {
assert_eq!(relevance_score("my_parse", "parse"), 0.7);
assert_eq!(relevance_score("my_parse_helper", "parse"), 0.7);
}
#[test]
fn relevance_score_substring_match() {
assert_eq!(relevance_score("xparse", "parse"), 0.5);
}
#[test]
fn relevance_score_no_match() {
assert_eq!(relevance_score("read_input", "parse"), 0.3);
}
#[test]
fn is_fts_unsupported_error_detects_unsupported_patterns() {
assert!(is_fts_unsupported_error(&QueryError::Query(
"Parser exception: syntax error near CALL".to_string()
)));
assert!(is_fts_unsupported_error(&QueryError::Query(
"feature not supported in this build".to_string()
)));
assert!(is_fts_unsupported_error(&QueryError::Query(
"Catalog exception: function fts_search does not exist".to_string()
)));
assert!(is_fts_unsupported_error(&QueryError::Query(
"table already exists".to_string()
)));
assert!(is_fts_unsupported_error(&QueryError::Query(
"NOT SUPPORTED".to_string()
)));
assert!(is_fts_unsupported_error(&QueryError::Query(
"PARSER EXCEPTION".to_string()
)));
}
#[test]
fn is_fts_unsupported_error_rejects_genuine_errors() {
assert!(!is_fts_unsupported_error(&QueryError::Query(
"connection refused".to_string()
)));
assert!(!is_fts_unsupported_error(&QueryError::Query(
"permission denied".to_string()
)));
assert!(!is_fts_unsupported_error(&QueryError::Query(
"out of memory".to_string()
)));
assert!(!is_fts_unsupported_error(&QueryError::InvalidQuery(
"empty query".to_string()
)));
assert!(!is_fts_unsupported_error(&QueryError::FullText(
"index corrupted".to_string()
)));
}
#[test]
fn search_uses_fts_when_index_exists() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"demo",
"parse_file",
"demo.parse_file",
"/a.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes");
let fts_created = repo
.connection()
.execute("CALL create_fts_index('fts_func_name', 'Function', ['name']);")
.is_ok()
|| repo
.connection()
.execute("CREATE FTS INDEX fts_func_name ON Function(name);")
.is_ok();
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "parse_file");
let _ = fts_created;
}
#[test]
fn search_with_project_filter_when_fts_unavailable() {
let repo = fresh_repo();
repo.save_nodes(
&[
sample_function("f1", "alpha", "parse", "alpha.parse", "/a.rs", 1),
sample_function("f2", "beta", "parse", "beta.parse", "/b.rs", 1),
],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher
.search("parse", Some("alpha"), 100)
.expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].qualified_name.as_deref(), Some("alpha.parse"));
}
#[test]
fn try_fts_search_returns_error_when_fts_unavailable() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let result = searcher.try_fts_search("parse", None, 100);
assert!(result.is_err(), "try_fts_search should error without FTS");
}
#[test]
fn try_fts_search_with_project_returns_error_when_fts_unavailable() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let result = searcher.try_fts_search("parse", Some("demo"), 100);
assert!(result.is_err());
}
#[test]
fn fallback_contains_search_returns_empty_when_no_data() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher
.fallback_contains_search("anything", None, 10)
.expect("fallback");
assert!(results.is_empty());
}
#[test]
fn fallback_contains_search_with_project_filter() {
let repo = fresh_repo();
repo.save_nodes(
&[
sample_function("f1", "alpha", "parse", "alpha.parse", "/a.rs", 1),
sample_function("f2", "beta", "parse", "beta.parse", "/b.rs", 1),
],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher
.fallback_contains_search("parse", Some("beta"), 100)
.expect("fallback");
assert_eq!(results.len(), 1);
assert_eq!(results[0].qualified_name.as_deref(), Some("beta.parse"));
}
#[test]
fn fallback_contains_search_matches_camel_query_to_snake_name() {
let repo = fresh_repo();
repo.save_nodes(
&[
sample_function("f1", "demo", "parse_file", "demo.parse_file", "/a.rs", 1),
sample_function("f2", "demo", "read_input", "demo.read_input", "/b.rs", 1),
],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher
.fallback_contains_search("parseFile", None, 100)
.expect("fallback");
let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
assert!(
names.contains(&"parse_file"),
"parse_file should match parseFile"
);
assert!(
!names.contains(&"read_input"),
"read_input should not match"
);
}
#[test]
fn fallback_contains_search_rejects_query_that_tokenizes_to_empty() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let err = searcher
.fallback_contains_search("___", None, 10)
.expect_err("separator-only query should error");
assert!(err.is_invalid_query());
}
#[test]
fn search_camel_query_matches_snake_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"demo",
"parse_file",
"demo.parse_file",
"/a.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parseFile", None, 100).expect("search");
let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"parse_file"));
}
fn sample_symbol(
label: NodeLabel,
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
) -> Node {
Node::builder(label, name, qn)
.id(id)
.project(project)
.file_path(file)
.start_line(line)
.language(Language::Rust)
.build()
}
#[test]
fn fts_name_indexes_covers_all_symbol_tables() {
assert_eq!(
FTS_NAME_INDEXES.len(),
15,
"expected 15 FTS name indexes, got {}: {FTS_NAME_INDEXES:?}",
FTS_NAME_INDEXES.len()
);
let labels: Vec<NodeLabel> = FTS_NAME_INDEXES.iter().map(|(_, l)| *l).collect();
for expected in [
NodeLabel::Function,
NodeLabel::Class,
NodeLabel::Method,
NodeLabel::Struct,
NodeLabel::Enum,
NodeLabel::Trait,
NodeLabel::Macro,
NodeLabel::Typedef,
NodeLabel::Namespace,
NodeLabel::Module,
NodeLabel::Variable,
NodeLabel::GlobalVar,
NodeLabel::Const,
NodeLabel::Static,
NodeLabel::TypeAlias,
] {
assert!(
labels.contains(&expected),
"FTS_NAME_INDEXES missing {expected:?}: {FTS_NAME_INDEXES:?}"
);
}
}
#[test]
fn search_finds_struct_by_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_symbol(
NodeLabel::Struct,
"s1",
"demo",
"Point",
"demo.Point",
"/a.rs",
1,
)],
NodeLabel::Struct,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("Point", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Point");
assert_eq!(results[0].label, "Struct");
}
#[test]
fn search_finds_enum_by_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_symbol(
NodeLabel::Enum,
"e1",
"demo",
"Color",
"demo.Color",
"/a.rs",
1,
)],
NodeLabel::Enum,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("Color", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Color");
assert_eq!(results[0].label, "Enum");
}
#[test]
fn search_finds_macro_by_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_symbol(
NodeLabel::Macro,
"m1",
"demo",
"FOO",
"demo.FOO",
"/a.rs",
1,
)],
NodeLabel::Macro,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("FOO", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "FOO");
assert_eq!(results[0].label, "Macro");
}
#[test]
fn search_finds_namespace_by_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_symbol(
NodeLabel::Namespace,
"n1",
"demo",
"graphics",
"demo.graphics",
"/a.rs",
1,
)],
NodeLabel::Namespace,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("graphics", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "graphics");
assert_eq!(results[0].label, "Namespace");
}
#[test]
fn search_finds_typedef_by_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_symbol(
NodeLabel::Typedef,
"t1",
"demo",
"Handle",
"demo.Handle",
"/a.rs",
1,
)],
NodeLabel::Typedef,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("Handle", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Handle");
assert_eq!(results[0].label, "Typedef");
}
#[test]
fn search_across_multiple_label_types() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"demo",
"parse",
"demo.parse",
"/a.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes function");
repo.save_nodes(
&[sample_symbol(
NodeLabel::Struct,
"s1",
"demo",
"Parser",
"demo.Parser",
"/b.rs",
1,
)],
NodeLabel::Struct,
)
.expect("save_nodes struct");
repo.save_nodes(
&[sample_symbol(
NodeLabel::Enum,
"e1",
"demo",
"ParseMode",
"demo.ParseMode",
"/c.rs",
1,
)],
NodeLabel::Enum,
)
.expect("save_nodes enum");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 100).expect("search");
let labels: Vec<&str> = results.iter().map(|r| r.label.as_str()).collect();
assert!(
labels.contains(&"Function"),
"expected Function in results: {labels:?}"
);
assert!(
labels.contains(&"Struct"),
"expected Struct in results: {labels:?}"
);
assert!(
labels.contains(&"Enum"),
"expected Enum in results: {labels:?}"
);
}
#[test]
fn fallback_contains_search_continues_on_query_error() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_function(
"f1",
"demo",
"parse",
"demo.parse",
"/a.rs",
1,
)],
NodeLabel::Function,
)
.expect("save_nodes");
repo.connection()
.execute("DROP TABLE Class;")
.expect("drop table");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parse", None, 100).expect("search");
assert!(results.iter().any(|r| r.name == "parse"));
}
#[test]
fn relevance_score_with_reason_exact_match_returns_one() {
let (score, reason) = relevance_score_with_reason("parse", "parse");
assert_eq!(score, 1.0);
assert_eq!(reason, "exact name match");
}
#[test]
fn relevance_score_with_reason_prefix_match_returns_zero_eight() {
let (score, reason) = relevance_score_with_reason("parse_file", "parse");
assert_eq!(score, 0.8);
assert_eq!(reason, "prefix match");
}
#[test]
fn relevance_score_with_reason_token_aligned_match_returns_zero_seven() {
let (score, reason) = relevance_score_with_reason("my_parse_helper", "parse");
assert_eq!(score, 0.7);
assert_eq!(reason, "token-aligned match");
}
#[test]
fn relevance_score_with_reason_substring_match_returns_zero_five() {
let (score, reason) = relevance_score_with_reason("myparsehelper", "parse");
assert_eq!(score, 0.5);
assert_eq!(reason, "substring match");
}
#[test]
fn relevance_score_with_reason_no_match_returns_zero_three() {
let (score, reason) = relevance_score_with_reason("read_input", "parse");
assert_eq!(score, 0.3);
assert_eq!(reason, "no match");
}
#[test]
fn relevance_score_with_reason_camel_case_token_alignment() {
let (score, _) = relevance_score_with_reason("parseFile", "fileparse");
assert_eq!(score, 0.3);
}
#[test]
fn relevance_score_returns_score_only() {
assert_eq!(relevance_score("parse", "parse"), 1.0);
assert_eq!(relevance_score("parse_file", "parse"), 0.8);
assert_eq!(relevance_score("read_input", "parse"), 0.3);
}
#[test]
fn is_fts_unsupported_error_already_exists() {
let err = QueryError::InvalidQuery("index already exists".to_string());
assert!(is_fts_unsupported_error(&err));
}
#[test]
fn is_fts_unsupported_error_not_supported() {
let err = QueryError::InvalidQuery("FTS not supported".to_string());
assert!(is_fts_unsupported_error(&err));
}
#[test]
fn is_fts_unsupported_error_parser_exception() {
let err = QueryError::InvalidQuery("parser exception at line 1".to_string());
assert!(is_fts_unsupported_error(&err));
}
#[test]
fn is_fts_unsupported_error_does_not_exist() {
let err = QueryError::InvalidQuery("table does not exist".to_string());
assert!(is_fts_unsupported_error(&err));
}
#[test]
fn is_fts_unsupported_error_other_error_returns_false() {
let err = QueryError::InvalidQuery("connection refused".to_string());
assert!(!is_fts_unsupported_error(&err));
}
#[test]
fn sort_and_truncate_preserves_order_when_within_limit() {
let mut results = vec![
SearchResult {
name: "b".to_string(),
label: "Function".to_string(),
file_path: None,
start_line: None,
qualified_name: None,
score: 1.0,
match_reason: "exact".to_string(),
degree: 0,
},
SearchResult {
name: "a".to_string(),
label: "Function".to_string(),
file_path: None,
start_line: None,
qualified_name: None,
score: 1.0,
match_reason: "exact".to_string(),
degree: 0,
},
];
sort_and_truncate(&mut results, 10);
assert_eq!(results.len(), 2);
assert_eq!(results[0].name, "a");
assert_eq!(results[1].name, "b");
}
#[test]
fn sort_and_truncate_truncates_when_exceeding_limit() {
let mut results: Vec<SearchResult> = (0..5)
.map(|i| SearchResult {
name: format!("func{i}"),
label: "Function".to_string(),
file_path: None,
start_line: None,
qualified_name: None,
score: 1.0 - i as f64 * 0.1,
match_reason: "match".to_string(),
degree: 0,
})
.collect();
sort_and_truncate(&mut results, 3);
assert_eq!(results.len(), 3);
assert_eq!(results[0].name, "func0");
assert_eq!(results[1].name, "func1");
}
#[test]
fn sort_and_truncate_zero_limit_empties_results() {
let mut results = vec![SearchResult {
name: "x".to_string(),
label: "Function".to_string(),
file_path: None,
start_line: None,
qualified_name: None,
score: 1.0,
match_reason: "exact".to_string(),
degree: 0,
}];
sort_and_truncate(&mut results, 0);
assert!(results.is_empty());
}
#[test]
fn search_rejects_whitespace_only_query() {
let repo = fresh_repo();
let searcher = FullTextSearcher::new(repo.connection());
let err = searcher
.search(" \t\n", None, 10)
.expect_err("whitespace-only query should error");
assert!(err.is_invalid_query());
}
#[test]
fn relevance_score_with_reason_name_with_empty_tokens() {
let (score, reason) = relevance_score_with_reason("12345", "parse");
assert_eq!(score, 0.3);
assert_eq!(reason, "no match");
}
#[test]
fn relevance_score_with_reason_query_with_empty_tokens() {
let (score, reason) = relevance_score_with_reason("parse", "12345");
assert_eq!(score, 0.3);
assert_eq!(reason, "no match");
}
#[test]
fn search_finds_module_by_name() {
let repo = fresh_repo();
repo.save_nodes(
&[sample_symbol(
NodeLabel::Module,
"mod1",
"demo",
"parser",
"demo.parser",
"/a.rs",
1,
)],
NodeLabel::Module,
)
.expect("save_nodes");
let searcher = FullTextSearcher::new(repo.connection());
let results = searcher.search("parser", None, 100).expect("search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "parser");
assert_eq!(results[0].label, "Module");
assert!(results[0].start_line.is_none());
}
}