use std::collections::{BTreeSet, HashMap};
use colored::Colorize;
use velesdb_core::GraphCollection;
use crate::helpers;
const SAMPLE_SIZE: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PhantomEdge {
pub edge_id: u64,
pub source: u64,
pub target: u64,
pub label: String,
pub missing_source: bool,
pub missing_target: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DoctorMode {
Report,
Purge,
Stub,
}
impl DoctorMode {
fn label(self) -> &'static str {
match self {
DoctorMode::Report => "report",
DoctorMode::Purge => "purge",
DoctorMode::Stub => "stub",
}
}
}
fn node_has_payload(
col: &GraphCollection,
cache: &mut HashMap<u64, bool>,
node_id: u64,
) -> anyhow::Result<bool> {
if let Some(&cached) = cache.get(&node_id) {
return Ok(cached);
}
let has = col
.get_node_payload(node_id)
.map_err(|e| anyhow::anyhow!("{e}"))?
.is_some();
cache.insert(node_id, has);
Ok(has)
}
pub(crate) fn scan_phantom_edges(col: &GraphCollection) -> anyhow::Result<Vec<PhantomEdge>> {
let edges = col.get_edges(None);
let mut cache = HashMap::new();
let mut phantoms = Vec::new();
for edge in &edges {
let missing_source = !node_has_payload(col, &mut cache, edge.source())?;
let missing_target = !node_has_payload(col, &mut cache, edge.target())?;
if missing_source || missing_target {
phantoms.push(PhantomEdge {
edge_id: edge.id(),
source: edge.source(),
target: edge.target(),
label: edge.label().to_string(),
missing_source,
missing_target,
});
}
}
Ok(phantoms)
}
pub(crate) fn purge_phantom_edges(col: &GraphCollection, phantoms: &[PhantomEdge]) -> usize {
phantoms
.iter()
.filter(|p| col.remove_edge(p.edge_id))
.count()
}
pub(crate) fn stub_phantom_edges(
col: &GraphCollection,
phantoms: &[PhantomEdge],
) -> anyhow::Result<usize> {
let mut missing_nodes: BTreeSet<u64> = BTreeSet::new();
for p in phantoms {
if p.missing_source {
missing_nodes.insert(p.source);
}
if p.missing_target {
missing_nodes.insert(p.target);
}
}
for &node_id in &missing_nodes {
col.upsert_node_payload(node_id, &serde_json::json!({}))
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok(missing_nodes.len())
}
fn phantom_to_json(p: &PhantomEdge) -> serde_json::Value {
serde_json::json!({
"edge_id": p.edge_id,
"source": p.source,
"target": p.target,
"label": p.label,
"missing_source": p.missing_source,
"missing_target": p.missing_target,
})
}
pub(crate) fn print_report(
collection: &str,
phantoms: &[PhantomEdge],
mode: DoctorMode,
fixed: usize,
format: &str,
) -> anyhow::Result<()> {
if format == "json" {
let sample: Vec<_> = phantoms
.iter()
.take(SAMPLE_SIZE)
.map(phantom_to_json)
.collect();
helpers::print_json(&serde_json::json!({
"collection": collection,
"phantom_edge_count": phantoms.len(),
"mode": mode.label(),
"fixed": fixed,
"sample": sample,
}))?;
} else {
print_report_table(collection, phantoms, mode, fixed);
}
Ok(())
}
fn print_report_table(collection: &str, phantoms: &[PhantomEdge], mode: DoctorMode, fixed: usize) {
println!(
"\n{} '{}'\n",
"Graph Doctor".bold().underline(),
collection.green()
);
if phantoms.is_empty() {
println!(" {} No phantom edges found.\n", "✅".green());
return;
}
println!(
" {} {} phantom edge(s) found (edge store references a node with no stored payload)\n",
"⚠️".yellow(),
phantoms.len().to_string().yellow()
);
for p in phantoms.iter().take(SAMPLE_SIZE) {
let missing = match (p.missing_source, p.missing_target) {
(true, true) => "source+target".to_string(),
(true, false) => format!("source={}", p.source),
(false, true) => format!("target={}", p.target),
(false, false) => String::new(),
};
println!(
" {} {} --[{}]--> {} missing={}",
format!("[{}]", p.edge_id).cyan(),
p.source,
p.label.green(),
p.target,
missing.red(),
);
}
if phantoms.len() > SAMPLE_SIZE {
println!(" ... and {} more", phantoms.len() - SAMPLE_SIZE);
}
match mode {
DoctorMode::Report => {
println!(
"\n {} Dry-run: no changes made. Re-run with --purge or --stub to repair.\n",
"ℹ️".cyan()
);
}
DoctorMode::Purge => {
println!(
"\n {} {} phantom edge(s) removed.\n",
"✅".green(),
fixed.to_string().green()
);
}
DoctorMode::Stub => {
println!(
"\n {} {} missing node(s) stubbed with an empty payload.\n",
"✅".green(),
fixed.to_string().green()
);
}
}
}