srcgraph_core/lib.rs
1//! Graph model, traits, and GraphML I/O.
2//!
3//! See `DESIGN.md` at the workspace root for the full plan.
4//!
5//! @yah:relay(R152, "srcgraph crate family v1 — core + Phase 1 metrics + PyO3 bindings")
6//! @yah:at(2026-05-12T22:13:13Z)
7//! @yah:status(open)
8//! @yah:next("T1 OwnedGraph+GraphML reader → T2 SCC → T3 LCOM4 → T4 Betweenness (benchmark vs networkx) → T5 PyO3 bindings → T6 swap visiting tool's betweenness panel")
9//! @arch:see(DESIGN.md)
10//!
11//! @yah:ticket(R152-T1, "srcgraph-core: OwnedGraph + GraphML reader matching the visiting tool's schema")
12//! @yah:assignee(agent:claude)
13//! @yah:at(2026-05-12T22:14:22Z)
14//! @yah:status(review)
15//! @yah:parent(R152)
16//! @yah:verify("cargo test -p srcgraph-core graphml_roundtrip")
17//! @yah:handoff("Implemented OwnedClassNode + OwnedGraph + GraphML reader/writer in srcgraph-core. All attributes from the visiting-tool schema are parsed (name, namespace, lineCount, methodCount, halstead fields, methodConnectivity as typed MethodConnectivity, all semantic-mode JSON blobs as serde_json::Value). Writer uses CDATA for JSON blobs to avoid XML escaping issues. ClassNode trait extended with method_connectivity() defaulting to None.")
18//! @yah:verify("cargo test -p srcgraph-core graphml_roundtrip")
19
20pub mod graphml;
21pub mod owned;
22
23pub use graphml::{read_graphml, write_graphml, Error as GraphError};
24pub use owned::{OwnedClassNode, OwnedGraph};
25
26use serde::{Deserialize, Serialize};
27
28// ── traits ────────────────────────────────────────────────────────────────────
29
30/// Minimum interface the metric algorithms need from a graph node.
31///
32/// `method_connectivity` has a default impl returning `None` so that external
33/// node types (e.g. `kg-store`) can implement this trait without providing
34/// connectivity data until they need LCOM4.
35pub trait ClassNode {
36 fn id(&self) -> &str;
37 fn namespace(&self) -> &str;
38 fn line_count(&self) -> u32;
39 fn method_count(&self) -> u32;
40 fn method_connectivity(&self) -> Option<&MethodConnectivity> {
41 None
42 }
43 /// Raw `cyclomaticComplexity` JSON blob — shape
44 /// `{"methods": [{"name": str, "complexity": int}, …]}`.
45 /// Default `None` for nodes from extractors that omit it.
46 fn cyclomatic_complexity(&self) -> Option<&serde_json::Value> {
47 None
48 }
49 /// Halstead raw counts. Default 0 for nodes without the attribute — η = 0
50 /// short-circuits V to 0, so a missing-data class produces the same answer
51 /// as a class with no operators or operands.
52 fn halstead_eta1(&self) -> u32 {
53 0
54 }
55 fn halstead_eta2(&self) -> u32 {
56 0
57 }
58 fn halstead_n1(&self) -> u32 {
59 0
60 }
61 fn halstead_n2(&self) -> u32 {
62 0
63 }
64 /// Raw `methodTokens` JSON blob — shape
65 /// `{"methods": [{"name": str, "tokens": [str]}, …]}`.
66 fn method_tokens(&self) -> Option<&serde_json::Value> {
67 None
68 }
69 /// Raw `methodFingerprints` JSON blob — shape
70 /// `{"methods": [{"name": str, "tokens": "TOK TOK TOK", "line": int, …}, …]}`.
71 /// Distinct from `methodTokens`: fingerprint tokens are space-separated
72 /// normalised token-class strings used for clone detection.
73 fn method_fingerprints(&self) -> Option<&serde_json::Value> {
74 None
75 }
76 /// Raw `callSequences` JSON blob — shape
77 /// `{"sequences": [{"method": str, "calls": [str], …}, …]}`. Each sequence
78 /// is the ordered list of method names a single method invokes; used as a
79 /// transaction for association-rule mining and as a trace log for process
80 /// mining.
81 fn call_sequences(&self) -> Option<&serde_json::Value> {
82 None
83 }
84}
85
86/// Minimum interface the metric algorithms need from an edge weight.
87pub trait EdgeKind: Copy {
88 fn is_method_call(&self) -> bool;
89 fn is_field_ref(&self) -> bool;
90 fn is_inheritance(&self) -> bool;
91}
92
93// ── shared types ─────────────────────────────────────────────────────────────
94
95/// Which methods within a class share field accesses.
96///
97/// Stored as a JSON-encoded string in the `methodConnectivity` GraphML attribute.
98/// The `edges` field lists pairs of method names that touch at least one common field.
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct MethodConnectivity {
101 #[serde(default)]
102 pub methods: Vec<String>,
103 /// Method-name pairs that share field access (used to build the bipartite graph for LCOM4).
104 #[serde(default)]
105 pub edges: Vec<(String, String)>,
106}
107
108/// Edge kinds matching the GraphML schema emitted by the visiting-tool extractor.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum EdgeType {
111 MethodCall,
112 FieldRef,
113 Inheritance,
114}
115
116impl EdgeKind for EdgeType {
117 fn is_method_call(&self) -> bool {
118 matches!(self, EdgeType::MethodCall)
119 }
120 fn is_field_ref(&self) -> bool {
121 matches!(self, EdgeType::FieldRef)
122 }
123 fn is_inheritance(&self) -> bool {
124 matches!(self, EdgeType::Inheritance)
125 }
126}
127
128// ── tests ─────────────────────────────────────────────────────────────────────
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 static SMALL_GRAPHML: &str =
135 include_str!("../tests/fixtures/small.graphml");
136
137 #[test]
138 fn graphml_roundtrip() {
139 // ── first pass: read the fixture ──────────────────────────────────
140 let g1 = read_graphml(SMALL_GRAPHML).expect("read original graphml");
141
142 assert_eq!(g1.node_count(), 3, "expected 3 nodes");
143 assert_eq!(g1.edge_count(), 2, "expected 2 edges");
144
145 let alpha = g1
146 .node_weights()
147 .find(|n| n.id == "com.example.Alpha")
148 .expect("Alpha node");
149 assert_eq!(alpha.name, "Alpha");
150 assert_eq!(alpha.namespace, "com.example");
151 assert_eq!(alpha.line_count, 42);
152 assert_eq!(alpha.method_count, 3);
153
154 let mc = alpha.method_connectivity().expect("Alpha.methodConnectivity");
155 assert_eq!(mc.methods.len(), 3);
156 assert_eq!(mc.edges.len(), 1);
157 assert_eq!(mc.edges[0].0, "foo");
158 assert_eq!(mc.edges[0].1, "bar");
159
160 // ── second pass: write then re-read ───────────────────────────────
161 let written = write_graphml(&g1).expect("write graphml");
162 let g2 = read_graphml(&written).expect("read written graphml");
163
164 assert_eq!(g2.node_count(), 3);
165 assert_eq!(g2.edge_count(), 2);
166
167 let alpha2 = g2
168 .node_weights()
169 .find(|n| n.id == "com.example.Alpha")
170 .expect("Alpha node (round-trip)");
171 assert_eq!(alpha2.name, "Alpha");
172 assert_eq!(alpha2.line_count, 42);
173
174 let mc2 = alpha2.method_connectivity().expect("Alpha.methodConnectivity (round-trip)");
175 assert_eq!(mc2.methods, mc.methods);
176 assert_eq!(mc2.edges[0].0, "foo");
177
178 // Edge types preserved
179 let edge_types: Vec<EdgeType> = g2.edge_weights().copied().collect();
180 assert!(edge_types.contains(&EdgeType::MethodCall));
181 assert!(edge_types.contains(&EdgeType::Inheritance));
182 }
183}