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