Skip to main content

meta_ast/output/
graph.rs

1//! Graph output serialization for dependency analysis results.
2//!
3//! Provides JSON serialization of the dependency graph, SCC analysis,
4//! and deployability hints for external consumers.
5
6use petgraph::graph::NodeIndex;
7use serde::Serialize;
8
9use crate::graph::CodeGraph;
10use crate::graph::edge::EdgeKind;
11use crate::graph::node::{FileNode, NodeData, SymbolNode};
12use crate::graph::scc::{DeployabilityHint, SccAnalysis};
13
14/// Complete graph analysis output for serialization.
15#[derive(Debug, Clone, Serialize)]
16pub struct GraphOutput {
17    /// Analysis metadata
18    pub metadata: GraphMetadata,
19    /// Graph nodes (files and symbols)
20    pub nodes: Vec<SerializedNode>,
21    /// Graph edges with kinds
22    pub edges: Vec<SerializedEdge>,
23    /// Strongly connected components
24    pub sccs: Vec<SerializedScc>,
25    /// Deployability summary statistics
26    pub deployability: DeployabilityStats,
27}
28
29/// Metadata about the graph analysis.
30#[derive(Debug, Clone, Serialize)]
31pub struct GraphMetadata {
32    /// Snapshot identifier for this analysis
33    pub snapshot_id: u64,
34    /// Total number of nodes
35    pub node_count: usize,
36    /// Total number of edges
37    pub edge_count: usize,
38    /// Number of SCCs computed
39    pub scc_count: usize,
40    /// Number of file nodes
41    pub file_count: usize,
42    /// Number of symbol nodes
43    pub symbol_count: usize,
44}
45
46/// Serialized node representation.
47#[derive(Debug, Clone, Serialize)]
48pub struct SerializedNode {
49    /// Node index in the graph
50    pub id: usize,
51    /// Node kind: "file" or "symbol"
52    pub kind: String,
53    /// File path (for file nodes) or containing file path (for symbol nodes)
54    pub path: Option<String>,
55    /// Language identifier (for file nodes)
56    pub language: Option<String>,
57    /// Symbol name (for symbol nodes)
58    pub name: Option<String>,
59    /// Symbol kind (for symbol nodes)
60    #[serde(rename = "symbol_kind", skip_serializing_if = "Option::is_none")]
61    pub symbol_kind: Option<String>,
62    /// Visibility (for symbol nodes)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub visibility: Option<String>,
65}
66
67/// Serialized edge representation.
68#[derive(Debug, Clone, Serialize)]
69pub struct SerializedEdge {
70    /// Source node index
71    pub source: usize,
72    /// Target node index
73    pub target: usize,
74    /// Edge kind: "ownership", "import", or "reference"
75    pub kind: String,
76    /// Confidence score (0.0 - 1.0) for cross-language resolution
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub confidence: Option<f32>,
79}
80
81/// Serialized SCC representation.
82#[derive(Debug, Clone, Serialize)]
83pub struct SerializedScc {
84    /// Component index
85    pub index: usize,
86    /// Node indices in this component
87    pub nodes: Vec<usize>,
88    /// Whether this component contains cycles
89    pub is_cyclic: bool,
90    /// Deployability hint
91    pub hint: String,
92    /// Component size
93    pub size: usize,
94}
95
96/// Deployability statistics summary.
97#[derive(Debug, Clone, Serialize)]
98pub struct DeployabilityStats {
99    /// Number of cyclic clusters (size > 1 or self-loop)
100    pub cyclic_clusters: usize,
101    /// Number of independent deployable units (size = 1, no self-loop)
102    pub independent_units: usize,
103    /// Number of self-loops (size = 1 with self-loop)
104    pub self_loops: usize,
105    /// Total number of components
106    pub total_components: usize,
107}
108
109impl GraphOutput {
110    /// Create a new GraphOutput from a CodeGraph and its SCC analysis.
111    pub fn from_graph(graph: &CodeGraph, scc_analysis: &SccAnalysis, snapshot_id: u64) -> Self {
112        let metadata = Self::build_metadata(graph, scc_analysis, snapshot_id);
113        let nodes = Self::serialize_nodes(graph);
114        let edges = Self::serialize_edges(graph);
115        let sccs = Self::serialize_sccs(scc_analysis);
116        let deployability = Self::build_deployability_stats(scc_analysis);
117
118        Self {
119            metadata,
120            nodes,
121            edges,
122            sccs,
123            deployability,
124        }
125    }
126
127    fn build_metadata(
128        graph: &CodeGraph,
129        scc_analysis: &SccAnalysis,
130        snapshot_id: u64,
131    ) -> GraphMetadata {
132        let node_count = graph.graph.node_count();
133        let edge_count = graph.graph.edge_count();
134        let scc_count = scc_analysis.components.len();
135
136        let mut file_count = 0;
137        let mut symbol_count = 0;
138
139        for node_data in graph.graph.node_weights() {
140            match node_data {
141                NodeData::File(_) => file_count += 1,
142                NodeData::Symbol(_) => symbol_count += 1,
143                NodeData::External(_) => {}
144            }
145        }
146
147        GraphMetadata {
148            snapshot_id,
149            node_count,
150            edge_count,
151            scc_count,
152            file_count,
153            symbol_count,
154        }
155    }
156
157    fn serialize_nodes(graph: &CodeGraph) -> Vec<SerializedNode> {
158        graph
159            .graph
160            .node_indices()
161            .map(|idx| {
162                let node_data = &graph.graph[idx];
163                Self::serialize_node(idx, node_data)
164            })
165            .collect()
166    }
167
168    fn serialize_node(idx: NodeIndex, node_data: &NodeData) -> SerializedNode {
169        match node_data {
170            NodeData::File(file_node) => Self::serialize_file_node(idx, file_node),
171            NodeData::Symbol(symbol_node) => Self::serialize_symbol_node(idx, symbol_node),
172            NodeData::External(external_node) => SerializedNode {
173                id: idx.index(),
174                kind: "external".to_string(),
175                path: Some(external_node.raw_path.clone()),
176                language: Some(external_node.language.as_ref().to_string()),
177                name: None,
178                symbol_kind: None,
179                visibility: None,
180            },
181        }
182    }
183
184    fn serialize_file_node(idx: NodeIndex, file_node: &FileNode) -> SerializedNode {
185        SerializedNode {
186            id: idx.index(),
187            kind: "file".to_string(),
188            path: Some(file_node.path.to_string_lossy().to_string()),
189            language: Some(file_node.language.as_ref().to_string()),
190            name: None,
191            symbol_kind: None,
192            visibility: None,
193        }
194    }
195
196    fn serialize_symbol_node(idx: NodeIndex, symbol_node: &SymbolNode) -> SerializedNode {
197        let visibility = symbol_node.visibility.map(|v| format!("{:?}", v));
198
199        SerializedNode {
200            id: idx.index(),
201            kind: "symbol".to_string(),
202            path: None,
203            language: None,
204            name: Some(symbol_node.name.clone()),
205            symbol_kind: Some(format!("{:?}", symbol_node.kind)),
206            visibility,
207        }
208    }
209
210    fn serialize_edges(graph: &CodeGraph) -> Vec<SerializedEdge> {
211        graph
212            .graph
213            .edge_indices()
214            .filter_map(|edge_idx| {
215                let (source, target) = graph.graph.edge_endpoints(edge_idx)?;
216                let edge_data = graph.graph.edge_weight(edge_idx)?;
217                let confidence = if edge_data.confidence < 1.0 {
218                    Some(edge_data.confidence)
219                } else {
220                    None
221                };
222
223                let kind_str = match edge_data.kind {
224                    EdgeKind::Ownership => "ownership",
225                    EdgeKind::Import => "import",
226                    EdgeKind::Reference => "reference",
227                };
228
229                Some(SerializedEdge {
230                    source: source.index(),
231                    target: target.index(),
232                    kind: kind_str.to_string(),
233                    confidence,
234                })
235            })
236            .collect()
237    }
238
239    fn serialize_sccs(scc_analysis: &SccAnalysis) -> Vec<SerializedScc> {
240        scc_analysis
241            .components
242            .iter()
243            .map(|scc| {
244                let nodes: Vec<usize> = scc.nodes.iter().map(|n| n.index()).collect();
245                let hint_str = scc.hint.to_string();
246
247                SerializedScc {
248                    index: scc.index,
249                    nodes,
250                    is_cyclic: scc.is_cyclic,
251                    hint: hint_str.to_string(),
252                    size: scc.nodes.len(),
253                }
254            })
255            .collect()
256    }
257
258    fn build_deployability_stats(scc_analysis: &SccAnalysis) -> DeployabilityStats {
259        let mut cyclic_clusters = 0;
260        let mut independent_units = 0;
261        let mut self_loops = 0;
262
263        for scc in &scc_analysis.components {
264            match scc.hint {
265                DeployabilityHint::Independent | DeployabilityHint::AcyclicDependency => {
266                    independent_units += 1;
267                }
268                DeployabilityHint::CyclicCluster => {
269                    cyclic_clusters += 1;
270                }
271                DeployabilityHint::SelfLoop => {
272                    self_loops += 1;
273                }
274            }
275        }
276
277        DeployabilityStats {
278            cyclic_clusters,
279            independent_units,
280            self_loops,
281            total_components: scc_analysis.components.len(),
282        }
283    }
284}
285
286/// Serialize graph output to the specified format.
287pub fn serialize_graph(
288    graph: &CodeGraph,
289    scc_analysis: &SccAnalysis,
290    snapshot_id: u64,
291    format: &crate::output::OutputFormat,
292) -> anyhow::Result<String> {
293    let output = GraphOutput::from_graph(graph, scc_analysis, snapshot_id);
294    format.serialize(&output)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::graph::builder::GraphBuilder;
301    use crate::language::LangId;
302    use crate::model::{
303        LineColumn, SourceRange, Symbol, SymbolId, SymbolKind, Visibility, ids::SnapshotId,
304    };
305    use crate::output::OutputFormat;
306    use std::path::PathBuf;
307
308    fn sample_symbol(id: u32, name: &str, kind: SymbolKind, path: &str) -> Symbol {
309        Symbol {
310            id: SymbolId(id),
311            name: name.to_string(),
312            kind,
313            language: LangId::Rust,
314            file_path: PathBuf::from(path),
315            source_range: SourceRange {
316                byte_start: 0,
317                byte_end: 10,
318                start: LineColumn { line: 1, column: 0 },
319                end: LineColumn {
320                    line: 1,
321                    column: 10,
322                },
323            },
324            visibility: Some(Visibility::Public),
325            signature: None,
326            docstring: None,
327            is_async: false,
328        }
329    }
330
331    #[test]
332    fn graph_output_has_required_keys() {
333        let mut builder = GraphBuilder::new(SnapshotId(1));
334        let _file_id = builder.add_file(PathBuf::from("src/main.rs"), LangId::Rust);
335        let symbol = sample_symbol(1, "main", SymbolKind::Function, "src/main.rs");
336        let _sym_idx = builder.add_symbol(&symbol).unwrap();
337
338        let graph = builder.build();
339        let scc_result = SccAnalysis::analyze(&graph.graph);
340
341        let output = GraphOutput::from_graph(&graph, &scc_result, 1);
342
343        assert_eq!(output.metadata.file_count, 1);
344        assert_eq!(output.metadata.symbol_count, 1);
345        assert!(!output.nodes.is_empty());
346    }
347
348    #[test]
349    fn serialized_node_kinds() {
350        let mut builder = GraphBuilder::new(SnapshotId(1));
351        let _file_id = builder.add_file(PathBuf::from("src/lib.rs"), LangId::Rust);
352        let symbol = sample_symbol(1, "lib_fn", SymbolKind::Function, "src/lib.rs");
353        let _sym_idx = builder.add_symbol(&symbol).unwrap();
354
355        let graph = builder.build();
356        let scc_result = SccAnalysis::analyze(&graph.graph);
357
358        let json = serialize_graph(&graph, &scc_result, 1, &OutputFormat::Json).unwrap();
359        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
360
361        // Verify structure
362        assert!(parsed.get("metadata").is_some());
363        assert!(parsed.get("nodes").is_some());
364        assert!(parsed.get("edges").is_some());
365        assert!(parsed.get("sccs").is_some());
366        assert!(parsed.get("deployability").is_some());
367
368        // Verify node kinds exist
369        let nodes = parsed["nodes"].as_array().unwrap();
370        let file_nodes: Vec<_> = nodes.iter().filter(|n| n["kind"] == "file").collect();
371        let symbol_nodes: Vec<_> = nodes.iter().filter(|n| n["kind"] == "symbol").collect();
372
373        assert_eq!(file_nodes.len(), 1);
374        assert_eq!(symbol_nodes.len(), 1);
375        assert!(file_nodes[0]["language"].is_string());
376        assert!(symbol_nodes[0]["name"].is_string());
377    }
378
379    #[test]
380    fn empty_graph_produces_empty_output() {
381        let builder = GraphBuilder::new(SnapshotId(1));
382        let graph = builder.build();
383        let scc_result = SccAnalysis::analyze(&graph.graph);
384
385        let output = GraphOutput::from_graph(&graph, &scc_result, 1);
386        let json = serde_json::to_string(&output).unwrap();
387
388        assert!(json.contains("\"node_count\":0"));
389        assert!(json.contains("\"sccs\":[]"));
390    }
391
392    #[test]
393    fn scc_serialization_contains_hint() {
394        // This test verifies SCC hints are serialized correctly
395        // We test through the public API that hints make it into output
396        let mut builder = GraphBuilder::new(SnapshotId(1));
397        let _file_id = builder.add_file(PathBuf::from("src/a.rs"), LangId::Rust);
398        let sym1 = sample_symbol(1, "func_a", SymbolKind::Function, "src/a.rs");
399        let sym2 = sample_symbol(2, "func_b", SymbolKind::Function, "src/a.rs");
400        builder.add_symbol(&sym1).unwrap();
401        builder.add_symbol(&sym2).unwrap();
402
403        // Create a cycle via reference edges between the symbols
404        builder.add_reference(sym1.id, sym2.id, 1.0);
405        builder.add_reference(sym2.id, sym1.id, 1.0);
406
407        let graph = builder.build();
408        let scc_result = SccAnalysis::analyze(&graph.graph);
409
410        let json = serialize_graph(&graph, &scc_result, 1, &OutputFormat::Json).unwrap();
411        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
412
413        // Find an SCC with cyclic=true
414        let sccs = parsed["sccs"].as_array().unwrap();
415        let cyclic_scc = sccs.iter().find(|s| s["is_cyclic"].as_bool().unwrap());
416
417        assert!(
418            cyclic_scc.is_some(),
419            "Expected to find a cyclic SCC in the output"
420        );
421        assert_eq!(
422            cyclic_scc.unwrap()["hint"],
423            "cyclic_cluster",
424            "Cyclic SCC should have cyclic_cluster hint"
425        );
426    }
427}