use serde::Serialize;
use crate::graph::CodeGraph;
use crate::graph::edge::EdgeKind;
use crate::graph::node::{FileNode, NodeData, SymbolNode};
use crate::graph::scc::{DeployabilityHint, SccAnalysis};
pub const SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize)]
pub struct GraphOutput {
pub schema_version: u32,
pub metadata: GraphMetadata,
pub nodes: Vec<SerializedNode>,
pub edges: Vec<SerializedEdge>,
pub sccs: Vec<SerializedScc>,
pub deployability: Option<DeployabilityStats>,
}
#[derive(Debug, Clone, Serialize)]
pub struct GraphMetadata {
pub snapshot_id: u64,
pub node_count: usize,
pub edge_count: usize,
pub scc_count: usize,
pub file_count: usize,
pub symbol_count: usize,
pub data_node_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct SerializedNode {
pub id: usize,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_range: Option<crate::model::SourceRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub type_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SerializedEdge {
pub source: usize,
pub target: usize,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_kind: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SerializedScc {
pub index: usize,
pub nodes: Vec<usize>,
pub is_cyclic: bool,
pub hint: String,
pub size: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeployabilityStats {
pub cyclic_clusters: usize,
pub independent_units: usize,
pub self_loops: usize,
pub total_components: usize,
}
impl GraphOutput {
pub fn from_graph(
graph: &CodeGraph,
scc_analysis: Option<&SccAnalysis>,
snapshot_id: u64,
) -> Self {
let metadata = Self::build_metadata(graph, scc_analysis, snapshot_id);
let nodes = Self::serialize_nodes(graph);
let edges = Self::serialize_edges(graph);
let (sccs, deployability) = if let Some(scc) = scc_analysis {
(
Self::serialize_sccs(scc),
Some(Self::build_deployability_stats(scc)),
)
} else {
(Vec::new(), None)
};
Self {
schema_version: SCHEMA_VERSION,
metadata,
nodes,
edges,
sccs,
deployability,
}
}
fn build_metadata(
graph: &CodeGraph,
scc_analysis: Option<&SccAnalysis>,
snapshot_id: u64,
) -> GraphMetadata {
let g = graph.graph();
let node_count = g.node_count();
let edge_count = g.edge_count();
let scc_count = scc_analysis.map_or(0, |s| s.components.len());
let mut file_count = 0;
let mut symbol_count = 0;
let mut data_node_count = 0;
for node_data in g.node_weights() {
match node_data {
NodeData::File(_) => file_count += 1,
NodeData::Symbol(_) => symbol_count += 1,
NodeData::External(_) => {}
NodeData::Data(_) => data_node_count += 1,
}
}
GraphMetadata {
snapshot_id,
node_count,
edge_count,
scc_count,
file_count,
symbol_count,
data_node_count,
}
}
fn serialize_nodes(graph: &CodeGraph) -> Vec<SerializedNode> {
let g = graph.graph();
g.node_indices()
.map(|idx| {
let node_data = &g[idx];
Self::serialize_node(graph, idx.index(), node_data)
})
.collect()
}
fn serialize_node(graph: &CodeGraph, id: usize, node_data: &NodeData) -> SerializedNode {
match node_data {
NodeData::File(f) => Self::serialize_file_node(id, f),
NodeData::Symbol(s) => Self::serialize_symbol_node(graph, id, s),
NodeData::External(e) => SerializedNode {
id,
kind: "external".to_string(),
path: Some(e.raw_path.clone()),
file_path: None,
source_range: None,
language: Some(e.language.as_ref().to_string()),
name: None,
symbol_kind: None,
visibility: None,
data_scope: None,
type_hint: None,
},
NodeData::Data(d) => SerializedNode {
id,
kind: "data".to_string(),
path: None,
file_path: None,
source_range: None,
language: None,
name: d.name.clone(),
symbol_kind: None,
visibility: None,
data_scope: Some(d.scope.as_str().to_string()),
type_hint: d.type_hint.clone(),
},
}
}
fn serialize_file_node(id: usize, file_node: &FileNode) -> SerializedNode {
SerializedNode {
id,
kind: "file".to_string(),
path: Some(crate::input::portable_path(&file_node.path)),
file_path: None,
source_range: None,
language: Some(file_node.language.as_ref().to_string()),
name: None,
symbol_kind: None,
visibility: None,
data_scope: None,
type_hint: None,
}
}
fn serialize_symbol_node(
graph: &CodeGraph,
id: usize,
symbol_node: &SymbolNode,
) -> SerializedNode {
let file_path = graph
.file_node(symbol_node.file_id)
.map(|file| crate::input::portable_path(&file.path));
SerializedNode {
id,
kind: "symbol".to_string(),
path: None,
file_path,
source_range: Some(symbol_node.source_range.clone()),
language: None,
name: Some(symbol_node.name.clone()),
symbol_kind: Some(format!("{:?}", symbol_node.kind)),
visibility: symbol_node.visibility.map(|v| format!("{:?}", v)),
data_scope: None,
type_hint: None,
}
}
fn serialize_edges(graph: &CodeGraph) -> Vec<SerializedEdge> {
let g = graph.graph();
let mut edges: Vec<SerializedEdge> = g
.edge_indices()
.filter_map(|edge_idx| {
let (source, target) = g.edge_endpoints(edge_idx)?;
let edge_data = g.edge_weight(edge_idx)?;
let confidence = if edge_data.confidence < 1.0 {
Some(edge_data.confidence)
} else {
None
};
let flow_kind = if edge_data.kind == EdgeKind::Flow {
edge_data.flow_kind.map(|fk| fk.as_str().to_string())
} else {
None
};
Some(SerializedEdge {
source: source.index(),
target: target.index(),
kind: edge_data.kind.as_str().to_string(),
confidence,
flow_kind,
})
})
.collect();
edges.sort_by(|a, b| (a.source, a.target, &a.kind).cmp(&(b.source, b.target, &b.kind)));
edges
}
fn serialize_sccs(scc_analysis: &SccAnalysis) -> Vec<SerializedScc> {
scc_analysis
.components
.iter()
.map(|scc| {
let nodes: Vec<usize> = scc.nodes.iter().map(|n| n.index()).collect();
SerializedScc {
index: scc.index,
nodes,
is_cyclic: scc.is_cyclic,
hint: scc.hint.to_string(),
size: scc.nodes.len(),
}
})
.collect()
}
fn build_deployability_stats(scc_analysis: &SccAnalysis) -> DeployabilityStats {
let mut cyclic_clusters = 0;
let mut independent_units = 0;
let mut self_loops = 0;
for scc in &scc_analysis.components {
match scc.hint {
DeployabilityHint::Independent | DeployabilityHint::AcyclicDependency => {
independent_units += 1;
}
DeployabilityHint::CyclicCluster => {
cyclic_clusters += 1;
}
DeployabilityHint::SelfLoop => {
self_loops += 1;
}
}
}
DeployabilityStats {
cyclic_clusters,
independent_units,
self_loops,
total_components: scc_analysis.components.len(),
}
}
}
pub fn serialize_graph(
graph: &CodeGraph,
scc_analysis: &SccAnalysis,
snapshot_id: u64,
format: &crate::output::OutputFormat,
) -> anyhow::Result<String> {
let output = GraphOutput::from_graph(graph, Some(scc_analysis), snapshot_id);
format.serialize(&output)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::GraphBuilder;
use crate::language::LangId;
use crate::model::{DataNodeId, DataScope, FlowKind, LineColumn, SnapshotId, SourceRange};
use crate::output::OutputFormat;
use std::path::PathBuf;
fn sample_source_range() -> SourceRange {
SourceRange {
byte_start: 0,
byte_end: 10,
start: LineColumn { line: 0, column: 0 },
end: LineColumn {
line: 0,
column: 10,
},
}
}
fn sample_scc_analysis() -> SccAnalysis {
SccAnalysis {
components: Vec::new(),
node_to_component: std::collections::HashMap::new(),
}
}
#[test]
fn graph_output_with_file_node() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("src/main.py"), LangId::Python);
let graph = builder.build();
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
assert_eq!(output.schema_version, SCHEMA_VERSION);
assert_eq!(output.metadata.file_count, 1);
assert_eq!(output.metadata.symbol_count, 0);
assert_eq!(output.metadata.data_node_count, 0);
assert_eq!(output.metadata.scc_count, 0);
assert_eq!(output.nodes.len(), 1);
assert_eq!(output.nodes[0].kind, "file");
assert_eq!(output.nodes[0].path.as_deref(), Some("src/main.py"));
assert_eq!(output.nodes[0].language.as_deref(), Some("python"));
}
#[test]
fn graph_output_language_uses_canonical_names() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("src/main.js"), LangId::JavaScript);
builder.add_file(PathBuf::from("src/main.ts"), LangId::TypeScript);
builder.add_file(PathBuf::from("src/main.tsx"), LangId::Tsx);
let graph = builder.build();
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
let language_of = |path: &str| {
output
.nodes
.iter()
.find(|n| n.path.as_deref() == Some(path))
.and_then(|n| n.language.as_deref())
};
assert_eq!(language_of("src/main.js"), Some("javascript"));
assert_eq!(language_of("src/main.ts"), Some("typescript"));
assert_eq!(language_of("src/main.tsx"), Some("tsx"));
}
#[test]
fn json_output_has_required_structure() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("src/main.py"), LangId::Python);
let graph = builder.build();
let scc = sample_scc_analysis();
let json = serialize_graph(&graph, &scc, 1, &OutputFormat::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["schema_version"], 2);
assert!(parsed["metadata"].is_object());
assert!(parsed["nodes"].is_array());
assert!(parsed["edges"].is_array());
if let Some(sccs) = parsed.get("sccs") {
assert!(sccs.is_array());
}
}
#[test]
fn empty_graph_serializes() {
let builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
let graph = builder.build();
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"node_count\":0"));
assert!(json.contains("\"edge_count\":0"));
assert!(json.contains("\"schema_version\":2"));
}
#[test]
fn scc_fields_serialize() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("a.py"), LangId::Python);
builder.add_file(PathBuf::from("b.py"), LangId::Python);
let graph = builder.build();
let scc = sample_scc_analysis();
let json = serialize_graph(&graph, &scc, 1, &OutputFormat::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let _sccs = &parsed["sccs"];
let _deploy = &parsed["deployability"];
}
#[test]
fn data_node_serializes_with_correct_fields() {
use crate::graph::node::DataGraphNode;
let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
let dnode = DataGraphNode {
id: DataNodeId::new(10).unwrap(),
symbol_id: None,
name: Some("local_var".into()),
scope: DataScope::Local,
type_hint: Some("u32".into()),
source_range: sample_source_range(),
};
graph.add_node(NodeData::Data(dnode));
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
assert_eq!(output.metadata.data_node_count, 1);
assert_eq!(output.nodes[0].kind, "data");
assert_eq!(output.nodes[0].name.as_deref(), Some("local_var"));
assert_eq!(output.nodes[0].data_scope.as_deref(), Some("local"));
assert_eq!(output.nodes[0].type_hint.as_deref(), Some("u32"));
assert!(output.nodes[0].symbol_kind.is_none());
assert!(output.nodes[0].visibility.is_none());
}
#[test]
fn flow_edge_serializes_with_flow_kind() {
use crate::graph::node::DataGraphNode;
let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
let src = graph.add_node(NodeData::Data(DataGraphNode {
id: DataNodeId::new(1).unwrap(),
symbol_id: None,
name: Some("x".into()),
scope: DataScope::Local,
type_hint: None,
source_range: sample_source_range(),
}));
let dst = graph.add_node(NodeData::Data(DataGraphNode {
id: DataNodeId::new(2).unwrap(),
symbol_id: None,
name: Some("y".into()),
scope: DataScope::Parameter,
type_hint: None,
source_range: sample_source_range(),
}));
graph.add_edge_normalized_with_flow(
src,
dst,
EdgeKind::Flow,
0.9,
Some(FlowKind::Argument),
);
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
assert_eq!(output.edges.len(), 1);
assert_eq!(output.edges[0].kind, "flow");
assert_eq!(output.edges[0].confidence, Some(0.9));
assert_eq!(output.edges[0].flow_kind.as_deref(), Some("argument"));
}
#[test]
fn flow_edge_without_flow_kind_omits_field() {
use crate::graph::node::DataGraphNode;
let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
let src = graph.add_node(NodeData::Data(DataGraphNode {
id: DataNodeId::new(1).unwrap(),
symbol_id: None,
name: Some("a".into()),
scope: DataScope::Local,
type_hint: None,
source_range: sample_source_range(),
}));
let dst = graph.add_node(NodeData::Data(DataGraphNode {
id: DataNodeId::new(2).unwrap(),
symbol_id: None,
name: Some("b".into()),
scope: DataScope::Local,
type_hint: None,
source_range: sample_source_range(),
}));
graph.add_edge_normalized(src, dst, EdgeKind::Flow, 1.0);
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
assert_eq!(output.edges[0].flow_kind, None);
assert_eq!(output.edges[0].confidence, None); }
#[test]
fn light_mode_omits_scc_and_deployability() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("main.rs"), LangId::Rust);
let graph = builder.build();
let output = GraphOutput::from_graph(&graph, None, 1);
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(output.metadata.scc_count, 0);
assert!(parsed.get("sccs").is_none() || parsed["sccs"].is_array());
assert!(parsed.get("deployability").is_none() || parsed["deployability"].is_null());
}
#[test]
fn light_mode_with_symbol_node() {
use crate::model::{Symbol, SymbolId, SymbolKind, Visibility};
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("main.rs"), LangId::Rust);
let sym = Symbol {
id: SymbolId::new(1).unwrap(),
name: "main_fn".into(),
kind: SymbolKind::Function,
language: LangId::Rust,
file_path: PathBuf::from("main.rs"),
source_range: sample_source_range(),
visibility: Some(Visibility::Public),
signature: None,
docstring: None,
is_async: false,
};
builder.add_symbol(&sym).unwrap();
let graph = builder.build();
let output = GraphOutput::from_graph(&graph, None, 1);
assert_eq!(output.nodes.len(), 2); assert_eq!(output.edges.len(), 1); let sym_node = output.nodes.iter().find(|n| n.kind == "symbol").unwrap();
assert_eq!(sym_node.name.as_deref(), Some("main_fn"));
assert_eq!(sym_node.file_path.as_deref(), Some("main.rs"));
assert_eq!(sym_node.source_range.as_ref(), Some(&sample_source_range()));
}
#[test]
fn has_all_required_keys() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("main.rs"), LangId::Rust);
let graph = builder.build();
let output = GraphOutput::from_graph(&graph, None, 1);
let val: serde_json::Value = serde_json::to_value(&output).unwrap();
let required = ["schema_version", "metadata", "nodes", "edges"];
for key in &required {
assert!(val.get(key).is_some(), "missing required key: {key}");
}
}
#[test]
fn edge_kinds_are_valid() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
let file_a = builder.add_file(PathBuf::from("a.py"), LangId::Python);
let _file_b = builder.add_file(PathBuf::from("b.py"), LangId::Python);
builder.add_import(file_a, PathBuf::from("b.py"));
let graph = builder.build();
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
let valid_kinds = ["ownership", "import", "reference", "flow"];
for edge in &output.edges {
assert!(
valid_kinds.contains(&edge.kind.as_str()),
"unexpected edge kind: {}",
edge.kind
);
}
}
#[test]
fn serialized_edges_stay_sorted() {
let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
builder.add_file(PathBuf::from("b.py"), LangId::Python);
builder.add_file(PathBuf::from("a.py"), LangId::Python);
let graph = builder.build();
let scc = sample_scc_analysis();
let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
let mut sorted = output.edges.clone();
sorted.sort_by(|a, b| (a.source, a.target, &a.kind).cmp(&(b.source, b.target, &b.kind)));
let order: Vec<_> = output
.edges
.iter()
.map(|e| (e.source, e.target, e.kind.clone()))
.collect();
let expected: Vec<_> = sorted
.iter()
.map(|e| (e.source, e.target, e.kind.clone()))
.collect();
assert_eq!(order, expected);
}
}