Skip to main content

gobby_code/graph/code_graph/
payload.rs

1use 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    /// Builds a node from unprefixed Falkor rows.
162    ///
163    /// Fallback key priority is id: `id`, `node_id`; name: `name`,
164    /// `node_name`, then id; type: `type`, `node_type`, then `default_type`.
165    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}