gobby_code/graph/code_graph/
payload.rs1use std::collections::HashSet;
2
3use gobby_core::graph_analytics::{AnalyticsEdge, AnalyticsGraph, AnalyticsNode, weight_for_kind};
4use serde::{Deserialize, Serialize};
5
6use crate::models::{ProjectionMetadata, ProjectionProvenance};
7use gobby_core::falkor::Row;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct GraphPayload {
11 nodes: Vec<GraphNode>,
12 pub links: Vec<GraphLink>,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub center: Option<String>,
15 #[serde(skip)]
16 node_ids: HashSet<String>,
17 #[serde(skip)]
18 node_cache_ready: bool,
19}
20
21impl GraphPayload {
22 pub fn with_center(center: impl Into<String>) -> Self {
23 Self {
24 nodes: vec![],
25 links: vec![],
26 center: Some(center.into()),
27 node_ids: HashSet::new(),
28 node_cache_ready: false,
29 }
30 }
31
32 pub fn push_node(&mut self, node: GraphNode) {
33 if node.id.is_empty() {
34 return;
35 }
36 if !self.node_cache_ready {
37 self.refresh_node_cache();
38 }
39 if !self.node_ids.insert(node.id.clone()) {
40 return;
41 }
42 self.nodes.push(node);
43 }
44
45 pub fn nodes(&self) -> &[GraphNode] {
46 &self.nodes
47 }
48
49 pub fn node_count(&self) -> usize {
50 self.nodes.len()
51 }
52
53 pub(crate) fn analytics_graph_from_parts(
54 nodes: impl IntoIterator<Item = (String, String, f64)>,
55 edges: impl IntoIterator<Item = (String, String, String)>,
56 ) -> AnalyticsGraph {
57 AnalyticsGraph {
58 nodes: nodes
59 .into_iter()
60 .map(|(id, kind, weight)| AnalyticsNode { id, kind, weight })
61 .collect(),
62 edges: edges
63 .into_iter()
64 .map(|(source, target, kind)| {
65 let weight = weight_for_kind(&kind);
66 AnalyticsEdge {
67 source,
68 target,
69 kind,
70 weight,
71 }
72 })
73 .collect(),
74 }
75 }
76
77 fn refresh_node_cache(&mut self) {
78 self.node_ids = self
79 .nodes
80 .iter()
81 .filter(|node| !node.id.is_empty())
82 .map(|node| node.id.clone())
83 .collect::<HashSet<_>>();
84 self.node_cache_ready = true;
85 }
86}
87
88impl PartialEq for GraphPayload {
89 fn eq(&self, other: &Self) -> bool {
90 self.nodes == other.nodes && self.links == other.links && self.center == other.center
91 }
92}
93
94impl From<&GraphPayload> for AnalyticsGraph {
95 fn from(payload: &GraphPayload) -> Self {
96 GraphPayload::analytics_graph_from_parts(
97 payload.nodes().iter().map(|node| {
98 (
99 node.id.clone(),
100 node.node_type.clone(),
101 analytics_node_weight(node.symbol_count),
102 )
103 }),
104 payload.links.iter().map(|link| {
105 (
106 link.source.clone(),
107 link.target.clone(),
108 link.link_type.clone(),
109 )
110 }),
111 )
112 }
113}
114
115fn analytics_node_weight(symbol_count: Option<usize>) -> f64 {
116 symbol_count.map(|count| count.max(1) as f64).unwrap_or(1.0)
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct GraphNode {
121 pub id: String,
122 pub name: String,
123 #[serde(rename = "type")]
124 pub node_type: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub kind: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub file_path: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub line_start: Option<usize>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub signature: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub symbol_count: Option<usize>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub language: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub blast_distance: Option<usize>,
139}
140
141impl GraphNode {
142 pub fn new(
143 id: impl Into<String>,
144 name: impl Into<String>,
145 node_type: impl Into<String>,
146 ) -> Self {
147 Self {
148 id: id.into(),
149 name: name.into(),
150 node_type: node_type.into(),
151 kind: None,
152 file_path: None,
153 line_start: None,
154 signature: None,
155 symbol_count: None,
156 language: None,
157 blast_distance: None,
158 }
159 }
160
161 pub(super) fn from_row(row: &Row, default_type: &str) -> Option<Self> {
166 let id = row_string_owned(row, &["id", "node_id"])?;
167 let mut node = Self::new(
168 id.clone(),
169 row_string_owned(row, &["name", "node_name"]).unwrap_or(id),
170 row_string_owned(row, &["type", "node_type"])
171 .unwrap_or_else(|| default_type.to_string()),
172 );
173 node.kind = row_string_owned(row, &["kind"]);
174 node.file_path = row_string_owned(row, &["file_path"]);
175 node.line_start = row_usize(row, &["line_start", "line"]);
176 node.signature = row_string_owned(row, &["signature"]);
177 node.symbol_count = row_usize(row, &["symbol_count"]);
178 node.language = row_string_owned(row, &["language"]);
179 node.blast_distance = row_usize(row, &["blast_distance", "distance"]);
180 Some(node)
181 }
182
183 pub(super) fn from_prefixed_row(row: &Row, prefix: &str, default_type: &str) -> Option<Self> {
184 let id_key = format!("{prefix}_id");
185 let name_key = format!("{prefix}_name");
186 let type_key = format!("{prefix}_type");
187 let kind_key = format!("{prefix}_kind");
188 let file_path_key = format!("{prefix}_file_path");
189 let line_start_key = format!("{prefix}_line_start");
190 let signature_key = format!("{prefix}_signature");
191
192 let id = row_string_owned(row, &[id_key.as_str()])?;
193 let mut node = Self::new(
194 id.clone(),
195 row_string_owned(row, &[name_key.as_str()]).unwrap_or(id),
196 row_string_owned(row, &[type_key.as_str()]).unwrap_or_else(|| default_type.to_string()),
197 );
198 node.kind = row_string_owned(row, &[kind_key.as_str()]);
199 node.file_path = row_string_owned(row, &[file_path_key.as_str()]);
200 node.line_start = row_usize(row, &[line_start_key.as_str()]);
201 node.signature = row_string_owned(row, &[signature_key.as_str()]);
202 Some(node)
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct GraphLink {
208 pub source: String,
209 pub target: String,
210 #[serde(rename = "type")]
211 pub link_type: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub line: Option<usize>,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub distance: Option<usize>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub metadata: Option<ProjectionMetadata>,
218}
219
220impl GraphLink {
221 pub fn new(
222 source: impl Into<String>,
223 target: impl Into<String>,
224 link_type: impl Into<String>,
225 ) -> Self {
226 Self {
227 source: source.into(),
228 target: target.into(),
229 link_type: link_type.into(),
230 line: None,
231 distance: None,
232 metadata: None,
233 }
234 }
235
236 pub fn from_row(row: &Row) -> Option<Self> {
237 let mut link = Self::new(
238 row_string_owned(row, &["source"])?,
239 row_string_owned(row, &["target"])?,
240 row_string_owned(row, &["type", "rel_type"]).unwrap_or_else(|| "CALLS".to_string()),
241 );
242 link.line = row_usize(row, &["line"]);
243 link.distance = row_usize(row, &["distance"]);
244 link.metadata = row_to_projection_metadata(row);
245 Some(link)
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum GraphBlastRadiusTarget {
251 SymbolId(String),
252 FilePath(String),
253}
254pub fn extracted_code_edge_metadata(
255 file_path: impl Into<String>,
256 line: usize,
257 source_symbol_id: Option<&str>,
258) -> ProjectionMetadata {
259 let mut metadata = ProjectionMetadata::gcode_extracted()
260 .with_source_file_path(file_path)
261 .with_source_line(line);
262 if let Some(source_symbol_id) = source_symbol_id {
263 metadata = metadata.with_source_symbol_id(source_symbol_id);
264 }
265 metadata
266}
267
268pub(super) fn row_to_projection_metadata(row: &Row) -> Option<ProjectionMetadata> {
269 let provenance = row
270 .get("provenance")
271 .and_then(|v| v.as_str())
272 .and_then(ProjectionProvenance::from_wire_value)?;
273 let source_system = row.get("source_system").and_then(|v| v.as_str())?;
274
275 let mut metadata = ProjectionMetadata::new(provenance, source_system);
276 metadata.confidence = row.get("confidence").and_then(|v| v.as_f64());
277 metadata.source_file_path = row_string_owned(row, &["metadata_source_file_path"]);
278 metadata.source_line = row
279 .get("source_line")
280 .or_else(|| row.get("line"))
281 .and_then(|v| v.as_u64())
282 .and_then(|line| usize::try_from(line).ok());
283 metadata.source_symbol_id = row
284 .get("source_symbol_id")
285 .or_else(|| row.get("caller_id"))
286 .or_else(|| row.get("source_id"))
287 .and_then(|v| v.as_str())
288 .map(ToOwned::to_owned);
289 metadata.matching_method = row
290 .get("matching_method")
291 .and_then(|v| v.as_str())
292 .map(ToOwned::to_owned);
293 Some(metadata)
294}
295
296pub(super) fn row_string_owned(row: &Row, keys: &[&str]) -> Option<String> {
297 keys.iter()
298 .find_map(|key| row.get(*key).and_then(|value| value.as_str()))
299 .filter(|value| !value.is_empty())
300 .map(ToOwned::to_owned)
301}
302
303pub(super) fn row_usize(row: &Row, keys: &[&str]) -> Option<usize> {
304 for key in keys {
305 let Some(value) = row.get(*key) else {
306 continue;
307 };
308 if let Some(value) = value.as_u64() {
309 return usize::try_from(value).ok();
310 }
311 if let Some(value) = value.as_i64() {
312 if let Ok(value) = usize::try_from(value) {
313 return Some(value);
314 }
315 log::warn!("negative graph payload integer ignored; key={key} value={value}");
316 return None;
317 }
318 }
319 None
320}
321
322pub(super) fn add_link_from_row(payload: &mut GraphPayload, row: &Row) {
323 if let Some(link) = GraphLink::from_row(row) {
324 payload.links.push(link);
325 }
326}
327
328pub(super) fn add_node_from_row(payload: &mut GraphPayload, row: &Row, default_type: &str) {
329 if let Some(node) = GraphNode::from_row(row, default_type) {
330 payload.push_node(node);
331 }
332}
333
334pub(super) fn add_prefixed_node_from_row(
335 payload: &mut GraphPayload,
336 row: &Row,
337 prefix: &str,
338 default_type: &str,
339) {
340 if let Some(node) = GraphNode::from_prefixed_row(row, prefix, default_type) {
341 payload.push_node(node);
342 }
343}