Skip to main content

gobby_code/graph/code_graph/
payload.rs

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