Skip to main content

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