use crate::cli::GraphExportFormat;
use crate::errors::AppError;
use crate::output;
use crate::paths::AppPaths;
use crate::storage::connection::open_ro;
use crate::storage::entities;
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(clap::Args)]
pub struct GraphArgs {
#[arg(long)]
pub namespace: Option<String>,
#[arg(long, value_enum, default_value = "json")]
pub format: GraphExportFormat,
#[arg(long)]
pub output: Option<PathBuf>,
#[arg(long, env = "NEUROGRAPHRAG_DB_PATH")]
pub db: Option<String>,
}
#[derive(Serialize)]
struct NodeOut {
id: i64,
name: String,
namespace: String,
kind: String,
#[serde(rename = "type")]
r#type: String,
}
#[derive(Serialize)]
struct EdgeOut {
from: String,
to: String,
relation: String,
weight: f64,
}
#[derive(Serialize)]
struct GraphSnapshot {
nodes: Vec<NodeOut>,
edges: Vec<EdgeOut>,
}
pub fn run(args: GraphArgs) -> Result<(), AppError> {
let paths = AppPaths::resolve(args.db.as_deref())?;
if !paths.db.exists() {
return Err(AppError::NotFound(format!(
"database not found at {}. Run 'neurographrag init' first.",
paths.db.display()
)));
}
let conn = open_ro(&paths.db)?;
let nodes_raw = entities::list_entities(&conn, args.namespace.as_deref())?;
let edges_raw = entities::list_relationships_by_namespace(&conn, args.namespace.as_deref())?;
let id_to_name: HashMap<i64, String> =
nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
let nodes: Vec<NodeOut> = nodes_raw
.into_iter()
.map(|n| NodeOut {
id: n.id,
name: n.name,
namespace: n.namespace,
r#type: n.kind.clone(),
kind: n.kind,
})
.collect();
let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
for r in edges_raw {
let from = match id_to_name.get(&r.source_id) {
Some(n) => n.clone(),
None => continue,
};
let to = match id_to_name.get(&r.target_id) {
Some(n) => n.clone(),
None => continue,
};
edges.push(EdgeOut {
from,
to,
relation: r.relation,
weight: r.weight,
});
}
let rendered = match args.format {
GraphExportFormat::Json => render_json(&GraphSnapshot { nodes, edges })?,
GraphExportFormat::Dot => render_dot(&nodes, &edges),
GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
};
if let Some(path) = &args.output {
fs::write(path, &rendered)?;
output::emit_progress(&format!("wrote {}", path.display()));
} else {
output::emit_text(&rendered);
}
Ok(())
}
fn render_json(snapshot: &GraphSnapshot) -> Result<String, AppError> {
Ok(serde_json::to_string_pretty(snapshot)?)
}
fn sanitize_dot_id(raw: &str) -> String {
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn render_dot(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
let mut out = String::new();
out.push_str("digraph neurographrag {\n");
for node in nodes {
let node_id = sanitize_dot_id(&node.name);
let escaped = node.name.replace('"', "\\\"");
out.push_str(&format!(" {node_id} [label=\"{escaped}\"];\n"));
}
for edge in edges {
let from = sanitize_dot_id(&edge.from);
let to = sanitize_dot_id(&edge.to);
let label = edge.relation.replace('"', "\\\"");
out.push_str(&format!(" {from} -> {to} [label=\"{label}\"];\n"));
}
out.push_str("}\n");
out
}
fn sanitize_mermaid_id(raw: &str) -> String {
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn render_mermaid(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
let mut out = String::new();
out.push_str("graph LR\n");
for node in nodes {
let id = sanitize_mermaid_id(&node.name);
let escaped = node.name.replace('"', "\\\"");
out.push_str(&format!(" {id}[\"{escaped}\"]\n"));
}
for edge in edges {
let from = sanitize_mermaid_id(&edge.from);
let to = sanitize_mermaid_id(&edge.to);
let label = edge.relation.replace('|', "\\|");
out.push_str(&format!(" {from} -->|{label}| {to}\n"));
}
out
}
#[cfg(test)]
mod testes {
use super::*;
fn cria_node(kind: &str) -> NodeOut {
NodeOut {
id: 1,
name: "entidade-teste".to_string(),
namespace: "default".to_string(),
kind: kind.to_string(),
r#type: kind.to_string(),
}
}
#[test]
fn node_out_type_duplica_kind() {
let node = cria_node("agent");
let json = serde_json::to_value(&node).expect("serialização deve funcionar");
assert_eq!(json["kind"], json["type"]);
assert_eq!(json["kind"], "agent");
assert_eq!(json["type"], "agent");
}
#[test]
fn node_out_serializa_todos_campos() {
let node = cria_node("document");
let json = serde_json::to_value(&node).expect("serialização deve funcionar");
assert!(json.get("id").is_some());
assert!(json.get("name").is_some());
assert!(json.get("namespace").is_some());
assert!(json.get("kind").is_some());
assert!(json.get("type").is_some());
}
#[test]
fn graph_snapshot_serializa_nodes_com_type() {
let node = cria_node("concept");
let snapshot = GraphSnapshot {
nodes: vec![node],
edges: vec![],
};
let json_str = render_json(&snapshot).expect("renderização deve funcionar");
let json: serde_json::Value = serde_json::from_str(&json_str).expect("json válido");
let primeiro_node = &json["nodes"][0];
assert_eq!(primeiro_node["kind"], primeiro_node["type"]);
assert_eq!(primeiro_node["type"], "concept");
}
}