Skip to main content

normalize_refactor/
lib.rs

1//! Refactoring engine — composable semantic actions for code transformations.
2//!
3//! Three layers:
4//! - **Actions** (`actions.rs`): Pure query and mutation primitives
5//! - **Recipes** (`rename.rs`, future: `move.rs`, `extract.rs`): Compositions of actions
6//! - **Executor** (`RefactoringExecutor`): Shared apply/dry-run/shadow logic
7
8pub mod actions;
9pub mod add_parameter;
10pub mod extract_function;
11pub mod inline_function;
12pub mod inline_variable;
13pub mod introduce_variable;
14pub mod move_item;
15pub mod rename;
16
17use std::path::PathBuf;
18
19use normalize_shadow::{EditInfo, Shadow};
20
21/// A planned edit to a single file (not yet applied).
22pub struct PlannedEdit {
23    pub file: PathBuf,
24    pub original: String,
25    pub new_content: String,
26    pub description: String,
27}
28
29/// A complete refactoring plan: multiple file edits + warnings.
30pub struct RefactoringPlan {
31    pub operation: String,
32    pub edits: Vec<PlannedEdit>,
33    pub warnings: Vec<String>,
34}
35
36/// Context available to all refactoring actions.
37pub struct RefactoringContext {
38    pub root: PathBuf,
39    pub editor: normalize_edit::Editor,
40    pub index: Option<normalize_facts::FileIndex>,
41    pub loader: normalize_languages::GrammarLoader,
42}
43
44/// Cross-file references to a symbol.
45pub struct References {
46    pub callers: Vec<CallerRef>,
47    pub importers: Vec<ImportRef>,
48}
49
50/// A call-site reference.
51pub struct CallerRef {
52    pub file: String,
53    pub caller: String,
54    pub line: usize,
55    #[allow(dead_code)]
56    pub access: Option<String>,
57    /// Resolution confidence: `"resolved"` when backed by ModuleResolver facts,
58    /// `"heuristic"` when found via import-name matching without full resolution.
59    pub confidence: &'static str,
60}
61
62/// An import-site reference.
63pub struct ImportRef {
64    pub file: String,
65    pub name: String,
66    #[allow(dead_code)]
67    pub alias: Option<String>,
68    pub line: usize,
69    /// Resolution confidence: `"resolved"` when backed by ModuleResolver facts,
70    /// `"heuristic"` when found via import-name matching without full resolution.
71    pub confidence: &'static str,
72}
73
74/// Executes a `RefactoringPlan`: writes files, manages shadow snapshots.
75pub struct RefactoringExecutor {
76    pub root: PathBuf,
77    pub dry_run: bool,
78    pub shadow_enabled: bool,
79    pub message: Option<String>,
80}
81
82impl RefactoringExecutor {
83    /// Apply the plan. On dry-run, returns the list of files that *would* change.
84    /// On real run, writes files and records shadow history.
85    pub fn apply(&self, plan: &RefactoringPlan) -> Result<Vec<String>, String> {
86        if plan.edits.is_empty() {
87            return Ok(vec![]);
88        }
89
90        let abs_paths: Vec<PathBuf> = plan.edits.iter().map(|e| e.file.clone()).collect();
91
92        // Shadow: snapshot before
93        if !self.dry_run && self.shadow_enabled {
94            let shadow = Shadow::new(&self.root);
95            if let Err(e) =
96                shadow.before_edit(&abs_paths.iter().map(|p| p.as_path()).collect::<Vec<_>>())
97            {
98                eprintln!("warning: shadow git: {}", e);
99            }
100        }
101
102        let mut modified: Vec<String> = vec![];
103
104        for edit in &plan.edits {
105            let rel_path = edit
106                .file
107                .strip_prefix(&self.root)
108                .unwrap_or(&edit.file)
109                .to_string_lossy()
110                .to_string();
111
112            if self.dry_run {
113                if !modified.contains(&rel_path) {
114                    modified.push(rel_path);
115                }
116            } else {
117                match std::fs::write(&edit.file, &edit.new_content) {
118                    Ok(_) => {
119                        if !modified.contains(&rel_path) {
120                            modified.push(rel_path);
121                        }
122                    }
123                    Err(e) => eprintln!("error writing {}: {}", rel_path, e),
124                }
125            }
126        }
127
128        // Shadow: commit after
129        if !self.dry_run && self.shadow_enabled && !modified.is_empty() {
130            let shadow = Shadow::new(&self.root);
131            let info = EditInfo {
132                operation: plan.operation.clone(),
133                target: plan
134                    .edits
135                    .first()
136                    .map(|e| e.description.clone())
137                    .unwrap_or_default(),
138                files: abs_paths,
139                message: self.message.clone(),
140                workflow: None,
141            };
142            if let Err(e) = shadow.after_edit(&info) {
143                eprintln!("warning: shadow git: {}", e);
144            }
145        }
146
147        Ok(modified)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn executor_dry_run_does_not_write() {
157        let dir = tempfile::tempdir().unwrap();
158        let file = dir.path().join("test.rs");
159        std::fs::write(&file, "original").unwrap();
160
161        let executor = RefactoringExecutor {
162            root: dir.path().to_path_buf(),
163            dry_run: true,
164            shadow_enabled: false,
165            message: None,
166        };
167
168        let plan = RefactoringPlan {
169            operation: "test".to_string(),
170            edits: vec![PlannedEdit {
171                file: file.clone(),
172                original: "original".to_string(),
173                new_content: "modified".to_string(),
174                description: "test edit".to_string(),
175            }],
176            warnings: vec![],
177        };
178
179        let result = executor.apply(&plan).unwrap();
180        assert_eq!(result, vec!["test.rs"]);
181        // File unchanged
182        assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
183    }
184
185    #[test]
186    fn executor_real_run_writes_files() {
187        let dir = tempfile::tempdir().unwrap();
188        let file = dir.path().join("test.rs");
189        std::fs::write(&file, "original").unwrap();
190
191        let executor = RefactoringExecutor {
192            root: dir.path().to_path_buf(),
193            dry_run: false,
194            shadow_enabled: false,
195            message: None,
196        };
197
198        let plan = RefactoringPlan {
199            operation: "test".to_string(),
200            edits: vec![PlannedEdit {
201                file: file.clone(),
202                original: "original".to_string(),
203                new_content: "modified".to_string(),
204                description: "test edit".to_string(),
205            }],
206            warnings: vec![],
207        };
208
209        let result = executor.apply(&plan).unwrap();
210        assert_eq!(result, vec!["test.rs"]);
211        assert_eq!(std::fs::read_to_string(&file).unwrap(), "modified");
212    }
213
214    #[test]
215    fn executor_deduplicates_modified_files() {
216        let dir = tempfile::tempdir().unwrap();
217        let file = dir.path().join("test.rs");
218        std::fs::write(&file, "original").unwrap();
219
220        let executor = RefactoringExecutor {
221            root: dir.path().to_path_buf(),
222            dry_run: false,
223            shadow_enabled: false,
224            message: None,
225        };
226
227        let plan = RefactoringPlan {
228            operation: "test".to_string(),
229            edits: vec![
230                PlannedEdit {
231                    file: file.clone(),
232                    original: "original".to_string(),
233                    new_content: "step1".to_string(),
234                    description: "edit 1".to_string(),
235                },
236                PlannedEdit {
237                    file: file.clone(),
238                    original: "step1".to_string(),
239                    new_content: "step2".to_string(),
240                    description: "edit 2".to_string(),
241                },
242            ],
243            warnings: vec![],
244        };
245
246        let result = executor.apply(&plan).unwrap();
247        assert_eq!(result.len(), 1);
248        assert_eq!(std::fs::read_to_string(&file).unwrap(), "step2");
249    }
250
251    #[test]
252    fn empty_plan_returns_empty() {
253        let dir = tempfile::tempdir().unwrap();
254        let executor = RefactoringExecutor {
255            root: dir.path().to_path_buf(),
256            dry_run: false,
257            shadow_enabled: false,
258            message: None,
259        };
260
261        let plan = RefactoringPlan {
262            operation: "test".to_string(),
263            edits: vec![],
264            warnings: vec![],
265        };
266
267        let result = executor.apply(&plan).unwrap();
268        assert!(result.is_empty());
269    }
270}