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