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                // Walk chars (not bytes) so UTF-8 continuation bytes aren't mistaken for
490                // word boundaries (#956).
491                let is_word_start = is_word_boundary_before(text, match_start);
492                let is_word_end = is_word_boundary_after(text, match_end);
493
494                // Allow the match if it is either in plain code or is an
495                // interpolated variable reference inside a double-quoted string.
496                // The second condition catches `"$var"`, `"${var}"`, `"@arr"` etc.
497                // while correctly skipping bare literal text like `"hello var"`.
498                if is_word_start
499                    && is_word_end
500                    && (is_rename_code_position(text, match_start)
501                        || is_interpolated_in_double_quote(text, match_start))
502                {
503                    // AC:AC4 - Scope check: if we have a package context, verify this reference
504                    // is in the correct scope
505                    let in_scope = if let Some(ref pkg) = scope_package {
506                        // Check if the reference is qualified with the correct package
507                        let before = &text[..match_start];
508                        let is_qualified_with_pkg = before.ends_with(&format!("{}::", pkg));
509
510                        // Check if we're within the right package scope
511                        let current_package = find_package_at_offset(text, match_start);
512                        let in_package_scope = current_package.as_deref() == Some(pkg.as_str());
513
514                        is_qualified_with_pkg || in_package_scope
515                    } else {
516                        true
517                    };
518
519                    if in_scope {
520                        // Also replace the package qualifier if it precedes the match
521                        let (edit_start, replacement) = if let Some(ref pkg) = scope_package {
522                            let prefix = format!("{}::", pkg);
523                            if match_start >= prefix.len()
524                                && text[match_start - prefix.len()..match_start] == *prefix
525                            {
526                                // Replace "Package::old_bare" with "Package::new_bare"
527                                (match_start - prefix.len(), format!("{}::{}", pkg, new_bare))
528                            } else {
529                                (match_start, new_bare.to_string())
530                            }
531                        } else {
532                            (match_start, new_bare.to_string())
533                        };
534
535                        let (start_line, start_col) = line_index.offset_to_position(edit_start);
536                        let (end_line, end_col) = line_index.offset_to_position(match_end);
537
538                        if let (Some(start_byte), Some(end_byte)) = (
539                            line_index.position_to_offset(start_line, start_col),
540                            line_index.position_to_offset(end_line, end_col),
541                        ) {
542                            file_edits.push(TextEdit {
543                                start: start_byte,
544                                end: end_byte,
545                                new_text: replacement,
546                            });
547                        }
548                    }
549                }
550
551                search_pos = match_end;
552
553                // Safety limit
554                if file_edits.len() >= 1000 {
555                    break;
556                }
557            }
558
559            if !file_edits.is_empty() {
560                if let Some(path) = doc_path {
561                    edits_by_file.entry(path).or_default().extend(file_edits);
562                }
563            }
564
565            files_processed += 1;
566        }
567
568        // If no edits found, the symbol wasn't found
569        if edits_by_file.is_empty() {
570            return Err(WorkspaceRenameError::SymbolNotFound {
571                symbol: old_name.to_string(),
572                file: file_path.display().to_string(),
573            });
574        }
575
576        // Build file edits, sorting each file's edits in reverse order for safe application
577        let file_edits: Vec<FileEdit> = edits_by_file
578            .into_iter()
579            .map(|(file_path, mut edits)| {
580                edits.sort_by_key(|e| std::cmp::Reverse(e.start));
581                FileEdit { file_path, edits }
582            })
583            .collect();
584
585        let total_changes: usize = file_edits.iter().map(|fe| fe.edits.len()).sum();
586        let files_modified = file_edits.len();
587
588        // AC:AC5 - Backup creation
589        let backup_info =
590            if self.config.create_backups { self.create_backup(&file_edits).ok() } else { None };
591
592        let elapsed_ms = start.elapsed().as_millis() as u64;
593
594        // Emit completion progress
595        if let Some(ref tx) = progress_tx {
596            let _ = tx.send(Progress::Complete { files_modified, changes: total_changes });
597        }
598
599        Ok(WorkspaceRenameResult {
600            file_edits,
601            backup_info,
602            description: format!("Rename '{}' to '{}'", old_name, new_name),
603            warnings: vec![],
604            statistics: RenameStatistics { files_modified, total_changes, elapsed_ms },
605        })
606    }
607
608    /// Check for name conflicts in the workspace
609    fn check_name_conflicts(
610        &self,
611        new_bare_name: &str,
612        scope_package: Option<&str>,
613    ) -> Result<(), WorkspaceRenameError> {
614        let all_symbols = self.index.all_symbols();
615
616        let mut conflicts = Vec::new();
617        for symbol in &all_symbols {
618            let matches_bare = symbol.name == new_bare_name;
619            let matches_qualified = if let Some(pkg) = scope_package {
620                let qualified = format!("{}::{}", pkg, new_bare_name);
621                symbol.qualified_name.as_deref() == Some(&qualified) || symbol.name == qualified
622            } else {
623                false
624            };
625
626            if matches_bare || matches_qualified {
627                conflicts.push(ConflictLocation {
628                    file: perl_workspace::workspace_index::uri_to_fs_path(&symbol.uri)
629                        .unwrap_or_default(),
630                    line: symbol.range.start.line,
631                    column: symbol.range.start.column,
632                    existing_symbol: symbol
633                        .qualified_name
634                        .clone()
635                        .unwrap_or_else(|| symbol.name.clone()),
636                });
637            }
638        }
639
640        if conflicts.is_empty() {
641            Ok(())
642        } else {
643            Err(WorkspaceRenameError::NameConflict {
644                new_name: new_bare_name.to_string(),
645                conflicts,
646            })
647        }
648    }
649
650    /// Create backups of files that will be modified
651    fn create_backup(&self, file_edits: &[FileEdit]) -> Result<BackupInfo, WorkspaceRenameError> {
652        // Use nanos + thread ID for uniqueness across parallel operations
653        let ts =
654            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
655        let backup_dir = std::env::temp_dir().join(format!(
656            "perl_rename_backup_{}_{}_{:?}",
657            ts.as_secs(),
658            ts.subsec_nanos(),
659            std::thread::current().id()
660        ));
661
662        std::fs::create_dir_all(&backup_dir).map_err(|e| {
663            WorkspaceRenameError::FileSystemError {
664                operation: "create_backup_dir".to_string(),
665                file: backup_dir.clone(),
666                error: e.to_string(),
667            }
668        })?;
669
670        let mut file_mappings = HashMap::new();
671
672        for (idx, file_edit) in file_edits.iter().enumerate() {
673            if file_edit.file_path.exists() {
674                // Use index prefix + filename for uniqueness within a single backup
675                let file_name = file_edit
676                    .file_path
677                    .file_name()
678                    .unwrap_or_default()
679                    .to_string_lossy()
680                    .to_string();
681                let backup_name = format!("{}_{}", idx, file_name);
682                let backup_path = backup_dir.join(&backup_name);
683
684                std::fs::copy(&file_edit.file_path, &backup_path).map_err(|e| {
685                    WorkspaceRenameError::FileSystemError {
686                        operation: "backup_copy".to_string(),
687                        file: file_edit.file_path.clone(),
688                        error: e.to_string(),
689                    }
690                })?;
691
692                file_mappings.insert(file_edit.file_path.clone(), backup_path);
693            }
694        }
695
696        Ok(BackupInfo { backup_dir, file_mappings })
697    }
698
699    /// Apply file edits atomically with rollback support
700    ///
701    /// # AC:AC3 - Atomic multi-file changes
702    pub fn apply_edits(&self, result: &WorkspaceRenameResult) -> Result<(), WorkspaceRenameError> {
703        let mut written_files = Vec::new();
704
705        for file_edit in &result.file_edits {
706            // Read original content
707            let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
708                // Rollback already-written files before returning error
709                if let Some(ref backup) = result.backup_info {
710                    let _ = self.rollback_from_backup(&written_files, backup);
711                }
712                WorkspaceRenameError::FileSystemError {
713                    operation: "read".to_string(),
714                    file: file_edit.file_path.clone(),
715                    error: e.to_string(),
716                }
717            })?;
718
719            // Apply edits in reverse order (edits are already sorted end-to-start)
720            let mut new_content = content;
721            for edit in &file_edit.edits {
722                if edit.start <= new_content.len() && edit.end <= new_content.len() {
723                    new_content = format!(
724                        "{}{}{}",
725                        &new_content[..edit.start],
726                        edit.new_text,
727                        &new_content[edit.end..],
728                    );
729                }
730            }
731
732            // Write modified content
733            std::fs::write(&file_edit.file_path, &new_content).map_err(|e| {
734                // Rollback already-written files
735                if let Some(ref backup) = result.backup_info {
736                    let _ = self.rollback_from_backup(&written_files, backup);
737                }
738                WorkspaceRenameError::FileSystemError {
739                    operation: "write".to_string(),
740                    file: file_edit.file_path.clone(),
741                    error: e.to_string(),
742                }
743            })?;
744
745            written_files.push(file_edit.file_path.clone());
746        }
747
748        Ok(())
749    }
750
751    /// Rollback files from backup
752    fn rollback_from_backup(
753        &self,
754        files: &[PathBuf],
755        backup: &BackupInfo,
756    ) -> Result<(), WorkspaceRenameError> {
757        for file in files {
758            if let Some(backup_path) = backup.file_mappings.get(file) {
759                std::fs::copy(backup_path, file).map_err(|e| {
760                    WorkspaceRenameError::RollbackFailed {
761                        original_error: "file write failed".to_string(),
762                        rollback_error: format!("failed to restore {}: {}", file.display(), e),
763                        backup_dir: backup.backup_dir.clone(),
764                    }
765                })?;
766            }
767        }
768        Ok(())
769    }
770
771    /// Update the workspace index after a rename operation
772    ///
773    /// # AC:AC8 - Dual indexing update
774    pub fn update_index_after_rename(
775        &self,
776        old_name: &str,
777        new_name: &str,
778        file_edits: &[FileEdit],
779    ) -> Result<(), WorkspaceRenameError> {
780        // Re-index each modified file with new content
781        for file_edit in file_edits {
782            let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
783                WorkspaceRenameError::IndexUpdateFailed {
784                    error: format!("Failed to read {}: {}", file_edit.file_path.display(), e),
785                    affected_files: vec![file_edit.file_path.clone()],
786                }
787            })?;
788
789            let uri_str = perl_workspace::workspace_index::fs_path_to_uri(&file_edit.file_path)
790                .map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
791                    error: format!("URI conversion failed: {}", e),
792                    affected_files: vec![file_edit.file_path.clone()],
793                })?;
794
795            // Remove old index entries and re-index with new content
796            self.index.remove_file(&uri_str);
797
798            let url =
799                url::Url::parse(&uri_str).map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
800                    error: format!("URL parse failed: {}", e),
801                    affected_files: vec![file_edit.file_path.clone()],
802                })?;
803
804            self.index.index_file(url, content).map_err(|e| {
805                WorkspaceRenameError::IndexUpdateFailed {
806                    error: format!(
807                        "Re-indexing failed for '{}' -> '{}': {}",
808                        old_name, new_name, e
809                    ),
810                    affected_files: vec![file_edit.file_path.clone()],
811                }
812            })?;
813        }
814
815        Ok(())
816    }
817}
818
819/// Perl string context at a given byte offset.
820#[derive(Clone, Copy, PartialEq, Eq, Debug)]
821enum StringContext {
822    /// Executable code (rename allowed).
823    Code,
824    /// Inside a `'...'` single-quoted string (no interpolation).
825    SingleQuoted,
826    /// Inside a `"..."` double-quoted string (interpolation allowed for sigil-preceded names).
827    DoubleQuoted,
828    /// Inside a `# ...` line comment (never rename).
829    LineComment,
830}
831
832/// Scan `text` up to `offset` and return the Perl string context at that position.
833fn scan_string_context(text: &str, offset: usize) -> StringContext {
834    let mut state = StringContext::Code;
835    let mut escaped = false;
836
837    for (idx, byte) in text.bytes().enumerate() {
838        if idx >= offset {
839            return state;
840        }
841
842        match state {
843            StringContext::Code => match byte {
844                b'\'' => state = StringContext::SingleQuoted,
845                b'"' => state = StringContext::DoubleQuoted,
846                b'#' => state = StringContext::LineComment,
847                _ => {}
848            },
849            StringContext::SingleQuoted => {
850                if escaped {
851                    escaped = false;
852                } else if byte == b'\\' {
853                    escaped = true;
854                } else if byte == b'\'' {
855                    state = StringContext::Code;
856                }
857            }
858            StringContext::DoubleQuoted => {
859                if escaped {
860                    escaped = false;
861                } else if byte == b'\\' {
862                    escaped = true;
863                } else if byte == b'"' {
864                    state = StringContext::Code;
865                }
866            }
867            StringContext::LineComment => {
868                if byte == b'\n' {
869                    state = StringContext::Code;
870                }
871            }
872        }
873    }
874
875    state
876}
877
878/// Check whether a byte offset is in executable Perl code rather than trivia.
879fn is_rename_code_position(text: &str, offset: usize) -> bool {
880    scan_string_context(text, offset) == StringContext::Code
881}
882
883/// Returns `true` when `offset` is inside a double-quoted Perl string AND the
884/// token at `offset` is immediately preceded by a Perl interpolation sigil
885/// (`$`, `@`, `%`) or by `{` that is itself preceded by a sigil (covering
886/// `"${var}"`, `"@{arr}"`, etc.).
887fn is_interpolated_in_double_quote(text: &str, offset: usize) -> bool {
888    if scan_string_context(text, offset) != StringContext::DoubleQuoted {
889        return false;
890    }
891
892    if offset == 0 {
893        return false;
894    }
895
896    let before = &text[..offset];
897    let last_char = match before.chars().next_back() {
898        Some(c) => c,
899        None => return false,
900    };
901
902    if matches!(last_char, '$' | '@' | '%') {
903        return true;
904    }
905
906    if last_char == '{' {
907        let before_brace = &before[..before.len() - 1];
908        let sigil = before_brace.chars().next_back();
909        return matches!(sigil, Some('$') | Some('@') | Some('%'));
910    }
911
912    false
913}
914
915/// Check if a char is a valid Perl identifier character.
916/// Operates on chars (not bytes) so UTF-8 continuation bytes are handled correctly (#956).
917fn is_perl_ident_char(c: char) -> bool {
918    c.is_alphanumeric() || c == '_'
919}
920
921/// Returns `true` when the position immediately before `byte_offset` in `text`
922/// is a word boundary (no preceding Perl identifier character).
923///
924/// Char-aware replacement for the old byte-level check that treated UTF-8
925/// continuation bytes as boundaries (#956).
926fn is_word_boundary_before(text: &str, byte_offset: usize) -> bool {
927    if byte_offset == 0 {
928        return true;
929    }
930    text[..byte_offset].chars().next_back().is_none_or(|c| !is_perl_ident_char(c))
931}
932
933/// Returns `true` when the position immediately at `byte_offset` in `text`
934/// is a word boundary (no following Perl identifier character).
935///
936/// Char-aware replacement for the old byte-level check that treated UTF-8
937/// lead bytes as boundaries (#956).
938fn is_word_boundary_after(text: &str, byte_offset: usize) -> bool {
939    if byte_offset >= text.len() {
940        return true;
941    }
942    text[byte_offset..].chars().next().is_none_or(|c| !is_perl_ident_char(c))
943}
944
945/// Find the current package scope at a given byte offset in Perl source
946fn find_package_at_offset(text: &str, offset: usize) -> Option<String> {
947    let before = &text[..offset];
948    // Search backwards for the most recent "package NAME" declaration
949    let mut last_package = None;
950    let mut search_pos = 0;
951    while let Some(found) = before[search_pos..].find("package ") {
952        let pkg_start = search_pos + found + "package ".len();
953        // Extract the package name (until ; or { or whitespace)
954        let remaining = &before[pkg_start..];
955        let pkg_end = remaining
956            .find(|c: char| c == ';' || c == '{' || c.is_whitespace())
957            .unwrap_or(remaining.len());
958        let pkg_name = remaining[..pkg_end].trim();
959        if !pkg_name.is_empty() {
960            last_package = Some(pkg_name.to_string());
961        }
962        search_pos = pkg_start;
963    }
964    last_package
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    #[test]
972    fn test_config_defaults() {
973        let config = WorkspaceRenameConfig::default();
974        assert!(config.atomic_mode);
975        assert!(config.create_backups);
976        assert_eq!(config.operation_timeout, 60);
977        assert!(config.parallel_processing);
978        assert_eq!(config.batch_size, 10);
979        assert_eq!(config.max_files, 0);
980        assert!(config.report_progress);
981        assert!(config.validate_syntax);
982    }
983
984    #[test]
985    fn test_split_qualified_name() {
986        assert_eq!(split_qualified_name("process"), (None, "process"));
987        assert_eq!(split_qualified_name("Utils::process"), (Some("Utils"), "process"));
988        assert_eq!(split_qualified_name("A::B::process"), (Some("A::B"), "process"));
989    }
990
991    #[test]
992    fn test_is_perl_ident_char() {
993        assert!(is_perl_ident_char('a'));
994        assert!(is_perl_ident_char('Z'));
995        assert!(is_perl_ident_char('0'));
996        assert!(is_perl_ident_char('_'));
997        assert!(!is_perl_ident_char(' '));
998        assert!(!is_perl_ident_char(':'));
999        assert!(!is_perl_ident_char(';'));
1000        // Unicode alphanumerics are valid Perl identifier chars under `use utf8`
1001        assert!(is_perl_ident_char('α'));
1002        assert!(is_perl_ident_char('変'));
1003        // UTF-8 continuation bytes must NOT be treated as ident chars
1004        assert!(!is_perl_ident_char('\u{B0}')); // not alphanumeric
1005    }
1006
1007    #[test]
1008    fn test_find_package_at_offset() {
1009        let text = "package Foo;\nsub bar { 1 }\npackage Bar;\nsub baz { 2 }\n";
1010        assert_eq!(find_package_at_offset(text, 20), Some("Foo".to_string()));
1011        assert_eq!(find_package_at_offset(text, 45), Some("Bar".to_string()));
1012        assert_eq!(find_package_at_offset(text, 0), None);
1013    }
1014
1015    // -------------------------------------------------------------------------
1016    // Char-boundary helpers (#956) - lib-level coverage for Codecov patch gate
1017    // -------------------------------------------------------------------------
1018
1019    #[test]
1020    fn test_is_word_boundary_before_ascii() {
1021        // At offset 0: always a boundary
1022        assert!(is_word_boundary_before("foo", 0));
1023        // Preceded by space: boundary
1024        assert!(is_word_boundary_before("x foo", 2));
1025        // Preceded by ASCII ident char: NOT a boundary
1026        assert!(!is_word_boundary_before("xfoo", 1));
1027        assert!(!is_word_boundary_before("_foo", 1));
1028    }
1029
1030    #[test]
1031    fn test_is_word_boundary_before_unicode() {
1032        // "変数foo" — each kanji is 3 bytes; byte offset of 'f' is 6
1033        let text = "変数foo";
1034        let foo_start = text.find("foo").unwrap();
1035        // The char before 'f' is '数' (alphanumeric) — NOT a boundary
1036        assert!(!is_word_boundary_before(text, foo_start));
1037
1038        // "αfoo" — α is 2 bytes (U+03B1); 'f' is at byte 2
1039        let text2 = "αfoo";
1040        let foo_start2 = text2.find("foo").unwrap();
1041        assert!(!is_word_boundary_before(text2, foo_start2));
1042
1043        // "$foo" — '$' is not alphanumeric — IS a boundary
1044        assert!(is_word_boundary_before("$foo", 1));
1045    }
1046
1047    #[test]
1048    fn test_is_word_boundary_after_ascii() {
1049        let text = "foo bar";
1050        // At text.len(): always a boundary
1051        assert!(is_word_boundary_after(text, text.len()));
1052        // After "foo": next char is space — boundary
1053        assert!(is_word_boundary_after(text, 3));
1054        // After 'f': next char is 'o' — NOT a boundary
1055        assert!(!is_word_boundary_after(text, 1));
1056    }
1057
1058    #[test]
1059    fn test_is_word_boundary_after_unicode() {
1060        // "fooα" — α is U+03B1 (2 bytes); byte 3 starts α
1061        let text = "fooα";
1062        // The char at byte 3 is 'α' (alphanumeric) — NOT a boundary
1063        assert!(!is_word_boundary_after(text, 3));
1064
1065        // "foo変" — '変' is 3 bytes; byte 3 starts '変'
1066        let text2 = "foo変";
1067        assert!(!is_word_boundary_after(text2, 3));
1068
1069        // "foo " — byte 3 is space — boundary
1070        assert!(is_word_boundary_after("foo ", 3));
1071    }
1072
1073    // -------------------------------------------------------------------------
1074    // String-interpolation helpers — lib-level coverage for Codecov patch gate
1075    // -------------------------------------------------------------------------
1076
1077    #[test]
1078    fn test_scan_string_context_code_positions() {
1079        let text = "sub foo { foo(); }\n";
1080        let foo_pos = text.find("foo").unwrap_or(0);
1081        assert_eq!(scan_string_context(text, foo_pos), StringContext::Code);
1082        assert_eq!(scan_string_context(text, text.len()), StringContext::Code);
1083    }
1084
1085    #[test]
1086    fn test_scan_string_context_single_quoted() {
1087        let text = "my $x = 'hello';";
1088        let h_pos = text.find("hello").unwrap_or(0);
1089        assert_eq!(scan_string_context(text, h_pos), StringContext::SingleQuoted);
1090    }
1091
1092    #[test]
1093    fn test_scan_string_context_double_quoted() {
1094        let text = "my $x = \"hello\";";
1095        let h_pos = text.find("hello").unwrap_or(0);
1096        assert_eq!(scan_string_context(text, h_pos), StringContext::DoubleQuoted);
1097    }
1098
1099    #[test]
1100    fn test_scan_string_context_line_comment() {
1101        let text = "foo(); # bar in comment\n";
1102        let bar_pos = text.find("bar").unwrap_or(0);
1103        assert_eq!(scan_string_context(text, bar_pos), StringContext::LineComment);
1104    }
1105
1106    #[test]
1107    fn test_is_interpolated_direct_sigils() {
1108        let dollar = "\"$var\"";
1109        let v_pos = dollar.find("var").unwrap_or(0);
1110        assert!(is_interpolated_in_double_quote(dollar, v_pos), "$var must be interpolated");
1111
1112        let at = "\"@arr\"";
1113        let a_pos = at.find("arr").unwrap_or(0);
1114        assert!(is_interpolated_in_double_quote(at, a_pos), "@arr must be interpolated");
1115
1116        let percent = "\"%hash\"";
1117        let h_pos = percent.find("hash").unwrap_or(0);
1118        assert!(is_interpolated_in_double_quote(percent, h_pos), "%hash must be interpolated");
1119    }
1120
1121    #[test]
1122    fn test_is_interpolated_braced_sigils() {
1123        let braced_dollar = "\"${var}\"";
1124        let v_pos = braced_dollar.find("var").unwrap_or(0);
1125        assert!(
1126            is_interpolated_in_double_quote(braced_dollar, v_pos),
1127            "${{var}} must be interpolated"
1128        );
1129
1130        let braced_at = "\"@{arr}\"";
1131        let a_pos = braced_at.find("arr").unwrap_or(0);
1132        assert!(is_interpolated_in_double_quote(braced_at, a_pos), "@{{arr}} must be interpolated");
1133    }
1134
1135    #[test]
1136    fn test_is_interpolated_bare_text_in_string_not_interpolated() {
1137        let text = "\"hello var text\"";
1138        let var_pos = text.find("var").unwrap_or(0);
1139        assert!(
1140            !is_interpolated_in_double_quote(text, var_pos),
1141            "bare text in string must NOT be treated as interpolated"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_is_interpolated_not_in_string_returns_false() {
1147        let text = "my $foo = 1;";
1148        let foo_pos = text.find("foo").unwrap_or(0);
1149        assert!(
1150            !is_interpolated_in_double_quote(text, foo_pos),
1151            "code position must NOT be treated as interpolated"
1152        );
1153    }
1154
1155    #[test]
1156    fn test_is_interpolated_single_quoted_returns_false() {
1157        let text = "my $x = '$foo';";
1158        let foo_pos = text.find("foo").unwrap_or(0);
1159        assert!(
1160            !is_interpolated_in_double_quote(text, foo_pos),
1161            "single-quoted string must never be treated as interpolated"
1162        );
1163    }
1164}