use anyhow::{Context, Result};
use std::collections::HashSet;
use std::path::Path;
use crate::gitnexus_client;
const CODENEXUS_MARKER: &str = "// === CODENEXUS ===";
const GITNEXUS_MARKER: &str = "// === GITNEXUS ===";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Codenexus,
Gitnexus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryDiff {
Match { count: usize },
CriticalDiff {
missing_in_codenexus: Vec<(String, String)>,
missing_in_gitnexus: Vec<(String, String)>,
},
}
pub fn extract_query_for_side(content: &str, side: Side) -> Result<String> {
let marker = match side {
Side::Codenexus => CODENEXUS_MARKER,
Side::Gitnexus => GITNEXUS_MARKER,
};
let other = match side {
Side::Codenexus => GITNEXUS_MARKER,
Side::Gitnexus => CODENEXUS_MARKER,
};
let start_idx = content
.find(marker)
.ok_or_else(|| anyhow::anyhow!("marker `{marker}` not found in .cql file"))?
+ marker.len();
let end_idx = content[start_idx..]
.find(other)
.map(|i| start_idx + i)
.unwrap_or(content.len());
let block: String = content[start_idx..end_idx]
.lines()
.map(|line| {
if let Some(idx) = line.find("//") {
&line[..idx]
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if block.is_empty() {
anyhow::bail!("empty Cypher block for side `{side:?}`");
}
Ok(block)
}
pub fn execute_codenexus_query(
db_path: &Path,
cql: &str,
project_id: Option<&str>,
) -> Result<Vec<(String, String)>> {
let repo = codenexus::storage::repository::Repository::open(db_path)
.with_context(|| format!("failed to open CodeNexus DB at {}", db_path.display()))?;
let final_cql = match project_id {
Some(pid) => {
let escaped = codenexus::storage::schema::escape_cypher_string(pid);
cql.replace("__PID__", &escaped)
}
None => cql.to_string(),
};
let rows = repo.connection().query(&final_cql)?;
let out = rows
.into_iter()
.map(|row| {
let symbol = row
.first()
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let file = row
.get(1)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(symbol, file)
})
.collect();
Ok(out)
}
pub fn execute_gitnexus_query(
repo: &str,
cql: &str,
gitnexus_binary: Option<&Path>,
) -> Result<Vec<(String, String)>> {
let response = gitnexus_client::run_cypher(repo, cql, gitnexus_binary)?;
let rows = match response {
gitnexus_client::CypherResponse::Rows { rows, .. } => rows,
gitnexus_client::CypherResponse::Empty => Vec::new(),
gitnexus_client::CypherResponse::Error { message, .. } => {
eprintln!("[warn] gitnexus query failed for `{repo}`: {message}");
Vec::new()
}
};
let out = rows
.into_iter()
.map(|row| {
let symbol = row.first().cloned().unwrap_or_default();
let file = row.get(1).cloned().unwrap_or_default();
(symbol, file)
})
.collect();
Ok(out)
}
pub fn compare_query_results(
codenexus_results: &[(String, String)],
gitnexus_results: &[(String, String)],
) -> QueryDiff {
let cn: HashSet<&(String, String)> = codenexus_results.iter().collect();
let gn: HashSet<&(String, String)> = gitnexus_results.iter().collect();
if cn == gn {
return QueryDiff::Match { count: cn.len() };
}
let missing_in_codenexus: Vec<(String, String)> = gn
.difference(&cn)
.map(|(s, f)| (s.clone(), f.clone()))
.collect();
let missing_in_gitnexus: Vec<(String, String)> = cn
.difference(&gn)
.map(|(s, f)| (s.clone(), f.clone()))
.collect();
QueryDiff::CriticalDiff {
missing_in_codenexus,
missing_in_gitnexus,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CQL: &str = r#"// Query: callers_of_function
// Returns: (symbol_name, file_path) tuples
// === CODENEXUS ===
MATCH (caller:Function)-[:Calls]->(callee:Function)
RETURN caller.name AS symbol_name, caller.filePath AS file_path
LIMIT 200
// === GITNEXUS ===
MATCH (caller:Function)-[r:CodeRelation]->(callee:Function)
WHERE r.type = 'CALLS'
RETURN caller.name AS symbol_name, caller.filePath AS file_path
LIMIT 200
"#;
#[test]
fn extract_codenexus_block_strips_markers_and_comments() {
let q = extract_query_for_side(SAMPLE_CQL, Side::Codenexus).unwrap();
assert!(q.contains("MATCH (caller:Function)"));
assert!(q.contains("LIMIT 200"));
assert!(!q.contains("==="));
assert!(!q.contains("Query: callers"));
}
#[test]
fn extract_gitnexus_block_includes_where_clause() {
let q = extract_query_for_side(SAMPLE_CQL, Side::Gitnexus).unwrap();
assert!(q.contains("WHERE r.type = 'CALLS'"));
assert!(!q.contains("==="));
}
#[test]
fn extract_returns_error_when_marker_missing() {
let content = "// no markers here\nMATCH (n) RETURN n";
let err = extract_query_for_side(content, Side::Codenexus).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn compare_match_when_sets_equal_regardless_of_order() {
let cn = vec![
("foo".to_string(), "a.rs".to_string()),
("bar".to_string(), "b.rs".to_string()),
("baz".to_string(), "c.rs".to_string()),
];
let gn = vec![
("baz".to_string(), "c.rs".to_string()),
("foo".to_string(), "a.rs".to_string()),
("bar".to_string(), "b.rs".to_string()),
];
let diff = compare_query_results(&cn, &gn);
assert_eq!(diff, QueryDiff::Match { count: 3 });
}
#[test]
fn compare_match_when_duplicates_present() {
let cn = vec![
("foo".to_string(), "a.rs".to_string()),
("foo".to_string(), "a.rs".to_string()),
];
let gn = vec![("foo".to_string(), "a.rs".to_string())];
let diff = compare_query_results(&cn, &gn);
assert_eq!(diff, QueryDiff::Match { count: 1 });
}
#[test]
fn compare_critical_diff_when_gitnexus_has_extra_symbol() {
let cn = vec![
("foo".to_string(), "a.rs".to_string()),
("bar".to_string(), "b.rs".to_string()),
];
let gn = vec![
("foo".to_string(), "a.rs".to_string()),
("bar".to_string(), "b.rs".to_string()),
("baz".to_string(), "c.rs".to_string()), ];
let diff = compare_query_results(&cn, &gn);
match diff {
QueryDiff::CriticalDiff {
missing_in_codenexus,
missing_in_gitnexus,
} => {
assert_eq!(
missing_in_codenexus,
vec![("baz".to_string(), "c.rs".to_string())]
);
assert!(missing_in_gitnexus.is_empty());
}
other => panic!("expected CriticalDiff, got {other:?}"),
}
}
#[test]
fn compare_critical_diff_when_codenexus_has_extra_symbol() {
let cn = vec![
("foo".to_string(), "a.rs".to_string()),
("bar".to_string(), "b.rs".to_string()),
("qux".to_string(), "d.rs".to_string()), ];
let gn = vec![
("foo".to_string(), "a.rs".to_string()),
("bar".to_string(), "b.rs".to_string()),
];
let diff = compare_query_results(&cn, &gn);
match diff {
QueryDiff::CriticalDiff {
missing_in_codenexus,
missing_in_gitnexus,
} => {
assert!(missing_in_codenexus.is_empty());
assert_eq!(
missing_in_gitnexus,
vec![("qux".to_string(), "d.rs".to_string())]
);
}
other => panic!("expected CriticalDiff, got {other:?}"),
}
}
#[test]
fn compare_both_sides_missing_is_critical_diff() {
let cn = vec![("foo".to_string(), "a.rs".to_string())];
let gn = vec![("bar".to_string(), "b.rs".to_string())];
let diff = compare_query_results(&cn, &gn);
match diff {
QueryDiff::CriticalDiff {
missing_in_codenexus,
missing_in_gitnexus,
} => {
assert_eq!(
missing_in_codenexus,
vec![("bar".to_string(), "b.rs".to_string())]
);
assert_eq!(
missing_in_gitnexus,
vec![("foo".to_string(), "a.rs".to_string())]
);
}
other => panic!("expected CriticalDiff, got {other:?}"),
}
}
#[test]
fn compare_empty_sets_match() {
let diff = compare_query_results(&[], &[]);
assert_eq!(diff, QueryDiff::Match { count: 0 });
}
#[test]
fn extract_strips_inline_comments_within_block() {
let content = "\
// === CODENEXUS ===
MATCH (n) RETURN n // limit for safety
// explanatory note
LIMIT 10
// === GITNEXUS ===
MATCH (m) RETURN m
LIMIT 5
";
let q = extract_query_for_side(content, Side::Codenexus).unwrap();
assert!(q.contains("MATCH (n) RETURN n"));
assert!(q.contains("LIMIT 10"));
assert!(!q.contains("limit for safety"));
assert!(!q.contains("explanatory note"));
assert!(!q.contains("==="));
}
#[test]
fn extract_returns_error_when_block_is_empty() {
let content = "\
// === CODENEXUS ===
// === GITNEXUS ===
MATCH (m) RETURN m
";
let err = extract_query_for_side(content, Side::Codenexus).unwrap_err();
assert!(
err.to_string().contains("empty Cypher block"),
"expected empty-block error, got: {err}"
);
}
#[test]
fn extract_gitnexus_block_at_eof_without_other_marker() {
let content = "\
// === CODENEXUS ===
MATCH (a) RETURN a
// === GITNEXUS ===
MATCH (b) RETURN b
LIMIT 50
";
let q = extract_query_for_side(content, Side::Gitnexus).unwrap();
assert!(q.contains("MATCH (b) RETURN b"));
assert!(q.contains("LIMIT 50"));
assert!(!q.contains("==="));
}
#[test]
fn execute_codenexus_query_returns_rows_from_real_db() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("cov.lbug");
let repo =
codenexus::storage::repository::Repository::open(&db_path).expect("Repository::open");
let project =
codenexus::model::Node::builder(codenexus::model::NodeLabel::Project, "demo", "demo")
.id("proj-1")
.build();
repo.save_nodes(&[project], codenexus::model::NodeLabel::Project)
.expect("save_nodes");
drop(repo);
let cql = "MATCH (p:Project) RETURN p.name AS name, p.id AS id";
let rows = execute_codenexus_query(&db_path, cql, None).expect("execute_codenexus_query");
assert_eq!(rows.len(), 1, "expected exactly one Project row");
assert_eq!(rows[0].0, "demo");
assert_eq!(rows[0].1, "proj-1");
}
#[test]
fn execute_codenexus_query_propagates_open_error_for_missing_db() {
let bogus = std::path::Path::new("/nonexistent/dir/missing.lbug");
let err = execute_codenexus_query(bogus, "MATCH (n) RETURN n", None).unwrap_err();
assert!(
err.to_string().contains("failed to open CodeNexus DB"),
"expected open-DB error, got: {err}"
);
}
#[test]
fn execute_codenexus_query_returns_empty_for_empty_table() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("empty.lbug");
let _ =
codenexus::storage::repository::Repository::open(&db_path).expect("Repository::open");
let cql = "MATCH (p:Project) RETURN p.name AS name, p.id AS id";
let rows = execute_codenexus_query(&db_path, cql, None).expect("execute_codenexus_query");
assert!(
rows.is_empty(),
"expected zero rows for empty Project table"
);
}
#[test]
fn execute_codenexus_query_falls_back_to_empty_when_column_value_is_null() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("null.lbug");
let repo =
codenexus::storage::repository::Repository::open(&db_path).expect("Repository::open");
let project = codenexus::model::Node::builder(
codenexus::model::NodeLabel::Project,
"nullproj",
"nullproj",
)
.id("np-1")
.build();
repo.save_nodes(&[project], codenexus::model::NodeLabel::Project)
.expect("save_nodes");
drop(repo);
let cql = "MATCH (p:Project) RETURN p.name AS name, p.rootPath AS root";
let rows = execute_codenexus_query(&db_path, cql, None).expect("execute_codenexus_query");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, "nullproj");
assert_eq!(rows[0].1, "");
}
#[test]
fn execute_codenexus_query_replaces_pid_placeholder() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("pid.lbug");
let repo =
codenexus::storage::repository::Repository::open(&db_path).expect("Repository::open");
let project =
codenexus::model::Node::builder(codenexus::model::NodeLabel::Project, "alpha", "alpha")
.id("proj-alpha-id")
.build();
repo.save_nodes(&[project], codenexus::model::NodeLabel::Project)
.expect("save_nodes");
drop(repo);
let cql = "MATCH (p:Project) WHERE p.id = '__PID__' RETURN p.name AS name, p.id AS id";
let rows = execute_codenexus_query(&db_path, cql, Some("proj-alpha-id")).expect("execute");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, "alpha");
assert_eq!(rows[0].1, "proj-alpha-id");
let rows = execute_codenexus_query(&db_path, cql, Some("proj-beta-id")).expect("execute");
assert!(rows.is_empty(), "non-matching pid should return zero rows");
}
}