Skip to main content

graphify_core/
model.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::confidence::Confidence;
6
7/// The kind of entity a graph node represents.
8///
9/// Serialized as lowercase strings (e.g. `"class"`, `"function"`).
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum NodeType {
13    Class,
14    Function,
15    Module,
16    Concept,
17    Paper,
18    Image,
19    File,
20    Method,
21    Interface,
22    Enum,
23    Struct,
24    Trait,
25    Constant,
26    Variable,
27    Package,
28    Namespace,
29}
30
31impl std::fmt::Display for NodeType {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            NodeType::Class => write!(f, "Class"),
35            NodeType::Function => write!(f, "Function"),
36            NodeType::Module => write!(f, "Module"),
37            NodeType::Concept => write!(f, "Concept"),
38            NodeType::Paper => write!(f, "Paper"),
39            NodeType::Image => write!(f, "Image"),
40            NodeType::File => write!(f, "File"),
41            NodeType::Method => write!(f, "Method"),
42            NodeType::Interface => write!(f, "Interface"),
43            NodeType::Enum => write!(f, "Enum"),
44            NodeType::Struct => write!(f, "Struct"),
45            NodeType::Trait => write!(f, "Trait"),
46            NodeType::Constant => write!(f, "Constant"),
47            NodeType::Variable => write!(f, "Variable"),
48            NodeType::Package => write!(f, "Package"),
49            NodeType::Namespace => write!(f, "Namespace"),
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct GraphNode {
56    pub id: String,
57    pub label: String,
58    pub source_file: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub source_location: Option<String>,
61    pub node_type: NodeType,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub community: Option<usize>,
64    #[serde(flatten)]
65    pub extra: HashMap<String, serde_json::Value>,
66}
67
68fn default_confidence_score() -> f64 {
69    1.0
70}
71
72fn default_weight() -> f64 {
73    1.0
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct GraphEdge {
78    pub source: String,
79    pub target: String,
80    pub relation: String,
81    pub confidence: Confidence,
82    #[serde(default = "default_confidence_score")]
83    pub confidence_score: f64,
84    pub source_file: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub source_location: Option<String>,
87    #[serde(default = "default_weight")]
88    pub weight: f64,
89    #[serde(flatten)]
90    pub extra: HashMap<String, serde_json::Value>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Hyperedge {
95    pub nodes: Vec<String>,
96    pub relation: String,
97    pub label: String,
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct ExtractionResult {
102    pub nodes: Vec<GraphNode>,
103    pub edges: Vec<GraphEdge>,
104    #[serde(default)]
105    pub hyperedges: Vec<Hyperedge>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CommunityInfo {
110    pub id: usize,
111    pub nodes: Vec<String>,
112    pub cohesion: f64,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub label: Option<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct GodNode {
119    pub id: String,
120    pub label: String,
121    pub degree: usize,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub community: Option<usize>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Surprise {
128    pub source: String,
129    pub target: String,
130    pub source_community: usize,
131    pub target_community: usize,
132    pub relation: String,
133}
134
135#[derive(Debug, Clone, Default, Serialize, Deserialize)]
136pub struct AnalysisResult {
137    pub god_nodes: Vec<GodNode>,
138    pub surprises: Vec<Surprise>,
139    pub questions: Vec<String>,
140}
141
142/// A node that bridges multiple communities.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct BridgeNode {
145    pub id: String,
146    pub label: String,
147    pub total_edges: usize,
148    pub cross_community_edges: usize,
149    /// Ratio of cross-community edges to total edges (0.0–1.0).
150    pub bridge_ratio: f64,
151    pub communities_touched: Vec<usize>,
152}
153
154/// PageRank importance score for a node.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct PageRankNode {
157    pub id: String,
158    pub label: String,
159    pub score: f64,
160    pub degree: usize,
161}
162
163/// A dependency cycle detected in the graph.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct DependencyCycle {
166    pub nodes: Vec<String>,
167    pub edges: Vec<(String, String)>,
168    /// Shorter cycles are more severe (1.0 / len).
169    pub severity: f64,
170}
171
172/// A node with temporal risk metrics from git history.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct TemporalNode {
175    pub id: String,
176    pub label: String,
177    pub last_modified: String,
178    pub change_count: usize,
179    pub age_days: u64,
180    pub churn_rate: f64,
181    pub risk_score: f64,
182}
183
184/// A pair of structurally similar nodes found via graph embedding.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SimilarPair {
187    pub node_a: String,
188    pub node_b: String,
189    pub similarity: f64,
190    pub label_a: String,
191    pub label_b: String,
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn sample_node() -> GraphNode {
199        GraphNode {
200            id: "my_class".into(),
201            label: "MyClass".into(),
202            source_file: "src/main.rs".into(),
203            source_location: Some("L42".into()),
204            node_type: NodeType::Class,
205            community: None,
206            extra: HashMap::new(),
207        }
208    }
209
210    fn sample_edge() -> GraphEdge {
211        GraphEdge {
212            source: "a".into(),
213            target: "b".into(),
214            relation: "calls".into(),
215            confidence: Confidence::Extracted,
216            confidence_score: 1.0,
217            source_file: "src/main.rs".into(),
218            source_location: None,
219            weight: 1.0,
220            extra: HashMap::new(),
221        }
222    }
223
224    #[test]
225    fn node_type_serializes_lowercase() {
226        assert_eq!(
227            serde_json::to_string(&NodeType::Class).unwrap(),
228            r#""class""#
229        );
230        assert_eq!(
231            serde_json::to_string(&NodeType::Function).unwrap(),
232            r#""function""#
233        );
234        assert_eq!(
235            serde_json::to_string(&NodeType::Namespace).unwrap(),
236            r#""namespace""#
237        );
238    }
239
240    #[test]
241    fn node_roundtrip() {
242        let node = sample_node();
243        let json = serde_json::to_string(&node).unwrap();
244        let back: GraphNode = serde_json::from_str(&json).unwrap();
245        assert_eq!(back.id, "my_class");
246        assert_eq!(back.node_type, NodeType::Class);
247    }
248
249    #[test]
250    fn node_skip_none_fields() {
251        let mut node = sample_node();
252        node.source_location = None;
253        node.community = None;
254        let json = serde_json::to_string(&node).unwrap();
255        assert!(!json.contains("source_location"));
256        assert!(!json.contains("community"));
257    }
258
259    #[test]
260    fn edge_defaults() {
261        let json = r#"{
262            "source": "a",
263            "target": "b",
264            "relation": "calls",
265            "confidence": "EXTRACTED",
266            "source_file": "x.rs"
267        }"#;
268        let edge: GraphEdge = serde_json::from_str(json).unwrap();
269        assert!((edge.confidence_score - 1.0).abs() < f64::EPSILON);
270        assert!((edge.weight - 1.0).abs() < f64::EPSILON);
271    }
272
273    #[test]
274    fn edge_roundtrip() {
275        let edge = sample_edge();
276        let json = serde_json::to_string(&edge).unwrap();
277        let back: GraphEdge = serde_json::from_str(&json).unwrap();
278        assert_eq!(back.relation, "calls");
279        assert_eq!(back.confidence, Confidence::Extracted);
280    }
281
282    #[test]
283    fn extraction_result_default() {
284        let r = ExtractionResult::default();
285        assert!(r.nodes.is_empty());
286        assert!(r.edges.is_empty());
287        assert!(r.hyperedges.is_empty());
288    }
289
290    #[test]
291    fn extra_fields_flatten() {
292        let mut node = sample_node();
293        node.extra
294            .insert("custom".into(), serde_json::Value::Bool(true));
295        let json = serde_json::to_string(&node).unwrap();
296        assert!(json.contains(r#""custom":true"#));
297    }
298
299    #[test]
300    fn community_info_roundtrip() {
301        let ci = CommunityInfo {
302            id: 0,
303            nodes: vec!["a".into(), "b".into()],
304            cohesion: 0.85,
305            label: Some("cluster-0".into()),
306        };
307        let json = serde_json::to_string(&ci).unwrap();
308        let back: CommunityInfo = serde_json::from_str(&json).unwrap();
309        assert_eq!(back.id, 0);
310        assert_eq!(back.nodes.len(), 2);
311    }
312
313    #[test]
314    fn analysis_result_default() {
315        let ar = AnalysisResult::default();
316        assert!(ar.god_nodes.is_empty());
317        assert!(ar.surprises.is_empty());
318        assert!(ar.questions.is_empty());
319    }
320}