use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitnexusStats {
pub name: String,
pub node_counts_by_label: BTreeMap<String, u64>,
pub edge_counts_by_type: BTreeMap<String, u64>,
pub file_count: u64,
}
#[derive(Debug, Clone)]
pub enum CypherResponse {
Rows {
#[allow(dead_code)]
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
Empty,
Error { message: String, corruption: bool },
}
#[derive(Debug, Deserialize)]
struct CypherJson {
#[serde(default)]
markdown: Option<String>,
#[serde(default)]
#[allow(dead_code)]
row_count: Option<u64>,
#[serde(default)]
error: Option<String>,
}
const CORRUPTION_PATTERNS: &[&str] = &[
"mmap for size",
"unreachable_code",
"wal_record",
"database disk image is malformed",
"file is encrypted or is not a database",
];
pub fn run_cypher(
repo: &str,
query: &str,
gitnexus_binary: Option<&Path>,
) -> Result<CypherResponse> {
let binary = gitnexus_binary.unwrap_or_else(|| Path::new("gitnexus"));
let output = Command::new(binary)
.args(["cypher", "--repo", repo, query])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.context("failed to spawn `gitnexus cypher`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found. Available:") {
bail!(
"gitnexus has no index for `{repo}`; run `gitnexus analyze` in the repo first.\n--- stderr ---\n{stderr}"
);
}
bail!(
"gitnexus cypher failed (exit {:?})\n--- stdout ---\n{}\n--- stderr ---\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
stderr,
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim() == "[]" {
return Ok(CypherResponse::Empty);
}
let parsed: CypherJson = serde_json::from_str(&stdout)
.with_context(|| format!("failed to parse gitnexus JSON: {stdout}"))?;
if let Some(msg) = parsed.error {
let corruption = CORRUPTION_PATTERNS
.iter()
.any(|p| msg.to_lowercase().contains(p));
return Ok(CypherResponse::Error {
message: msg,
corruption,
});
}
let markdown = parsed.markdown.unwrap_or_default();
let (headers, rows) = parse_markdown_table(&markdown);
if headers.is_empty() {
return Ok(CypherResponse::Empty);
}
Ok(CypherResponse::Rows { headers, rows })
}
fn parse_markdown_table(markdown: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = markdown.lines().filter(|l| !l.trim().is_empty());
let Some(header_line) = lines.next() else {
return (Vec::new(), Vec::new());
};
let headers = split_row(header_line);
if headers.is_empty() {
return (Vec::new(), Vec::new());
}
let _ = lines.next();
let mut rows = Vec::new();
for line in lines {
if line
.trim()
.trim_matches(|c: char| c == '|' || c == '-' || c == ':' || c.is_whitespace())
.is_empty()
{
continue;
}
rows.push(split_row(line));
}
(headers, rows)
}
fn split_row(line: &str) -> Vec<String> {
line.trim()
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|c| c.trim().to_string())
.collect()
}
pub fn fetch_reference(repo_name: &str, gitnexus_binary: Option<&Path>) -> Result<GitnexusStats> {
let mut node_counts = BTreeMap::new();
match run_cypher(
repo_name,
"MATCH (n) RETURN label(n) AS lbl, count(*) AS c ORDER BY lbl",
gitnexus_binary,
) {
Ok(CypherResponse::Rows { rows, .. }) => {
for row in &rows {
if row.len() < 2 {
continue;
}
let label = row[0].clone();
let count: u64 = row[1].parse().unwrap_or(0);
node_counts.insert(label, count);
}
}
Ok(CypherResponse::Error {
message,
corruption: true,
}) => {
bail!("gitnexus reference unavailable for `{repo_name}`: {message}");
}
Ok(CypherResponse::Error { message, .. }) => {
bail!("gitnexus node-count query failed for `{repo_name}`: {message}");
}
Ok(CypherResponse::Empty) => {
eprintln!("[warn] gitnexus reports 0 nodes for `{repo_name}`");
}
Err(e) => return Err(e),
}
let mut edge_counts = BTreeMap::new();
match run_cypher(
repo_name,
"MATCH ()-[r:CodeRelation]->() RETURN r.type AS t, count(*) AS c ORDER BY t",
gitnexus_binary,
) {
Ok(CypherResponse::Rows { rows, .. }) => {
for row in &rows {
if row.len() < 2 {
continue;
}
let t = row[0].clone();
let c: u64 = row[1].parse().unwrap_or(0);
edge_counts.insert(t, c);
}
}
Ok(CypherResponse::Error {
message,
corruption: true,
}) => {
bail!("gitnexus reference unavailable for `{repo_name}`: {message}");
}
Ok(CypherResponse::Error { message, .. }) => {
eprintln!("[warn] gitnexus edge-count query failed for `{repo_name}`: {message}");
}
Ok(CypherResponse::Empty) => {
eprintln!("[warn] gitnexus reports 0 edges for `{repo_name}`");
}
Err(e) => return Err(e),
}
let mut file_count = 0u64;
match run_cypher(
repo_name,
"MATCH (f:File) RETURN count(*) AS c",
gitnexus_binary,
) {
Ok(CypherResponse::Rows { rows, .. }) => {
if let Some(row) = rows.first() {
if let Some(cell) = row.first() {
file_count = cell.parse().unwrap_or(0);
}
}
}
Ok(CypherResponse::Error {
message,
corruption: true,
}) => {
bail!("gitnexus reference unavailable for `{repo_name}`: {message}");
}
Ok(CypherResponse::Error { message, .. }) => {
eprintln!("[warn] gitnexus file-count query failed for `{repo_name}`: {message}");
}
Ok(CypherResponse::Empty) | Err(_) => {
}
}
Ok(GitnexusStats {
name: repo_name.to_string(),
node_counts_by_label: node_counts,
edge_counts_by_type: edge_counts,
file_count,
})
}
pub fn write_reference(name: &str, stats: &GitnexusStats) -> Result<PathBuf> {
let dir = Path::new("tools/verification/results");
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{name}.gitnexus.json"));
let json = serde_json::to_string_pretty(stats)?;
std::fs::write(&path, json + "\n")
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(path)
}
pub fn load_reference(name: &str) -> Result<GitnexusStats> {
let dir = Path::new("tools/verification/results");
let path = dir.join(format!("{name}.gitnexus.json"));
let bytes = std::fs::read(&path)
.with_context(|| format!("failed to read gitnexus reference {}", path.display()))?;
let stats: GitnexusStats = serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse gitnexus reference {}", path.display()))?;
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_markdown_table_basic() {
let md = "| lbl | c |\n| --- | --- |\n| Function | 100 |\n| File | 20 |";
let (headers, rows) = parse_markdown_table(md);
assert_eq!(headers, vec!["lbl".to_string(), "c".to_string()]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], vec!["Function".to_string(), "100".to_string()]);
assert_eq!(rows[1], vec!["File".to_string(), "20".to_string()]);
}
#[test]
fn parse_markdown_table_skips_extra_separators() {
let md = "| a | b |\n| --- | --- |\n| --- | --- |\n| 1 | 2 |";
let (headers, rows) = parse_markdown_table(md);
assert_eq!(headers, vec!["a".to_string(), "b".to_string()]);
assert_eq!(rows, vec![vec!["1".to_string(), "2".to_string()]]);
}
#[test]
fn parse_markdown_table_empty_input() {
let (headers, rows) = parse_markdown_table("");
assert!(headers.is_empty());
assert!(rows.is_empty());
}
#[test]
fn parse_markdown_table_single_column() {
let md = "| c |\n| --- |\n| 42 |";
let (headers, rows) = parse_markdown_table(md);
assert_eq!(headers, vec!["c".to_string()]);
assert_eq!(rows, vec![vec!["42".to_string()]]);
}
#[test]
fn corruption_patterns_match_known_errors() {
assert!(CORRUPTION_PATTERNS.iter().any(|p| {
"Mmap for size 8796093022208 failed"
.to_lowercase()
.contains(p)
}));
assert!(CORRUPTION_PATTERNS.iter().any(|p| {
"Assertion failed ... UNREACHABLE_CODE"
.to_lowercase()
.contains(p)
}));
assert!(!CORRUPTION_PATTERNS.iter().any(|p| {
"Binder exception: Table Foo does not exist"
.to_lowercase()
.contains(p)
}));
}
#[test]
fn gitnexus_stats_serialize_roundtrip() {
let stats = GitnexusStats {
name: "demo".to_string(),
node_counts_by_label: {
let mut m = BTreeMap::new();
m.insert("Function".to_string(), 100);
m.insert("File".to_string(), 20);
m
},
edge_counts_by_type: {
let mut m = BTreeMap::new();
m.insert("CALLS".to_string(), 500);
m
},
file_count: 20,
};
let json = serde_json::to_string_pretty(&stats).unwrap();
let back: GitnexusStats = serde_json::from_str(&json).unwrap();
assert_eq!(back, stats);
}
#[test]
fn cypher_json_parses_markdown_response() {
let json = r#"{"markdown":"| col1 | col2 |\n| --- | --- |\n| v1 | v2 |","row_count":1}"#;
let parsed: CypherJson = serde_json::from_str(json).unwrap();
assert!(parsed.markdown.is_some());
assert_eq!(parsed.row_count, Some(1));
assert!(parsed.error.is_none());
}
#[test]
fn cypher_json_parses_error_response() {
let json = r#"{"error":"Binder exception: Table Foo does not exist"}"#;
let parsed: CypherJson = serde_json::from_str(json).unwrap();
assert!(parsed.markdown.is_none());
assert_eq!(
parsed.error.as_deref(),
Some("Binder exception: Table Foo does not exist")
);
}
#[test]
fn cypher_json_parses_empty_object() {
let json = r#"{}"#;
let parsed: CypherJson = serde_json::from_str(json).unwrap();
assert!(parsed.markdown.is_none());
assert!(parsed.error.is_none());
}
#[test]
fn split_row_trims_cells() {
let cells = split_row("| hello | world |");
assert_eq!(cells, vec!["hello".to_string(), "world".to_string()]);
}
#[test]
fn split_row_single_cell() {
let cells = split_row("| only |");
assert_eq!(cells, vec!["only".to_string()]);
}
#[test]
fn write_and_load_reference_roundtrip() {
let stats = GitnexusStats {
name: "_test_roundtrip".to_string(),
node_counts_by_label: {
let mut m = BTreeMap::new();
m.insert("Function".to_string(), 42);
m.insert("File".to_string(), 10);
m
},
edge_counts_by_type: {
let mut m = BTreeMap::new();
m.insert("CALLS".to_string(), 100);
m
},
file_count: 10,
};
let path = write_reference("_test_roundtrip", &stats).expect("write_reference");
assert!(path.exists(), "reference file should be created");
let loaded = load_reference("_test_roundtrip").expect("load_reference");
assert_eq!(loaded, stats);
std::fs::remove_file(&path).expect("cleanup test file");
}
#[test]
fn load_reference_returns_err_for_nonexistent() {
let result = load_reference("_nonexistent_repo_xyz");
assert!(
result.is_err(),
"loading nonexistent reference should error"
);
}
}