use serde::{Deserialize, Serialize};
use serde_json::Value;
#[cfg(any(feature = "cli", feature = "mcp", test))]
use crate::kit::{AsyncKit, AsyncReady, QueryModule};
use crate::query::{validate_cypher_subset, QueryResult};
#[cfg(any(feature = "cli", feature = "mcp", test))]
use crate::service::error::CodeNexusError;
#[cfg(any(feature = "cli", feature = "mcp"))]
use crate::service::error::{kit_not_initialized, to_api_error};
#[cfg(any(feature = "cli", feature = "mcp"))]
use crate::service::runtime::kit;
#[cfg(any(feature = "cli", feature = "mcp"))]
use sdforge::forge;
#[cfg(any(feature = "cli", feature = "mcp"))]
use sdforge::prelude::ApiError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QueryOutput {
pub columns: Vec<String>,
pub rows: Vec<Vec<Value>>,
pub duration_ms: u64,
}
#[cfg(any(feature = "cli", feature = "mcp", test))]
fn query_output(r: QueryResult) -> QueryOutput {
QueryOutput {
columns: r.columns,
rows: r.rows,
duration_ms: r.duration_ms,
}
}
#[cfg(any(feature = "cli", feature = "mcp", test))]
pub fn run_query(kit: &AsyncKit<AsyncReady>, cypher: &str) -> Result<QueryOutput, CodeNexusError> {
let q = kit.require::<QueryModule>()?;
validate_cypher_subset(cypher)?;
let result = q.cypher(cypher)?;
Ok(query_output(result))
}
#[cfg(feature = "cli")]
#[forge(
name = "query",
version = "0.3.3",
description = "Execute a Cypher query against the CodeNexus knowledge graph.",
cli = true
)]
async fn query(cypher: String) -> Result<(), ApiError> {
let kit = kit().ok_or_else(kit_not_initialized)?;
let result = run_query(&kit, &cypher).map_err(|e| to_api_error(e, "query_error"))?;
let json = serde_json::to_string(&result)
.map_err(|e| to_api_error(CodeNexusError::from(e), "query_error"))?;
println!("{json}");
Ok(())
}
#[cfg(feature = "mcp")]
#[forge(
name = "query",
version = "0.3.3",
tool_name = "query",
description = "Execute a Cypher query against the CodeNexus knowledge graph."
)]
async fn query_mcp(cypher: String) -> Result<QueryOutput, ApiError> {
let kit = kit().ok_or_else(kit_not_initialized)?;
run_query(&kit, &cypher).map_err(|e| to_api_error(e, "query_error"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kit::{build_kit, KitBootstrapConfig};
use crate::query::QueryError;
use std::path::PathBuf;
use tempfile::TempDir;
fn fresh_db_path() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("svc_query_testdb");
(dir, path)
}
fn build_kit_for_db(db: &std::path::Path) -> AsyncKit<AsyncReady> {
let config = KitBootstrapConfig::new(db.to_path_buf());
tokio::runtime::Runtime::new()
.unwrap()
.block_on(build_kit(&config))
.expect("build_kit")
}
#[test]
fn run_query_returns_error_for_empty_cypher() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(&db);
let err = run_query(&kit, "").expect_err("empty query should error");
match err {
CodeNexusError::Query(QueryError::InvalidQuery(msg)) => {
assert!(msg.contains("empty"), "error should mention empty: {msg}");
}
other => panic!("expected QueryError::InvalidQuery, got {other:?}"),
}
}
#[test]
fn run_query_returns_error_for_whitespace_only() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(&db);
let err = run_query(&kit, " \t\n ").expect_err("whitespace query should error");
assert!(matches!(
err,
CodeNexusError::Query(QueryError::InvalidQuery(_))
));
}
#[test]
fn run_query_succeeds_for_valid_match_query_on_empty_db() {
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(&db);
let result = run_query(&kit, "MATCH (n) RETURN n LIMIT 1");
match result {
Ok(output) => {
assert!(output.rows.is_empty(), "empty DB should have no rows");
}
Err(CodeNexusError::Query(_)) => {
}
Err(other) => panic!("unexpected error for valid query: {other:?}"),
}
}
#[test]
fn query_output_maps_query_result() {
let result = QueryResult {
columns: vec!["n".into(), "id".into()],
rows: vec![vec![Value::Null, Value::from(42)]],
duration_ms: 1500,
};
let output = query_output(result);
assert_eq!(output.columns, vec!["n".to_string(), "id".to_string()]);
assert_eq!(output.rows.len(), 1);
assert_eq!(output.rows[0][1], Value::from(42));
assert_eq!(output.duration_ms, 1500);
}
#[test]
fn query_output_maps_empty_result() {
let result = QueryResult {
columns: vec![],
rows: vec![],
duration_ms: 0,
};
let output = query_output(result);
assert!(output.columns.is_empty());
assert!(output.rows.is_empty());
assert_eq!(output.duration_ms, 0);
}
#[test]
fn query_output_serializes_to_json() {
let output = QueryOutput {
columns: vec!["n".into()],
rows: vec![vec![Value::from(1)]],
duration_ms: 100,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"columns\":[\"n\"]"));
assert!(json.contains("\"duration_ms\":100"));
assert!(json.contains("\"rows\":[[1]]"));
}
#[test]
fn query_output_round_trips_through_json() {
let output = QueryOutput {
columns: vec!["id".into(), "name".into()],
rows: vec![
vec![Value::from(1), Value::from("foo")],
vec![Value::from(2), Value::from("bar")],
],
duration_ms: 42,
};
let json = serde_json::to_string(&output).unwrap();
let parsed: QueryOutput = serde_json::from_str(&json).unwrap();
assert_eq!(output, parsed);
}
#[serial_test::serial(kit_init)]
#[cfg(feature = "cli")]
#[test]
fn query_wrapper_succeeds_via_init_kit() {
use crate::service::runtime::{init_kit, reset_kit_for_testing};
reset_kit_for_testing();
let (_dir, db) = fresh_db_path();
let kit = build_kit_for_db(&db);
init_kit(kit).expect("init_kit");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(query(
"MATCH (n:Function) RETURN n.name LIMIT 10".to_string(),
));
assert!(result.is_ok(), "wrapper should succeed: {:?}", result.err());
reset_kit_for_testing();
}
#[serial_test::serial(kit_init)]
#[cfg(feature = "cli")]
#[test]
fn query_wrapper_fails_when_kit_not_initialized() {
use crate::service::runtime::reset_kit_for_testing;
reset_kit_for_testing();
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(query("MATCH (n) RETURN n LIMIT 1".to_string()));
assert!(result.is_err(), "wrapper should fail without kit");
reset_kit_for_testing();
}
}