Skip to main content

perl_parser/refactor/
workspace_rename.rs

1//! Workspace-wide rename refactoring for Perl symbols
2//!
3//! This module implements comprehensive symbol renaming across entire workspaces,
4//! supporting variables, subroutines, and packages with full LSP integration.
5//!
6//! # LSP Workflow Integration
7//!
8//! Workspace rename operates across the complete LSP pipeline:
9//! - **Parse**: Extract symbols from Perl source files
10//! - **Index**: Utilize dual indexing for qualified and bare symbol lookup
11//! - **Navigate**: Resolve cross-file references
12//! - **Complete**: Validate new names and detect conflicts
13//! - **Analyze**: Perform scope analysis and semantic validation
14//!
15//! # Features
16//!
17//! - **Cross-file rename**: Identify and rename symbols across entire workspace
18//! - **Atomic operations**: All-or-nothing changes with automatic rollback
19//! - **Scope-aware**: Respects Perl package namespaces and lexical scoping
20//! - **Dual indexing**: Finds both qualified (`Package::sub`) and bare (`sub`) references
21//! - **Progress reporting**: Real-time feedback during large operations
22//! - **Backup support**: Optional backup creation for safety
23//!
24//! # Example
25//!
26//! ```rust,ignore
27//! use perl_refactoring::workspace_rename::{WorkspaceRename, WorkspaceRenameConfig};
28//! use perl_workspace::workspace_index::WorkspaceIndex;
29//! use std::path::Path;
30//!
31//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
32//! let index = WorkspaceIndex::new();
33//! let config = WorkspaceRenameConfig::default();
34//! let rename_engine = WorkspaceRename::new(index, config);
35//!
36//! let result = rename_engine.rename_symbol(
37//!     "old_function",
38//!     "new_function",
39//!     Path::new("lib/Utils.pm"),
40//!     (5, 4), // Line 5, column 4
41//! )?;
42//!
43//! println!("Renamed {} occurrences across {} files",
44//!          result.statistics.total_changes,
45//!          result.statistics.files_modified);
46//! # Ok(())
47//! # }
48//! ```
49
50use super::refactoring::BackupInfo;
51use super::workspace_refactor::{FileEdit, TextEdit};
52use perl_parser_core::qualified_name::split_qualified_name;
53use perl_workspace::workspace_index::WorkspaceIndex;
54use serde::{Deserialize, Serialize};
55use std::collections::{BTreeMap, HashMap};
56use std::path::{Path, PathBuf};
57use std::time::Instant;
58
59/// Configuration for workspace-wide rename operations
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WorkspaceRenameConfig {
62    /// Enable atomic transaction with rollback (default: true)
63    pub atomic_mode: bool,
64
65    /// Create backups before modification (default: true)
66    pub create_backups: bool,
67
68    /// Operation timeout in seconds (default: 60)
69    pub operation_timeout: u64,
70
71    /// Enable parallel file processing (default: true)
72    pub parallel_processing: bool,
73
74    /// Number of files per batch in parallel mode (default: 10)
75    pub batch_size: usize,
76
77    /// Maximum number of files to process (0 = unlimited) (default: 0)
78    pub max_files: usize,
79
80    /// Enable progress reporting (default: true)
81    pub report_progress: bool,
82
83    /// Validate syntax after each file edit (default: true)
84    pub validate_syntax: bool,
85}
86
87impl Default for WorkspaceRenameConfig {
88    fn default() -> Self {
89        Self {
90            atomic_mode: true,
91            create_backups: true,
92            operation_timeout: 60,
93            parallel_processing: true,
94            batch_size: 10,
95            max_files: 0,
96            report_progress: true,
97            validate_syntax: true,
98        }
99    }
100}
101
102/// Result of a workspace rename operation
103#[derive(Debug, Serialize, Deserialize)]
104pub struct WorkspaceRenameResult {
105    /// File edits to apply
106    pub file_edits: Vec<FileEdit>,
107    /// Backup information for rollback
108    pub backup_info: Option<BackupInfo>,
109    /// Human-readable description
110    pub description: String,
111    /// Non-fatal warnings
112    pub warnings: Vec<String>,
113    /// Operation statistics
114    pub statistics: RenameStatistics,
115}
116
117/// Statistics for a rename operation
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RenameStatistics {
120    /// Number of files modified
121    pub files_modified: usize,
122    /// Total number of changes made
123    pub total_changes: usize,
124    /// Operation duration in milliseconds
125    pub elapsed_ms: u64,
126}
127
128/// Progress events during rename operation
129#[derive(Debug, Clone)]
130pub enum Progress {
131    /// Workspace scan started
132    Scanning {
133        /// Total files to scan
134        total: usize,
135    },
136    /// Processing a file
137    Processing {
138        /// Current file index
139        current: usize,
140        /// Total files
141        total: usize,
142        /// File being processed
143        file: PathBuf,
144    },
145    /// Operation complete
146    Complete {
147        /// Files modified
148        files_modified: usize,
149        /// Total changes
150        changes: usize,
151    },
152}
153
154/// Errors specific to workspace rename operations
155#[derive(Debug, Clone)]
156pub enum WorkspaceRenameError {
157    /// Symbol not found in workspace
158    SymbolNotFound {
159        /// Symbol name
160        symbol: String,
161        /// File path
162        file: String,
163    },
164
165    /// Name conflict detected in scope
166    NameConflict {
167        /// New name that conflicts
168        new_name: String,
169        /// Locations of conflicts
170        conflicts: Vec<ConflictLocation>,
171    },
172
173    /// Operation timed out
174    Timeout {
175        /// Elapsed seconds
176        elapsed_seconds: u64,
177        /// Files processed before timeout
178        files_processed: usize,
179        /// Total files
180        total_files: usize,
181    },
182
183    /// File system operation failed
184    FileSystemError {
185        /// Operation name
186        operation: String,
187        /// File path
188        file: PathBuf,
189        /// Error message
190        error: String,
191    },
192
193    /// Rollback failed (critical)
194    RollbackFailed {
195        /// Original error
196        original_error: String,
197        /// Rollback error
198        rollback_error: String,
199        /// Backup directory
200        backup_dir: PathBuf,
201    },
202
203    /// Index update failed
204    IndexUpdateFailed {
205        /// Error message
206        error: String,
207        /// Affected files
208        affected_files: Vec<PathBuf>,
209    },
210
211    /// Security violation
212    SecurityError {
213        /// Error message
214        message: String,
215        /// Offending path
216        path: Option<PathBuf>,
217    },
218
219    /// Feature not yet implemented
220    NotImplemented {
221        /// Description of unimplemented feature
222        feature: String,
223    },
224}
225
226impl std::fmt::Display for WorkspaceRenameError {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        match self {
229            WorkspaceRenameError::SymbolNotFound { symbol, file } => {
230                write!(f, "Symbol '{}' not found in {}", symbol, file)
231            }
232            WorkspaceRenameError::NameConflict { new_name, conflicts } => {
233                write!(f, "Name '{}' conflicts with {} existing symbols", new_name, conflicts.len())
234            }
235            WorkspaceRenameError::Timeout { elapsed_seconds, files_processed, total_files } => {
236                write!(
237                    f,
238                    "Operation timed out after {}s ({}/{} files)",
239                    elapsed_seconds, files_processed, total_files
240                )
241            }
242            WorkspaceRenameError::FileSystemError { operation, file, error } => {
243                write!(f, "File system error during {}: {} - {}", operation, file.display(), error)
244            }
245            WorkspaceRenameError::RollbackFailed { original_error, rollback_error, backup_dir } => {
246                write!(
247                    f,
248                    "Rollback failed - original: {}, rollback: {}, backup: {}",
249                    original_error,
250                    rollback_error,
251                    backup_dir.display()
252                )
253            }
254            WorkspaceRenameError::IndexUpdateFailed { error, affected_files } => {
255                write!(f, "Index update failed: {} ({} files)", error, affected_files.len())
256            }
257            WorkspaceRenameError::SecurityError { message, path } => {
258                if let Some(p) = path {
259                    write!(f, "Security error: {} ({})", message, p.display())
260                } else {
261                    write!(f, "Security error: {}", message)
262                }
263            }
264            WorkspaceRenameError::NotImplemented { feature } => {
265                write!(f, "Feature not yet implemented: {}", feature)
266            }
267        }
268    }
269}
270
271impl std::error::Error for WorkspaceRenameError {}
272
273/// Location of a name conflict
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct ConflictLocation {
276    /// File path
277    pub file: PathBuf,
278    /// Line number
279    pub line: u32,
280    /// Column number
281    pub column: u32,
282    /// Existing symbol name
283    pub existing_symbol: String,
284}
285
286/// Workspace rename engine
287///
288/// Provides comprehensive symbol renaming across entire workspace with atomic
289/// operations, backup support, and progress reporting.
290pub struct WorkspaceRename {
291    /// Workspace index for symbol lookup
292    index: WorkspaceIndex,
293    /// Configuration
294    config: WorkspaceRenameConfig,
295}
296
297impl WorkspaceRename {
298    /// Create a new workspace rename engine
299    ///
300    /// # Arguments
301    /// * `index` - Workspace index for symbol lookup
302    /// * `config` - Rename configuration
303    ///
304    /// # Returns
305    /// A new `WorkspaceRename` instance
306    pub fn new(index: WorkspaceIndex, config: WorkspaceRenameConfig) -> Self {
307        Self { index, config }
308    }
309
310    /// Get a reference to the workspace index
311    pub fn index(&self) -> &WorkspaceIndex {
312        &self.index
313    }
314
315    /// Rename a symbol across the workspace
316    ///
317    /// # Arguments
318    /// * `old_name` - Current symbol name
319    /// * `new_name` - New symbol name
320    /// * `file_path` - File containing the symbol
321    /// * `position` - Position of the symbol (line, column)
322    ///
323    /// # Returns
324    /// * `Ok(WorkspaceRenameResult)` - Rename result with edits and statistics
325    /// * `Err(WorkspaceRenameError)` - Error during rename operation
326    ///
327    /// # Errors
328    /// * `SymbolNotFound` - Symbol not found in workspace
329    /// * `NameConflict` - New name conflicts with existing symbol
330    /// * `Timeout` - Operation exceeded configured timeout
331    /// * `FileSystemError` - File I/O error
332    pub fn rename_symbol(
333        &self,
334        old_name: &str,
335        new_name: &str,
336        file_path: &Path,
337        _position: (usize, usize),
338    ) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
339        self.rename_symbol_impl(old_name, new_name, file_path, None)
340    }
341
342    /// Rename a symbol with progress reporting
343    ///
344    /// # Arguments
345    /// * `old_name` - Current symbol name
346    /// * `new_name` - New symbol name
347    /// * `file_path` - File containing the symbol
348    /// * `position` - Position of the symbol (line, column)
349    /// * `progress_tx` - Channel for progress events
350    ///
351    /// # Returns
352    /// * `Ok(WorkspaceRenameResult)` - Rename result with edits and statistics
353    /// * `Err(WorkspaceRenameError)` - Error during rename operation
354    pub fn rename_symbol_with_progress(
355        &self,
356        old_name: &str,
357        new_name: &str,
358        file_path: &Path,
359        _position: (usize, usize),
360        progress_tx: std::sync::mpsc::Sender<Progress>,
361    ) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
362        self.rename_symbol_impl(old_name, new_name, file_path, Some(progress_tx))
363    }
364
365    /// Core rename implementation shared between rename_symbol and rename_symbol_with_progress
366    fn rename_symbol_impl(
367        &self,
368        old_name: &str,
369        new_name: &str,
370        file_path: &Path,
371        progress_tx: Option<std::sync::mpsc::Sender<Progress>>,
372    ) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
373        let start = Instant::now();
374        let timeout = std::time::Duration::from_secs(self.config.operation_timeout);
375
376        // Extract the bare name and optional package qualifier from old_name
377        let (old_package, old_bare) = split_qualified_name(old_name);
378        let (_new_package, new_bare) = split_qualified_name(new_name);
379
380        // AC:AC2 - Name conflict validation
381        // Check if any symbol already exists with the new name
382        self.check_name_conflicts(new_bare, old_package)?;
383
384        // AC:AC1 - Workspace symbol identification using dual indexing
385        // Find definition first
386        let definition = self.index.find_definition(old_name);
387
388        // Get the package context for scope-aware rename
389        // Only use scope filtering when the user explicitly provided a qualified name
390        let scope_package = old_package.map(|p| p.to_string());
391
392        // Collect all references using dual indexing
393        let mut all_references = self.index.find_references(old_name);
394
395        // If qualified, also find bare references
396        if let Some(_pkg) = &scope_package {
397            let qualified = format!("{}::{}", _pkg, old_bare);
398            let qualified_refs = self.index.find_references(&qualified);
399            for r in qualified_refs {
400                if !all_references
401                    .iter()
402                    .any(|existing| existing.uri == r.uri && existing.range == r.range)
403                {
404                    all_references.push(r);
405                }
406            }
407            // Also search bare form
408            let bare_refs = self.index.find_references(old_bare);
409            for r in bare_refs {
410                if !all_references
411                    .iter()
412                    .any(|existing| existing.uri == r.uri && existing.range == r.range)
413                {
414                    all_references.push(r);
415                }
416            }
417        }
418
419        // Add the definition location if not already present
420        if let Some(ref def) = definition {
421            if !all_references.iter().any(|r| r.uri == def.uri && r.range == def.range) {
422                all_references.push(def.clone());
423            }
424        }
425
426        // Also try text-based fallback search across all indexed documents
427        let store = self.index.document_store();
428        let all_docs = store.all_documents();
429        let total_files = all_docs.len();
430
431        // Emit scanning progress
432        if let Some(ref tx) = progress_tx {
433            let _ = tx.send(Progress::Scanning { total: total_files });
434        }
435
436        // AC:AC4 - Perl scoping rules
437        // For scope-aware rename, we search for the old_bare name in document text
438        // but only replace it when it matches the correct scope
439        let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
440        let mut files_processed = 0;
441
442        for (idx, doc) in all_docs.iter().enumerate() {
443            // Check timeout
444            if start.elapsed() > timeout {
445                return Err(WorkspaceRenameError::Timeout {
446                    elapsed_seconds: start.elapsed().as_secs(),
447                    files_processed,
448                    total_files,
449                });
450            }
451
452            // Check max_files limit
453            if self.config.max_files > 0 && files_processed >= self.config.max_files {
454                break;
455            }
456
457            let doc_path = perl_workspace::workspace_index::uri_to_fs_path(&doc.uri);
458
459            // Emit processing progress
460            if let Some(ref tx) = progress_tx {
461                let _ = tx.send(Progress::Processing {
462                    current: idx + 1,
463                    total: total_files,
464                    file: doc_path.clone().unwrap_or_default(),
465                });
466            }
467
468            // Search for the old name in this document's text
469            let text = &doc.text;
470            if !text.contains(old_bare) {
471                files_processed += 1;
472                continue;
473            }
474
475            let line_index = &doc.line_index;
476            let mut search_pos = 0;
477            let mut file_edits = Vec::new();
478
479            while let Some(found) = text[search_pos..].find(old_bare) {
480                let match_start = search_pos + found;
481                let match_end = match_start + old_bare.len();
482
483                // Bounds check
484                if match_end > text.len() {
485                    break;
486                }
487
488                // Verify this is a word boundary match (not a substring of a larger identifier)
489                let is_word_start =
490                    match_start == 0 || !is_identifier_char(text.as_bytes()[match_start - 1]);
491                let is_word_end =
492                    match_end >= text.len() || !is_identifier_char(text.as_bytes()[match_end]);
493
494                if is_word_start && is_word_end {
495                    // AC:AC4 - Scope check: if we have a package context, verify this reference
496                    // is in the correct scope
497                    let in_scope = if let Some(ref pkg) = scope_package {
498                        // Check if the reference is qualified with the correct package
499                        let before = &text[..match_start];
500                        let is_qualified_with_pkg = before.ends_with(&format!("{}::", pkg));
501
502                        // Check if we're within the right package scope
503                        let current_package = find_package_at_offset(text, match_start);
504                        let in_package_scope = current_package.as_deref() == Some(pkg.as_str());
505
506                        is_qualified_with_pkg || in_package_scope
507                    } else {
508                        true
509                    };
510
511                    if in_scope {
512                        // Also replace the package qualifier if it precedes the match
513                        let (edit_start, replacement) = if let Some(ref pkg) = scope_package {
514                            let prefix = format!("{}::", pkg);
515                            if match_start >= prefix.len()
516                                && text[match_start - prefix.len()..match_start] == *prefix
517                            {
518                                // Replace "Package::old_bare" with "Package::new_bare"
519                                (match_start - prefix.len(), format!("{}::{}", pkg, new_bare))
520                            } else {
521                                (match_start, new_bare.to_string())
522                            }
523                        } else {
524                            (match_start, new_bare.to_string())
525                        };
526
527                        let (start_line, start_col) = line_index.offset_to_position(edit_start);
528                        let (end_line, end_col) = line_index.offset_to_position(match_end);
529
530                        if let (Some(start_byte), Some(end_byte)) = (
531                            line_index.position_to_offset(start_line, start_col),
532                            line_index.position_to_offset(end_line, end_col),
533                        ) {
534                            file_edits.push(TextEdit {
535                                start: start_byte,
536                                end: end_byte,
537                                new_text: replacement,
538                            });
539                        }
540                    }
541                }
542
543                search_pos = match_end;
544
545                // Safety limit
546                if file_edits.len() >= 1000 {
547                    break;
548                }
549            }
550
551            if !file_edits.is_empty() {
552                if let Some(path) = doc_path {
553                    edits_by_file.entry(path).or_default().extend(file_edits);
554                }
555            }
556
557            files_processed += 1;
558        }
559
560        // If no edits found, the symbol wasn't found
561        if edits_by_file.is_empty() {
562            return Err(WorkspaceRenameError::SymbolNotFound {
563                symbol: old_name.to_string(),
564                file: file_path.display().to_string(),
565            });
566        }
567
568        // Build file edits, sorting each file's edits in reverse order for safe application
569        let file_edits: Vec<FileEdit> = edits_by_file
570            .into_iter()
571            .map(|(file_path, mut edits)| {
572                edits.sort_by(|a, b| b.start.cmp(&a.start));
573                FileEdit { file_path, edits }
574            })
575            .collect();
576
577        let total_changes: usize = file_edits.iter().map(|fe| fe.edits.len()).sum();
578        let files_modified = file_edits.len();
579
580        // AC:AC5 - Backup creation
581        let backup_info =
582            if self.config.create_backups { self.create_backup(&file_edits).ok() } else { None };
583
584        let elapsed_ms = start.elapsed().as_millis() as u64;
585
586        // Emit completion progress
587        if let Some(ref tx) = progress_tx {
588            let _ = tx.send(Progress::Complete { files_modified, changes: total_changes });
589        }
590
591        Ok(WorkspaceRenameResult {
592            file_edits,
593            backup_info,
594            description: format!("Rename '{}' to '{}'", old_name, new_name),
595            warnings: vec![],
596            statistics: RenameStatistics { files_modified, total_changes, elapsed_ms },
597        })
598    }
599
600    /// Check for name conflicts in the workspace
601    fn check_name_conflicts(
602        &self,
603        new_bare_name: &str,
604        scope_package: Option<&str>,
605    ) -> Result<(), WorkspaceRenameError> {
606        let all_symbols = self.index.all_symbols();
607
608        let mut conflicts = Vec::new();
609        for symbol in &all_symbols {
610            let matches_bare = symbol.name == new_bare_name;
611            let matches_qualified = if let Some(pkg) = scope_package {
612                let qualified = format!("{}::{}", pkg, new_bare_name);
613                symbol.qualified_name.as_deref() == Some(&qualified) || symbol.name == qualified
614            } else {
615                false
616            };
617
618            if matches_bare || matches_qualified {
619                conflicts.push(ConflictLocation {
620                    file: perl_workspace::workspace_index::uri_to_fs_path(&symbol.uri)
621                        .unwrap_or_default(),
622                    line: symbol.range.start.line,
623                    column: symbol.range.start.column,
624                    existing_symbol: symbol
625                        .qualified_name
626                        .clone()
627                        .unwrap_or_else(|| symbol.name.clone()),
628                });
629            }
630        }
631
632        if conflicts.is_empty() {
633            Ok(())
634        } else {
635            Err(WorkspaceRenameError::NameConflict {
636                new_name: new_bare_name.to_string(),
637                conflicts,
638            })
639        }
640    }
641
642    /// Create backups of files that will be modified
643    fn create_backup(&self, file_edits: &[FileEdit]) -> Result<BackupInfo, WorkspaceRenameError> {
644        // Use nanos + thread ID for uniqueness across parallel operations
645        let ts =
646            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
647        let backup_dir = std::env::temp_dir().join(format!(
648            "perl_rename_backup_{}_{}_{:?}",
649            ts.as_secs(),
650            ts.subsec_nanos(),
651            std::thread::current().id()
652        ));
653
654        std::fs::create_dir_all(&backup_dir).map_err(|e| {
655            WorkspaceRenameError::FileSystemError {
656                operation: "create_backup_dir".to_string(),
657                file: backup_dir.clone(),
658                error: e.to_string(),
659            }
660        })?;
661
662        let mut file_mappings = HashMap::new();
663
664        for (idx, file_edit) in file_edits.iter().enumerate() {
665            if file_edit.file_path.exists() {
666                // Use index prefix + filename for uniqueness within a single backup
667                let file_name = file_edit
668                    .file_path
669                    .file_name()
670                    .unwrap_or_default()
671                    .to_string_lossy()
672                    .to_string();
673                let backup_name = format!("{}_{}", idx, file_name);
674                let backup_path = backup_dir.join(&backup_name);
675
676                std::fs::copy(&file_edit.file_path, &backup_path).map_err(|e| {
677                    WorkspaceRenameError::FileSystemError {
678                        operation: "backup_copy".to_string(),
679                        file: file_edit.file_path.clone(),
680                        error: e.to_string(),
681                    }
682                })?;
683
684                file_mappings.insert(file_edit.file_path.clone(), backup_path);
685            }
686        }
687
688        Ok(BackupInfo { backup_dir, file_mappings })
689    }
690
691    /// Apply file edits atomically with rollback support
692    ///
693    /// # AC:AC3 - Atomic multi-file changes
694    pub fn apply_edits(&self, result: &WorkspaceRenameResult) -> Result<(), WorkspaceRenameError> {
695        let mut written_files = Vec::new();
696
697        for file_edit in &result.file_edits {
698            // Read original content
699            let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
700                // Rollback already-written files before returning error
701                if let Some(ref backup) = result.backup_info {
702                    let _ = self.rollback_from_backup(&written_files, backup);
703                }
704                WorkspaceRenameError::FileSystemError {
705                    operation: "read".to_string(),
706                    file: file_edit.file_path.clone(),
707                    error: e.to_string(),
708                }
709            })?;
710
711            // Apply edits in reverse order (edits are already sorted end-to-start)
712            let mut new_content = content;
713            for edit in &file_edit.edits {
714                if edit.start <= new_content.len() && edit.end <= new_content.len() {
715                    new_content = format!(
716                        "{}{}{}",
717                        &new_content[..edit.start],
718                        edit.new_text,
719                        &new_content[edit.end..],
720                    );
721                }
722            }
723
724            // Write modified content
725            std::fs::write(&file_edit.file_path, &new_content).map_err(|e| {
726                // Rollback already-written files
727                if let Some(ref backup) = result.backup_info {
728                    let _ = self.rollback_from_backup(&written_files, backup);
729                }
730                WorkspaceRenameError::FileSystemError {
731                    operation: "write".to_string(),
732                    file: file_edit.file_path.clone(),
733                    error: e.to_string(),
734                }
735            })?;
736
737            written_files.push(file_edit.file_path.clone());
738        }
739
740        Ok(())
741    }
742
743    /// Rollback files from backup
744    fn rollback_from_backup(
745        &self,
746        files: &[PathBuf],
747        backup: &BackupInfo,
748    ) -> Result<(), WorkspaceRenameError> {
749        for file in files {
750            if let Some(backup_path) = backup.file_mappings.get(file) {
751                std::fs::copy(backup_path, file).map_err(|e| {
752                    WorkspaceRenameError::RollbackFailed {
753                        original_error: "file write failed".to_string(),
754                        rollback_error: format!("failed to restore {}: {}", file.display(), e),
755                        backup_dir: backup.backup_dir.clone(),
756                    }
757                })?;
758            }
759        }
760        Ok(())
761    }
762
763    /// Update the workspace index after a rename operation
764    ///
765    /// # AC:AC8 - Dual indexing update
766    pub fn update_index_after_rename(
767        &self,
768        old_name: &str,
769        new_name: &str,
770        file_edits: &[FileEdit],
771    ) -> Result<(), WorkspaceRenameError> {
772        // Re-index each modified file with new content
773        for file_edit in file_edits {
774            let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
775                WorkspaceRenameError::IndexUpdateFailed {
776                    error: format!("Failed to read {}: {}", file_edit.file_path.display(), e),
777                    affected_files: vec![file_edit.file_path.clone()],
778                }
779            })?;
780
781            let uri_str = perl_workspace::workspace_index::fs_path_to_uri(&file_edit.file_path)
782                .map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
783                    error: format!("URI conversion failed: {}", e),
784                    affected_files: vec![file_edit.file_path.clone()],
785                })?;
786
787            // Remove old index entries and re-index with new content
788            self.index.remove_file(&uri_str);
789
790            let url =
791                url::Url::parse(&uri_str).map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
792                    error: format!("URL parse failed: {}", e),
793                    affected_files: vec![file_edit.file_path.clone()],
794                })?;
795
796            self.index.index_file(url, content).map_err(|e| {
797                WorkspaceRenameError::IndexUpdateFailed {
798                    error: format!(
799                        "Re-indexing failed for '{}' -> '{}': {}",
800                        old_name, new_name, e
801                    ),
802                    affected_files: vec![file_edit.file_path.clone()],
803                }
804            })?;
805        }
806
807        Ok(())
808    }
809}
810
811/// Check if a byte is a valid Perl identifier character
812fn is_identifier_char(b: u8) -> bool {
813    b.is_ascii_alphanumeric() || b == b'_'
814}
815
816/// Find the current package scope at a given byte offset in Perl source
817fn find_package_at_offset(text: &str, offset: usize) -> Option<String> {
818    let before = &text[..offset];
819    // Search backwards for the most recent "package NAME" declaration
820    let mut last_package = None;
821    let mut search_pos = 0;
822    while let Some(found) = before[search_pos..].find("package ") {
823        let pkg_start = search_pos + found + "package ".len();
824        // Extract the package name (until ; or { or whitespace)
825        let remaining = &before[pkg_start..];
826        let pkg_end = remaining
827            .find(|c: char| c == ';' || c == '{' || c.is_whitespace())
828            .unwrap_or(remaining.len());
829        let pkg_name = remaining[..pkg_end].trim();
830        if !pkg_name.is_empty() {
831            last_package = Some(pkg_name.to_string());
832        }
833        search_pos = pkg_start;
834    }
835    last_package
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    #[test]
843    fn test_config_defaults() {
844        let config = WorkspaceRenameConfig::default();
845        assert!(config.atomic_mode);
846        assert!(config.create_backups);
847        assert_eq!(config.operation_timeout, 60);
848        assert!(config.parallel_processing);
849        assert_eq!(config.batch_size, 10);
850        assert_eq!(config.max_files, 0);
851        assert!(config.report_progress);
852        assert!(config.validate_syntax);
853    }
854
855    #[test]
856    fn test_split_qualified_name() {
857        assert_eq!(split_qualified_name("process"), (None, "process"));
858        assert_eq!(split_qualified_name("Utils::process"), (Some("Utils"), "process"));
859        assert_eq!(split_qualified_name("A::B::process"), (Some("A::B"), "process"));
860    }
861
862    #[test]
863    fn test_is_identifier_char() {
864        assert!(is_identifier_char(b'a'));
865        assert!(is_identifier_char(b'Z'));
866        assert!(is_identifier_char(b'0'));
867        assert!(is_identifier_char(b'_'));
868        assert!(!is_identifier_char(b' '));
869        assert!(!is_identifier_char(b':'));
870        assert!(!is_identifier_char(b';'));
871    }
872
873    #[test]
874    fn test_find_package_at_offset() {
875        let text = "package Foo;\nsub bar { 1 }\npackage Bar;\nsub baz { 2 }\n";
876        assert_eq!(find_package_at_offset(text, 20), Some("Foo".to_string()));
877        assert_eq!(find_package_at_offset(text, 45), Some("Bar".to_string()));
878        assert_eq!(find_package_at_offset(text, 0), None);
879    }
880}