Skip to main content

nusy_codegraph/
rename.rs

1//! Graph-native rename — rename a code object, all edges follow.
2//!
3//! Renames a CodeNode ID across nodes and edges batches. All references
4//! (parent_id, edge source_id, edge target_id) are updated atomically.
5//! Returns new batches — originals are not mutated.
6
7use crate::schema::{edge_col, node_col};
8use arrow::array::{Array, RecordBatch, StringArray};
9use std::sync::Arc;
10
11/// Errors from rename operations.
12#[derive(Debug, thiserror::Error)]
13pub enum RenameError {
14    #[error("Node not found: {0}")]
15    NodeNotFound(String),
16
17    #[error("Target ID already exists: {0}")]
18    TargetExists(String),
19
20    #[error("Arrow error: {0}")]
21    Arrow(#[from] arrow::error::ArrowError),
22}
23
24pub type Result<T> = std::result::Result<T, RenameError>;
25
26/// Rename a CodeNode, updating all references in nodes and edges batches.
27///
28/// Updates:
29/// - The node's own `id` field
30/// - Any `parent_id` references pointing to the old ID
31/// - Any `source_id` or `target_id` in edges pointing to the old ID
32///
33/// Returns `(new_nodes_batch, new_edges_batch)`.
34pub fn rename_node(
35    nodes_batch: &RecordBatch,
36    edges_batch: &RecordBatch,
37    old_id: &str,
38    new_id: &str,
39) -> Result<(RecordBatch, RecordBatch)> {
40    // Verify old_id exists
41    let ids = nodes_batch
42        .column(node_col::ID)
43        .as_any()
44        .downcast_ref::<StringArray>()
45        .expect("id column");
46
47    let found = (0..nodes_batch.num_rows()).any(|i| ids.value(i) == old_id);
48    if !found {
49        return Err(RenameError::NodeNotFound(old_id.to_string()));
50    }
51
52    // Verify new_id doesn't already exist
53    let conflict = (0..nodes_batch.num_rows()).any(|i| ids.value(i) == new_id);
54    if conflict {
55        return Err(RenameError::TargetExists(new_id.to_string()));
56    }
57
58    // Rebuild nodes batch
59    let new_nodes = rename_in_nodes(nodes_batch, old_id, new_id)?;
60    let new_edges = rename_in_edges(edges_batch, old_id, new_id)?;
61
62    Ok((new_nodes, new_edges))
63}
64
65/// Rename references in the nodes batch (id + parent_id columns).
66fn rename_in_nodes(batch: &RecordBatch, old_id: &str, new_id: &str) -> Result<RecordBatch> {
67    let mut columns: Vec<arrow::array::ArrayRef> = Vec::with_capacity(batch.num_columns());
68
69    for col_idx in 0..batch.num_columns() {
70        match col_idx {
71            node_col::ID => {
72                let old = batch
73                    .column(col_idx)
74                    .as_any()
75                    .downcast_ref::<StringArray>()
76                    .expect("id");
77                let vals: Vec<String> = (0..batch.num_rows())
78                    .map(|i| {
79                        let v = old.value(i);
80                        if v == old_id {
81                            new_id.to_string()
82                        } else {
83                            v.to_string()
84                        }
85                    })
86                    .collect();
87                let refs: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
88                columns.push(Arc::new(StringArray::from(refs)));
89            }
90            node_col::PARENT_ID => {
91                let old = batch
92                    .column(col_idx)
93                    .as_any()
94                    .downcast_ref::<StringArray>()
95                    .expect("parent_id");
96                let vals: Vec<Option<String>> = (0..batch.num_rows())
97                    .map(|i| {
98                        if old.is_null(i) {
99                            None
100                        } else {
101                            let v = old.value(i);
102                            if v == old_id {
103                                Some(new_id.to_string())
104                            } else {
105                                Some(v.to_string())
106                            }
107                        }
108                    })
109                    .collect();
110                let refs: Vec<Option<&str>> = vals.iter().map(|s| s.as_deref()).collect();
111                columns.push(Arc::new(StringArray::from(refs)));
112            }
113            _ => {
114                columns.push(batch.column(col_idx).clone());
115            }
116        }
117    }
118
119    let schema = Arc::new(batch.schema().as_ref().clone());
120    Ok(RecordBatch::try_new(schema, columns)?)
121}
122
123/// Rename references in the edges batch (source_id + target_id columns).
124fn rename_in_edges(batch: &RecordBatch, old_id: &str, new_id: &str) -> Result<RecordBatch> {
125    if batch.num_rows() == 0 {
126        return Ok(batch.clone());
127    }
128
129    let mut columns: Vec<arrow::array::ArrayRef> = Vec::with_capacity(batch.num_columns());
130
131    for col_idx in 0..batch.num_columns() {
132        match col_idx {
133            edge_col::SOURCE_ID | edge_col::TARGET_ID => {
134                let old = batch
135                    .column(col_idx)
136                    .as_any()
137                    .downcast_ref::<StringArray>()
138                    .expect("edge string column");
139                let vals: Vec<String> = (0..batch.num_rows())
140                    .map(|i| {
141                        let v = old.value(i);
142                        if v == old_id {
143                            new_id.to_string()
144                        } else {
145                            v.to_string()
146                        }
147                    })
148                    .collect();
149                let refs: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
150                columns.push(Arc::new(StringArray::from(refs)));
151            }
152            _ => {
153                columns.push(batch.column(col_idx).clone());
154            }
155        }
156    }
157
158    let schema = Arc::new(batch.schema().as_ref().clone());
159    Ok(RecordBatch::try_new(schema, columns)?)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::mcp_tools::{QueryFilter, codegraph_query_objects};
166    use crate::schema::{
167        CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
168        build_code_nodes_batch,
169    };
170
171    fn sample_nodes() -> Vec<CodeNode> {
172        vec![
173            CodeNode {
174                id: "class:brain/store.py::DualStore".into(),
175                kind: CodeNodeKind::Class,
176                parent_id: Some("mod:brain/store.py".into()),
177                name: "DualStore".into(),
178                signature: None,
179                docstring: Some("Dual store.".into()),
180                body_hash: Some("ds_h".into()),
181                body: None,
182                loc: Some(100),
183                cyclomatic_complexity: None,
184                coverage_pct: None,
185                last_modified: None,
186                ..Default::default()
187            },
188            CodeNode {
189                id: "func:brain/store.py::DualStore::promote".into(),
190                kind: CodeNodeKind::Method,
191                parent_id: Some("class:brain/store.py::DualStore".into()),
192                name: "promote".into(),
193                signature: Some("def promote(self)".into()),
194                docstring: None,
195                body_hash: Some("prom_h".into()),
196                body: None,
197                loc: Some(20),
198                cyclomatic_complexity: Some(3),
199                coverage_pct: None,
200                last_modified: None,
201                ..Default::default()
202            },
203            CodeNode {
204                id: "func:brain/signal.py::fuse".into(),
205                kind: CodeNodeKind::Function,
206                parent_id: None,
207                name: "fuse".into(),
208                signature: Some("def fuse(signals)".into()),
209                docstring: None,
210                body_hash: Some("fuse_h".into()),
211                body: None,
212                loc: Some(30),
213                cyclomatic_complexity: Some(5),
214                coverage_pct: None,
215                last_modified: None,
216                ..Default::default()
217            },
218        ]
219    }
220
221    fn sample_edges() -> Vec<CodeEdge> {
222        vec![
223            CodeEdge {
224                source_id: "func:brain/signal.py::fuse".into(),
225                target_id: "func:brain/store.py::DualStore::promote".into(),
226                predicate: CodeEdgePredicate::Calls,
227                weight: Some(1.0),
228                commit_id: None,
229            },
230            CodeEdge {
231                source_id: "class:brain/store.py::DualStore".into(),
232                target_id: "func:brain/store.py::DualStore::promote".into(),
233                predicate: CodeEdgePredicate::Contains,
234                weight: None,
235                commit_id: None,
236            },
237        ]
238    }
239
240    #[test]
241    fn test_rename_function() {
242        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
243        let edges = build_code_edges_batch(&sample_edges()).unwrap();
244
245        let (new_nodes, new_edges) = rename_node(
246            &nodes,
247            &edges,
248            "func:brain/signal.py::fuse",
249            "func:brain/signal.py::fuse_signals",
250        )
251        .unwrap();
252
253        // Old ID gone, new ID exists
254        let old_result = codegraph_query_objects(
255            &new_nodes,
256            &QueryFilter {
257                name_contains: Some("fuse".into()),
258                ..Default::default()
259            },
260        )
261        .unwrap();
262        assert_eq!(old_result.nodes.len(), 1);
263        assert_eq!(old_result.nodes[0].id, "func:brain/signal.py::fuse_signals");
264
265        // Edge source updated
266        let sources = new_edges
267            .column(edge_col::SOURCE_ID)
268            .as_any()
269            .downcast_ref::<StringArray>()
270            .unwrap();
271        assert_eq!(sources.value(0), "func:brain/signal.py::fuse_signals");
272    }
273
274    #[test]
275    fn test_rename_class_updates_child_parent() {
276        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
277        let edges = build_code_edges_batch(&sample_edges()).unwrap();
278
279        let (new_nodes, _) = rename_node(
280            &nodes,
281            &edges,
282            "class:brain/store.py::DualStore",
283            "class:brain/store.py::GraphStore",
284        )
285        .unwrap();
286
287        // Child's parent_id should be updated
288        let parent_ids = new_nodes
289            .column(node_col::PARENT_ID)
290            .as_any()
291            .downcast_ref::<StringArray>()
292            .unwrap();
293        // promote is row 1, parent_id should now be GraphStore
294        assert_eq!(parent_ids.value(1), "class:brain/store.py::GraphStore");
295    }
296
297    #[test]
298    fn test_rename_updates_edge_target() {
299        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
300        let edges = build_code_edges_batch(&sample_edges()).unwrap();
301
302        let (_, new_edges) = rename_node(
303            &nodes,
304            &edges,
305            "func:brain/store.py::DualStore::promote",
306            "func:brain/store.py::DualStore::elevate",
307        )
308        .unwrap();
309
310        // Both edges target promote — should be updated
311        let targets = new_edges
312            .column(edge_col::TARGET_ID)
313            .as_any()
314            .downcast_ref::<StringArray>()
315            .unwrap();
316        assert_eq!(targets.value(0), "func:brain/store.py::DualStore::elevate");
317        assert_eq!(targets.value(1), "func:brain/store.py::DualStore::elevate");
318    }
319
320    #[test]
321    fn test_rename_preserves_unaffected_nodes() {
322        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
323        let edges = build_code_edges_batch(&sample_edges()).unwrap();
324
325        let (new_nodes, _) = rename_node(
326            &nodes,
327            &edges,
328            "func:brain/signal.py::fuse",
329            "func:brain/signal.py::fuse2",
330        )
331        .unwrap();
332
333        assert_eq!(new_nodes.num_rows(), 3);
334        let result = codegraph_query_objects(
335            &new_nodes,
336            &QueryFilter {
337                name_contains: Some("DualStore".into()),
338                ..Default::default()
339            },
340        )
341        .unwrap();
342        assert_eq!(result.nodes[0].docstring, Some("Dual store.".into()));
343    }
344
345    #[test]
346    fn test_rename_nonexistent_node_errors() {
347        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
348        let edges = build_code_edges_batch(&sample_edges()).unwrap();
349
350        let result = rename_node(&nodes, &edges, "func:nonexistent::foo", "func:new::foo");
351        assert!(result.is_err());
352    }
353
354    #[test]
355    fn test_rename_to_existing_id_errors() {
356        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
357        let edges = build_code_edges_batch(&sample_edges()).unwrap();
358
359        let result = rename_node(
360            &nodes,
361            &edges,
362            "func:brain/signal.py::fuse",
363            "class:brain/store.py::DualStore", // already exists
364        );
365        assert!(result.is_err());
366    }
367}