Skip to main content

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}