strixonomy_refactor/
apply.rs1use crate::error::{RefactorError, Result};
2use crate::model::{FileChange, RefactorPlan};
3use std::fs;
4use std::path::{Path, PathBuf};
5use strixonomy_core::{
6 atomic_write, canonical_workspace_root, read_to_string_capped, validate_workspace_scope_any,
7 MAX_FILE_BYTES,
8};
9
10pub fn validate_refactor_plan_paths_any(
12 workspace_roots: &[PathBuf],
13 plan: &RefactorPlan,
14) -> Result<()> {
15 for change in &plan.changes {
16 validate_workspace_scope_any(&change.path, workspace_roots)
17 .map_err(RefactorError::Invalid)?;
18 }
19 Ok(())
20}
21
22pub fn validate_refactor_plan_paths(workspace_root: &Path, plan: &RefactorPlan) -> Result<()> {
24 let root = canonical_workspace_root(workspace_root).map_err(RefactorError::Invalid)?;
25 validate_refactor_plan_paths_any(std::slice::from_ref(&root), plan)
26}
27
28pub fn plans_equivalent(server: &RefactorPlan, client: &RefactorPlan) -> bool {
30 if server.changes.len() != client.changes.len() {
31 return false;
32 }
33 let mut server_sorted: Vec<&FileChange> = server.changes.iter().collect();
34 let mut client_sorted: Vec<&FileChange> = client.changes.iter().collect();
35 server_sorted.sort_by_key(|c| &c.path);
36 client_sorted.sort_by_key(|c| &c.path);
37 for (s, c) in server_sorted.iter().zip(client_sorted.iter()) {
38 if s.path != c.path || s.preview_text != c.preview_text {
39 return false;
40 }
41 }
42 true
43}
44
45struct FileBackup {
46 path: PathBuf,
47 previous: Option<String>,
48 created: bool,
49}
50
51fn backup_file(path: &Path) -> std::io::Result<FileBackup> {
52 let created = !path.exists();
53 let previous = if created {
54 None
55 } else {
56 Some(read_to_string_capped(path, MAX_FILE_BYTES).map_err(std::io::Error::other)?)
57 };
58 Ok(FileBackup { path: path.to_path_buf(), previous, created })
59}
60
61fn restore_backup(backup: &FileBackup) -> std::io::Result<()> {
62 if backup.created {
63 if backup.path.exists() {
64 fs::remove_file(&backup.path)?;
65 }
66 } else if let Some(content) = &backup.previous {
67 atomic_write(&backup.path, content)?;
68 }
69 Ok(())
70}
71
72pub fn apply_refactor_plan(
74 plan: &RefactorPlan,
75 preview_only: bool,
76 workspace_root: &Path,
77) -> Result<()> {
78 let root = canonical_workspace_root(workspace_root).map_err(RefactorError::Invalid)?;
79 apply_refactor_plan_checked(plan, preview_only, Some(std::slice::from_ref(&root))).map(|_| ())
80}
81
82pub fn apply_refactor_plan_checked(
84 plan: &RefactorPlan,
85 preview_only: bool,
86 workspace_roots: Option<&[PathBuf]>,
87) -> Result<usize> {
88 apply_refactor_plan_checked_with_overrides(plan, preview_only, workspace_roots, None)
89}
90
91pub fn apply_refactor_plan_checked_with_overrides(
93 plan: &RefactorPlan,
94 preview_only: bool,
95 workspace_roots: Option<&[PathBuf]>,
96 document_overrides: Option<&std::collections::HashMap<PathBuf, String>>,
97) -> Result<usize> {
98 if let Some(roots) = workspace_roots {
99 validate_refactor_plan_paths_any(roots, plan)?;
100 }
101 if preview_only {
102 return Ok(0);
103 }
104 let mut written = 0usize;
105 let mut backups: Vec<FileBackup> = Vec::new();
106 for change in &plan.changes {
107 if change.preview_text == change.original_text {
108 continue;
109 }
110 let backup = backup_file(&change.path)?;
111 let current = effective_current_text(&change.path, &backup, document_overrides);
113 if current != change.original_text {
114 rollback_backups(&backups)?;
115 return Err(RefactorError::Invalid(format!(
116 "file changed since preview: {}",
117 change.path.display()
118 )));
119 }
120 if let Err(e) = atomic_write(&change.path, &change.preview_text) {
121 rollback_backups(&backups)?;
122 return Err(RefactorError::Io(e));
123 }
124 backups.push(backup);
125 written += 1;
126 }
127 if written == 0 && !plan.changes.is_empty() {
128 rollback_backups(&backups)?;
129 return Err(RefactorError::Invalid("no files changed".to_string()));
130 }
131 Ok(written)
132}
133
134fn effective_current_text(
135 path: &Path,
136 backup: &FileBackup,
137 document_overrides: Option<&std::collections::HashMap<PathBuf, String>>,
138) -> String {
139 if let Some(overrides) = document_overrides {
140 if let Some(text) = overrides.get(path) {
141 return text.clone();
142 }
143 if let Ok(canonical) = path.canonicalize() {
144 if let Some(text) = overrides.get(&canonical) {
145 return text.clone();
146 }
147 }
148 }
149 backup.previous.clone().unwrap_or_default()
150}
151
152fn rollback_backups(backups: &[FileBackup]) -> Result<()> {
153 let mut restore_errors = Vec::new();
154 for b in backups.iter().rev() {
155 if let Err(e) = restore_backup(b) {
156 restore_errors.push(format!("{}: {e}", b.path.display()));
157 }
158 }
159 if restore_errors.is_empty() {
160 Ok(())
161 } else {
162 Err(RefactorError::Invalid(format!(
163 "refactor rollback failed for: {}",
164 restore_errors.join("; ")
165 )))
166 }
167}
168
169pub fn plan_touches_path(plan: &RefactorPlan, path: &Path) -> bool {
170 plan.changes.iter().any(|c| c.path == path)
171}