use std::path::Path;
use std::time::Instant;
use tracing::debug;
use super::cypher::CypherExecutor;
use super::error::Result;
use super::fulltext::FullTextSearcher;
use super::structured::StructuredSearcher;
use super::{QueryResult, SearchResult};
use crate::model::NodeLabel;
use crate::storage::StorageConnection;
pub struct QueryFacade {
conn: StorageConnection,
}
impl QueryFacade {
pub fn new(db_path: &Path) -> Result<Self> {
let conn = StorageConnection::open(db_path)?;
conn.init_schema()?;
Ok(Self { conn })
}
#[must_use]
pub fn with_connection(conn: StorageConnection) -> Self {
Self { conn }
}
pub fn connection(&self) -> &StorageConnection {
&self.conn
}
pub fn cypher(&self, query: &str) -> Result<QueryResult> {
let start = Instant::now();
let result = CypherExecutor::new(&self.conn).execute(query);
let duration_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64;
debug!(
event = "query_executed",
query_type = "cypher",
duration_ms = duration_ms,
results = result.as_ref().map(|r| r.rows.len()).unwrap_or(0),
"query executed"
);
result
}
pub fn search(
&self,
text: &str,
project: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
let start = Instant::now();
let result = StructuredSearcher::new(&self.conn).search(text, project, limit);
let duration_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64;
debug!(
event = "query_executed",
query_type = "search_name",
duration_ms = duration_ms,
results = result.as_ref().map(|r| r.len()).unwrap_or(0),
"query executed"
);
result
}
pub fn search_by_type(
&self,
label: NodeLabel,
project: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
let start = Instant::now();
let result = StructuredSearcher::new(&self.conn).search_by_type(label, project, limit);
let duration_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64;
debug!(
event = "query_executed",
query_type = "search_type",
duration_ms = duration_ms,
results = result.as_ref().map(|r| r.len()).unwrap_or(0),
"query executed"
);
result
}
pub fn search_by_file(
&self,
file_path: &str,
project: Option<&str>,
) -> Result<Vec<SearchResult>> {
let start = Instant::now();
let result = StructuredSearcher::new(&self.conn).search_by_file(file_path, project);
let duration_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64;
debug!(
event = "query_executed",
query_type = "search_file",
duration_ms = duration_ms,
results = result.as_ref().map(|r| r.len()).unwrap_or(0),
"query executed"
);
result
}
pub fn fulltext_search(
&self,
text: &str,
project: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
let start = Instant::now();
let result = FullTextSearcher::new(&self.conn).search(text, project, limit);
let duration_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64;
debug!(
event = "query_executed",
query_type = "fulltext",
duration_ms = duration_ms,
results = result.as_ref().map(|r| r.len()).unwrap_or(0),
"query executed"
);
result
}
}
impl std::fmt::Debug for QueryFacade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QueryFacade")
.field("conn", &"Opaque StorageConnection")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeLabel;
use crate::query::error::QueryError;
use crate::test_log_capture::capture_tracing_debug;
fn fresh_facade() -> QueryFacade {
let conn = StorageConnection::in_memory().expect("in_memory connection");
conn.init_schema().expect("init_schema");
QueryFacade::with_connection(conn)
}
fn seed_fixture(facade: &QueryFacade) {
facade
.cypher("CREATE (:Project {id: 'demo', name: 'demo', rootPath: '/', language: 'rust', fileCount: 2, indexedAt: 0});")
.expect("create project");
let funcs = [
(
"f1",
"demo",
"parse_file",
"demo.parse_file",
"/src/main.rs",
1,
),
(
"f2",
"demo",
"parse_line",
"demo.parse_line",
"/src/main.rs",
10,
),
(
"f3",
"demo",
"read_input",
"demo.read_input",
"/src/lib.rs",
1,
),
];
for (id, project, name, qn, file, line) in funcs {
let cypher = format!(
"CREATE (:Function {{id: '{id}', project: '{project}', name: '{name}', \
qualifiedName: '{qn}', filePath: '{file}', startLine: {line}, endLine: {end}, \
signature: '', returnType: '', isExported: false, docstring: '', \
content: '', parentQn: ''}});",
end = line + 10,
);
facade.cypher(&cypher).expect("create function");
}
let cypher = "CREATE (:Class {id: 'c1', project: 'demo', name: 'Parser', \
qualifiedName: 'demo.Parser', filePath: '/src/main.rs', \
startLine: 20, endLine: 40, isExported: true, docstring: '', \
content: '', parentQn: ''});";
facade.cypher(cypher).expect("create class");
}
#[test]
fn facade_cypher_executes_query() {
let facade = fresh_facade();
seed_fixture(&facade);
let result = facade
.cypher("MATCH (f:Function) RETURN f.name AS name ORDER BY f.name;")
.expect("cypher");
assert_eq!(result.columns, vec!["name"]);
assert_eq!(result.rows.len(), 3);
assert_eq!(result.rows[0][0], serde_json::json!("parse_file"));
}
#[test]
fn facade_cypher_ac_query_001() {
let facade = fresh_facade();
seed_fixture(&facade);
let result = facade
.cypher("MATCH (f:Function) RETURN f.name LIMIT 10;")
.expect("cypher");
assert_eq!(result.rows.len(), 3);
}
#[test]
fn facade_search_finds_by_name() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade.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(&"Parser")); }
#[test]
fn facade_search_ac_search_001() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade.search("parse", None, 100).expect("search");
assert!(results.iter().any(|r| r.name.contains("parse")));
}
#[test]
fn facade_search_by_type_returns_functions() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade
.search_by_type(NodeLabel::Function, None, 100)
.expect("search_by_type");
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.label == "Function"));
}
#[test]
fn facade_search_by_type_returns_classes() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade
.search_by_type(NodeLabel::Class, None, 100)
.expect("search_by_type");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Parser");
}
#[test]
fn facade_search_by_file_returns_symbols_in_file() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade
.search_by_file("/src/main.rs", None)
.expect("search_by_file");
assert_eq!(results.len(), 3);
assert!(results
.iter()
.all(|r| r.file_path.as_deref() == Some("/src/main.rs")));
}
#[test]
fn facade_fulltext_search_returns_results() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade
.fulltext_search("parse", None, 100)
.expect("fulltext_search");
assert!(!results.is_empty());
assert!(results
.iter()
.all(|r| r.name.to_ascii_lowercase().contains("parse")));
}
#[test]
fn facade_fulltext_search_respects_limit() {
let facade = fresh_facade();
seed_fixture(&facade);
let results = facade
.fulltext_search("parse", None, 1)
.expect("fulltext_search");
assert_eq!(results.len(), 1);
}
#[test]
fn facade_search_filters_by_project() {
let facade = fresh_facade();
seed_fixture(&facade);
facade
.cypher("CREATE (:Function {id: 'f9', project: 'other', name: 'parse_other', qualifiedName: 'other.parse_other', filePath: '/x.rs', startLine: 1, endLine: 2, signature: '', returnType: '', isExported: false, docstring: '', content: '', parentQn: ''});")
.expect("create other function");
let results = facade.search("parse", Some("demo"), 100).expect("search");
assert!(results
.iter()
.all(|r| r.qualified_name.as_ref().unwrap().starts_with("demo.")));
}
#[test]
fn facade_cypher_invalid_returns_error() {
let facade = fresh_facade();
let err = facade
.cypher("MATCH (a RETURN a;")
.expect_err("invalid cypher");
assert!(matches!(err, QueryError::Storage(_)));
}
#[test]
fn facade_cypher_empty_returns_invalid_query() {
let facade = fresh_facade();
let err = facade.cypher("").expect_err("empty query");
assert!(err.is_invalid_query());
}
#[test]
fn facade_new_opens_database_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("testdb");
let facade = QueryFacade::new(&path).expect("QueryFacade::new");
std::mem::forget(dir);
let result = facade
.cypher("MATCH (p:Project) RETURN count(p) AS cnt;")
.expect("cypher");
assert_eq!(result.rows[0][0], serde_json::json!(0));
}
#[test]
fn facade_connection_returns_reference() {
let facade = fresh_facade();
let _ = facade.connection();
}
#[test]
fn facade_debug_does_not_panic() {
let facade = fresh_facade();
let s = format!("{facade:?}");
assert!(s.contains("QueryFacade"));
}
#[test]
fn log_003_cypher_emits_query_executed() {
let facade = fresh_facade();
seed_fixture(&facade);
let captured = capture_tracing_debug(|| {
let _ = facade
.cypher("MATCH (f:Function) RETURN f.name AS name LIMIT 10;")
.expect("cypher");
});
assert!(
captured.contains("query_executed"),
"LOG-003: query_executed event should be emitted, got: {captured:?}"
);
assert!(
captured.contains("query_type") && captured.contains("cypher"),
"query_executed should carry query_type=cypher"
);
assert!(
captured.contains("duration_ms"),
"query_executed should carry duration_ms field"
);
}
#[test]
fn log_003_search_emits_query_executed() {
let facade = fresh_facade();
seed_fixture(&facade);
let captured = capture_tracing_debug(|| {
let _ = facade.search("parse", None, 100).expect("search");
});
assert!(
captured.contains("query_executed"),
"LOG-003: search should emit query_executed, got: {captured:?}"
);
assert!(
captured.contains("search_name"),
"search should carry query_type=search_name"
);
}
#[test]
fn log_003_fulltext_emits_query_executed() {
let facade = fresh_facade();
seed_fixture(&facade);
let captured = capture_tracing_debug(|| {
let _ = facade
.fulltext_search("parse", None, 100)
.expect("fulltext");
});
assert!(
captured.contains("query_executed"),
"LOG-003: fulltext_search should emit query_executed, got: {captured:?}"
);
assert!(
captured.contains("fulltext"),
"fulltext_search should carry query_type=fulltext"
);
}
}