1use crate::schema::{edge_col, node_col};
8use arrow::array::{Array, RecordBatch, StringArray};
9use std::sync::Arc;
10
11#[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
26pub fn rename_node(
35 nodes_batch: &RecordBatch,
36 edges_batch: &RecordBatch,
37 old_id: &str,
38 new_id: &str,
39) -> Result<(RecordBatch, RecordBatch)> {
40 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 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 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
65fn 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
123fn 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 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 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 let parent_ids = new_nodes
289 .column(node_col::PARENT_ID)
290 .as_any()
291 .downcast_ref::<StringArray>()
292 .unwrap();
293 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 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", );
365 assert!(result.is_err());
366 }
367}