Skip to main content

strixonomy_refactor/
apply.rs

1use 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
10/// Validate every path in a refactor plan is within at least one workspace root.
11pub 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
22/// Validate every path in a refactor plan is within the workspace jail.
23pub 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
28/// Returns true when client-submitted plan matches a server re-preview byte-for-byte.
29pub 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
72/// Apply a refactor plan to disk. When `preview_only` is true, no files are written.
73pub 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
82/// Apply plan and return count of files written.
83pub 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
91/// Apply plan with optional open-buffer overrides used for compare-and-swap.
92pub 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        // Compare-and-swap against the same source the preview used (buffer or disk).
112        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}