Skip to main content

codelens_engine/
move_symbol.rs

1use crate::edit_transaction::{ApplyEvidence, apply_full_writes_with_evidence};
2use crate::project::ProjectRoot;
3use crate::rename::{RenameEdit, apply_edits, find_all_word_matches};
4use crate::symbols::{find_symbol, find_symbol_range};
5use anyhow::{Result, bail};
6use serde::Serialize;
7use std::fs;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct MoveResult {
11    pub success: bool,
12    pub message: String,
13    pub source_file: String,
14    pub target_file: String,
15    pub symbol_name: String,
16    pub import_updates: usize,
17    pub edits: Vec<MoveEdit>,
18    /// G7b — `ApplyEvidence` for the source+target atomic transaction.
19    /// `None` on dry runs (no on-disk write attempted) and on the
20    /// pre-substrate validation failures that bail before the
21    /// transaction begins.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub apply_evidence: Option<ApplyEvidence>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct MoveEdit {
28    pub file_path: String,
29    pub action: MoveAction,
30    pub content: String,
31}
32
33#[derive(Debug, Clone, Serialize)]
34#[serde(rename_all = "snake_case")]
35pub enum MoveAction {
36    RemoveFromSource,
37    AddToTarget,
38    UpdateImport,
39}
40
41/// Move a symbol from one file to another, updating imports across the project.
42pub fn move_symbol(
43    project: &ProjectRoot,
44    file_path: &str,
45    symbol_name: &str,
46    name_path: Option<&str>,
47    target_file: &str,
48    dry_run: bool,
49) -> Result<MoveResult> {
50    if file_path == target_file {
51        bail!("Source and target files are the same");
52    }
53
54    // 1. Find the symbol
55    let symbols = find_symbol(project, symbol_name, Some(file_path), true, true, 1)?;
56    let _sym = symbols
57        .first()
58        .ok_or_else(|| anyhow::anyhow!("Symbol '{}' not found in '{}'", symbol_name, file_path))?;
59
60    // 2. Extract the full symbol text
61    let resolved_source = project.resolve(file_path)?;
62    let source_content = fs::read_to_string(&resolved_source)?;
63    let (start_byte, end_byte) = find_symbol_range(project, file_path, symbol_name, name_path)?;
64    let symbol_text = source_content[start_byte..end_byte].to_string();
65
66    // 3. Determine the line range of the symbol for removal
67    let start_line = source_content[..start_byte].lines().count();
68    let end_line = source_content[..end_byte].lines().count();
69
70    // 4. Build edits
71    let mut edits = Vec::new();
72
73    // Edit 1: Remove from source
74    edits.push(MoveEdit {
75        file_path: file_path.to_string(),
76        action: MoveAction::RemoveFromSource,
77        content: symbol_text.clone(),
78    });
79
80    // Edit 2: Add to target
81    edits.push(MoveEdit {
82        file_path: target_file.to_string(),
83        action: MoveAction::AddToTarget,
84        content: symbol_text.clone(),
85    });
86
87    // 5. Find import references to update
88    let matches = find_all_word_matches(project, symbol_name)?;
89    let ext = std::path::Path::new(file_path)
90        .extension()
91        .and_then(|e| e.to_str())
92        .unwrap_or("");
93
94    let source_module = file_path_to_module(file_path, ext);
95    let target_module = file_path_to_module(target_file, ext);
96
97    let mut import_edits: Vec<RenameEdit> = Vec::new();
98
99    for (ref_file, line, _col) in &matches {
100        if ref_file == file_path || ref_file == target_file {
101            continue;
102        }
103        let ref_resolved = match project.resolve(ref_file) {
104            Ok(p) => p,
105            Err(_) => continue,
106        };
107        let ref_content = match fs::read_to_string(&ref_resolved) {
108            Ok(c) => c,
109            Err(_) => continue,
110        };
111        let ref_lines: Vec<&str> = ref_content.lines().collect();
112        if *line == 0 || *line > ref_lines.len() {
113            continue;
114        }
115        let line_text = ref_lines[*line - 1];
116
117        // Check if this line is an import/from statement referencing the source module
118        if is_import_line(line_text, &source_module, ext) {
119            let new_line = line_text.replace(&source_module, &target_module);
120            if new_line != line_text {
121                import_edits.push(RenameEdit {
122                    file_path: ref_file.clone(),
123                    line: *line,
124                    column: 1,
125                    old_text: line_text.to_string(),
126                    new_text: new_line,
127                });
128
129                edits.push(MoveEdit {
130                    file_path: ref_file.clone(),
131                    action: MoveAction::UpdateImport,
132                    content: format!("{} → {}", source_module, target_module),
133                });
134            }
135        }
136    }
137
138    let import_updates = edits
139        .iter()
140        .filter(|e| matches!(e.action, MoveAction::UpdateImport))
141        .count();
142
143    let mut result = MoveResult {
144        success: true,
145        message: format!(
146            "Moved '{}' from '{}' to '{}', updated {} import(s)",
147            symbol_name, file_path, target_file, import_updates
148        ),
149        source_file: file_path.to_string(),
150        target_file: target_file.to_string(),
151        symbol_name: symbol_name.to_string(),
152        import_updates,
153        edits,
154        apply_evidence: None,
155    };
156
157    if !dry_run {
158        // Compute the post-move source content (symbol removed).
159        let source_lines: Vec<String> = source_content.lines().map(String::from).collect();
160        let start_idx = if start_line > 0 { start_line - 1 } else { 0 };
161        let end_idx = end_line.min(source_lines.len());
162        let mut new_lines: Vec<String> = Vec::new();
163        for (i, line) in source_lines.iter().enumerate() {
164            if i < start_idx || i >= end_idx {
165                new_lines.push(line.clone());
166            }
167        }
168        // Remove trailing blank line if the symbol was followed by one
169        if start_idx > 0
170            && start_idx < new_lines.len()
171            && new_lines[start_idx].trim().is_empty()
172            && (start_idx == 0 || new_lines[start_idx - 1].trim().is_empty())
173        {
174            new_lines.remove(start_idx);
175        }
176        let mut new_source = new_lines.join("\n");
177        if source_content.ends_with('\n') {
178            new_source.push('\n');
179        }
180
181        // Compute the post-move target content (symbol appended).
182        let resolved_target = project.resolve(target_file)?;
183        let mut target_content = if resolved_target.exists() {
184            fs::read_to_string(&resolved_target)?
185        } else {
186            String::new()
187        };
188        if !target_content.is_empty() && !target_content.ends_with('\n') {
189            target_content.push('\n');
190        }
191        if !target_content.is_empty() {
192            target_content.push('\n');
193        }
194        target_content.push_str(&symbol_text);
195        target_content.push('\n');
196
197        // G7b — atomic 2-file substrate. If the target write fails, the
198        // source write is rolled back to its pre-move bytes so the
199        // symbol never disappears mid-flight.
200        let writes: Vec<(&str, &str)> = vec![
201            (file_path, new_source.as_str()),
202            (target_file, target_content.as_str()),
203        ];
204        match apply_full_writes_with_evidence(project, &writes) {
205            Ok(evidence) => {
206                result.apply_evidence = Some(evidence);
207            }
208            Err(err) => {
209                // Surface the substrate's evidence on rollback so callers
210                // can confirm the disk state is back to its pre-move
211                // shape. Other errors (PreReadFailed,
212                // PreApplyHashMismatch) propagate as-is.
213                if let crate::edit_transaction::ApplyError::ApplyFailed { evidence, source } = err {
214                    result.apply_evidence = Some(evidence);
215                    result.success = false;
216                    result.message = format!(
217                        "move '{symbol_name}' failed mid-write: {source}; source+target rolled back"
218                    );
219                    return Ok(result);
220                }
221                return Err(anyhow::anyhow!(err));
222            }
223        }
224
225        // Update imports across the project. These edits operate on
226        // files outside the source/target pair so a failure here does
227        // not undo the move itself; the import-update step is best-
228        // effort the same way it was pre-G7b.
229        if !import_edits.is_empty() {
230            apply_edits(project, &import_edits)?;
231        }
232    }
233
234    Ok(result)
235}
236
237/// Convert a file path to a module path based on language conventions.
238fn file_path_to_module(path: &str, ext: &str) -> String {
239    let without_ext = path.strip_suffix(&format!(".{}", ext)).unwrap_or(path);
240
241    match ext {
242        "py" => without_ext.replace(['/', '\\'], "."),
243        "js" | "ts" | "tsx" | "jsx" => {
244            let clean = without_ext.strip_suffix("/index").unwrap_or(without_ext);
245            format!("./{}", clean)
246        }
247        "go" => {
248            // Go uses directory-based packages
249            std::path::Path::new(without_ext)
250                .parent()
251                .map(|p| p.to_string_lossy().to_string())
252                .unwrap_or_else(|| ".".to_string())
253        }
254        "java" | "kt" | "scala" => without_ext.replace(['/', '\\'], "."),
255        _ => without_ext.replace(['/', '\\'], "."),
256    }
257}
258
259/// Check if a line is an import statement referencing the given module.
260fn is_import_line(line: &str, module: &str, ext: &str) -> bool {
261    let trimmed = line.trim();
262    match ext {
263        "py" => {
264            (trimmed.starts_with("from ") || trimmed.starts_with("import "))
265                && trimmed.contains(module)
266        }
267        "js" | "ts" | "tsx" | "jsx" => {
268            (trimmed.starts_with("import ") || trimmed.contains("require("))
269                && trimmed.contains(module)
270        }
271        "go" => trimmed.contains(module) && trimmed.contains('"'),
272        "java" | "kt" | "scala" => trimmed.starts_with("import ") && trimmed.contains(module),
273        "rs" => trimmed.starts_with("use ") && trimmed.contains(module),
274        _ => trimmed.starts_with("import ") && trimmed.contains(module),
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::ProjectRoot;
282    use std::fs;
283
284    fn make_fixture() -> (tempfile::TempDir, std::path::PathBuf, ProjectRoot) {
285        let (temp_dir, dir) = crate::test_helpers::make_unique_temp_dir("codelens-move-fixture-");
286        fs::create_dir_all(&dir).unwrap();
287        let project = ProjectRoot::new(dir.clone()).unwrap();
288        (temp_dir, dir, project)
289    }
290
291    #[test]
292    fn test_file_path_to_module_python() {
293        assert_eq!(
294            file_path_to_module("utils/helpers.py", "py"),
295            "utils.helpers"
296        );
297    }
298
299    #[test]
300    fn test_file_path_to_module_js() {
301        assert_eq!(
302            file_path_to_module("utils/helpers.js", "js"),
303            "./utils/helpers"
304        );
305    }
306
307    #[test]
308    fn test_is_import_line_python() {
309        assert!(is_import_line(
310            "from utils.helpers import foo",
311            "utils.helpers",
312            "py"
313        ));
314        assert!(!is_import_line("x = helpers.foo()", "utils.helpers", "py"));
315    }
316
317    #[test]
318    fn test_is_import_line_js() {
319        assert!(is_import_line(
320            "import { foo } from './utils/helpers';",
321            "./utils/helpers",
322            "js"
323        ));
324    }
325
326    #[test]
327    fn test_same_file_error() {
328        let (_temp, _dir, project) = make_fixture();
329        let result = move_symbol(&project, "a.py", "foo", None, "a.py", true);
330        assert!(result.is_err());
331    }
332
333    #[test]
334    fn test_move_dry_run() {
335        let (_temp, dir, project) = make_fixture();
336
337        let source = "def foo():\n    return 42\n\ndef bar():\n    return foo()\n";
338        fs::write(dir.join("source.py"), source).unwrap();
339        fs::write(dir.join("target.py"), "# target\n").unwrap();
340
341        let result = move_symbol(&project, "source.py", "foo", None, "target.py", true).unwrap();
342        assert!(result.success);
343        assert_eq!(result.symbol_name, "foo");
344
345        // Dry run: files unchanged
346        let after = fs::read_to_string(dir.join("source.py")).unwrap();
347        assert_eq!(after, source);
348
349        fs::remove_dir_all(&dir).ok();
350    }
351}