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(skip_serializing_if = "Option::is_none")]
90    pub provenance: Option<String>,
91    #[serde(flatten)]
92    pub extra: HashMap<String, serde_json::Value>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Hyperedge {
97    pub nodes: Vec<String>,
98    pub relation: String,
99    pub label: String,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct ExtractionResult {
104    pub nodes: Vec<GraphNode>,
105    pub edges: Vec<GraphEdge>,
106    #[serde(default)]
107    pub hyperedges: Vec<Hyperedge>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CommunityInfo {
112    pub id: usize,
113    pub nodes: Vec<String>,
114    pub cohesion: f64,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub label: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct GodNode {
121    pub id: String,
122    pub label: String,
123    pub degree: usize,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub community: Option<usize>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Surprise {
130    pub source: String,
131    pub target: String,
132    pub source_community: usize,
133    pub target_community: usize,
134    pub relation: String,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct AnalysisResult {
139    pub god_nodes: Vec<GodNode>,
140    pub surprises: Vec<Surprise>,
141    pub questions: Vec<String>,
142}
143
144/// A node that bridges multiple communities.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct BridgeNode {
147    pub id: String,
148    pub label: String,
149    pub total_edges: usize,
150    pub cross_community_edges: usize,
151    /// Ratio of cross-community edges to total edges (0.0–1.0).
152    pub bridge_ratio: f64,
153    pub communities_touched: Vec<usize>,
154}
155
156/// PageRank importance score for a node.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct PageRankNode {
159    pub id: String,
160    pub label: String,
161    pub score: f64,
162    pub degree: usize,
163}
164
165/// A dependency cycle detected in the graph.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct DependencyCycle {
168    pub nodes: Vec<String>,
169    pub edges: Vec<(String, String)>,
170    /// Shorter cycles are more severe (1.0 / len).
171    pub severity: f64,
172}
173
174/// A node with temporal risk metrics from git history.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct TemporalNode {
177    pub id: String,
178    pub label: String,
179    pub last_modified: String,
180    pub change_count: usize,
181    pub age_days: u64,
182    pub churn_rate: f64,
183    pub risk_score: f64,
184}
185
186/// A pair of structurally similar nodes found via graph embedding.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SimilarPair {
189    pub node_a: String,
190    pub node_b: String,
191    pub similarity: f64,
192    pub label_a: String,
193    pub label_b: String,
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn sample_node() -> GraphNode {
201        GraphNode {
202            id: "my_class".into(),
203            label: "MyClass".into(),
204            source_file: "src/main.rs".into(),
205            source_location: Some("L42".into()),
206            node_type: NodeType::Class,
207            community: None,
208            extra: HashMap::new(),
209        }
210    }
211
212    fn sample_edge() -> GraphEdge {
213        GraphEdge {
214            source: "a".into(),
215            target: "b".into(),
216            relation: "calls".into(),
217            confidence: Confidence::Extracted,
218            confidence_score: 1.0,
219            source_file: "src/main.rs".into(),
220            source_location: None,
221            weight: 1.0,
222            provenance: None,
223            extra: HashMap::new(),
224        }
225    }
226
227    #[test]
228    fn node_type_serializes_lowercase() {
229        assert_eq!(
230            serde_json::to_string(&NodeType::Class).unwrap(),
231            r#""class""#
232        );
233        assert_eq!(
234            serde_json::to_string(&NodeType::Function).unwrap(),
235            r#""function""#
236        );
237        assert_eq!(
238            serde_json::to_string(&NodeType::Namespace).unwrap(),
239            r#""namespace""#
240        );
241    }
242
243    #[test]
244    fn node_roundtrip() {
245        let node = sample_node();
246        let json = serde_json::to_string(&node).unwrap();
247        let back: GraphNode = serde_json::from_str(&json).unwrap();
248        assert_eq!(back.id, "my_class");
249        assert_eq!(back.node_type, NodeType::Class);
250    }
251
252    #[test]
253    fn node_skip_none_fields() {
254        let mut node = sample_node();
255        node.source_location = None;
256        node.community = None;
257        let json = serde_json::to_string(&node).unwrap();
258        assert!(!json.contains("source_location"));
259        assert!(!json.contains("community"));
260    }
261
262    #[test]
263    fn edge_defaults() {
264        let json = r#"{
265            "source": "a",
266            "target": "b",
267            "relation": "calls",
268            "confidence": "EXTRACTED",
269            "source_file": "x.rs"
270        }"#;
271        let edge: GraphEdge = serde_json::from_str(json).unwrap();
272        assert!((edge.confidence_score - 1.0).abs() < f64::EPSILON);
273        assert!((edge.weight - 1.0).abs() < f64::EPSILON);
274    }
275
276    #[test]
277    fn edge_roundtrip() {
278        let edge = sample_edge();
279        let json = serde_json::to_string(&edge).unwrap();
280        let back: GraphEdge = serde_json::from_str(&json).unwrap();
281        assert_eq!(back.relation, "calls");
282        assert_eq!(back.confidence, Confidence::Extracted);
283    }
284
285    #[test]
286    fn extraction_result_default() {
287        let r = ExtractionResult::default();
288        assert!(r.nodes.is_empty());
289        assert!(r.edges.is_empty());
290        assert!(r.hyperedges.is_empty());
291    }
292
293    #[test]
294    fn extra_fields_flatten() {
295        let mut node = sample_node();
296        node.extra
297            .insert("custom".into(), serde_json::Value::Bool(true));
298        let json = serde_json::to_string(&node).unwrap();
299        assert!(json.contains(r#""custom":true"#));
300    }
301
302    #[test]
303    fn community_info_roundtrip() {
304        let ci = CommunityInfo {
305            id: 0,
306            nodes: vec!["a".into(), "b".into()],
307            cohesion: 0.85,
308            label: Some("cluster-0".into()),
309        };
310        let json = serde_json::to_string(&ci).unwrap();
311        let back: CommunityInfo = serde_json::from_str(&json).unwrap();
312        assert_eq!(back.id, 0);
313        assert_eq!(back.nodes.len(), 2);
314    }
315
316    #[test]
317    fn analysis_result_default() {
318        let ar = AnalysisResult::default();
319        assert!(ar.god_nodes.is_empty());
320        assert!(ar.surprises.is_empty());
321        assert!(ar.questions.is_empty());
322    }
323}