mod backup;
mod batch_loader;
pub mod pattern;
pub(crate) mod gates;
pub(crate) mod preview;
use crate::error::{Result, SpliceError};
use crate::io_ext;
use crate::symbol::Language as SymbolLanguage;
use crate::validate::AnalyzerMode;
use crate::verify;
use ropey::Rope;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
pub use backup::{restore_from_manifest, BackupManifest, BackupWriter};
pub use batch_loader::load_batches_from_file;
pub(crate) use gates::run_validation_gates;
pub use pattern::{
apply_pattern_replace, find_pattern_in_files, PatternReplaceConfig, PatternReplaceResult,
};
#[cfg(test)]
use preview::should_skip_entry;
#[derive(Debug, Clone, Serialize)]
pub struct SpanReplacement {
pub file: PathBuf,
pub start: usize,
pub end: usize,
pub content: String,
}
impl SpanReplacement {
pub fn new(file: PathBuf, start: usize, end: usize, content: String) -> Self {
Self {
file,
start,
end,
content,
}
}
}
#[derive(Debug, Clone)]
pub struct SpanBatch {
replacements: Vec<SpanReplacement>,
}
impl SpanBatch {
pub fn new(replacements: Vec<SpanReplacement>) -> Self {
Self { replacements }
}
pub fn replacements(&self) -> &[SpanReplacement] {
&self.replacements
}
pub fn push(&mut self, replacement: SpanReplacement) {
self.replacements.push(replacement);
}
pub fn is_empty(&self) -> bool {
self.replacements.is_empty()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FilePatchSummary {
pub file: PathBuf,
pub before_hash: String,
pub after_hash: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PreviewReport {
pub file: String,
pub line_start: usize,
pub line_end: usize,
pub lines_added: usize,
pub lines_removed: usize,
pub bytes_added: usize,
pub bytes_removed: usize,
}
#[allow(
clippy::too_many_arguments,
reason = "patch primitive: byte span + content + validation config"
)]
pub fn apply_patch_with_validation(
file_path: &Path,
start: usize,
end: usize,
new_content: &str,
workspace_dir: &Path,
language: SymbolLanguage,
analyzer_mode: AnalyzerMode,
strict: bool,
skip: bool,
) -> Result<(String, String)> {
let db_path = workspace_dir.join(".magellan/magellan.db"); let pre_checks =
verify::pre_verify_patch(file_path, None, workspace_dir, &db_path, strict, skip)?;
for check in &pre_checks {
if check.is_blocking() {
return Err(SpliceError::PreVerificationFailed {
check: format!("{:?}", check),
});
}
}
for check in &pre_checks {
if check.is_warning() {
log::warn!("Pre-verification warning: {:?}", check);
}
}
if let Some(function_name) = extract_function_name_from_patch(new_content) {
if let Ok(complexity) =
crate::cfg_analysis::check_function_complexity(&db_path, &function_name, file_path)
{
match complexity.risk_level {
crate::cfg_analysis::RiskLevel::VeryHigh => {
log::warn!(
"VERY HIGH COMPLEXITY: Function '{}' has branch distance={}, dominator depth={}, loop nesting={}. \
Consider manual review before automated refactoring.",
function_name,
complexity.max_branch_distance,
complexity.max_dominator_depth,
complexity.max_loop_nesting
);
}
crate::cfg_analysis::RiskLevel::High => {
log::warn!(
"HIGH COMPLEXITY: Function '{}' has branch distance={}, dominator depth={}. \
Automated refactoring may be risky.",
function_name,
complexity.max_branch_distance,
complexity.max_dominator_depth
);
}
crate::cfg_analysis::RiskLevel::Medium => {
log::info!(
"Medium complexity: Function '{}' (branch distance={}, dominator depth={})",
function_name,
complexity.max_branch_distance,
complexity.max_dominator_depth
);
}
crate::cfg_analysis::RiskLevel::Low => {
log::debug!(
"Low complexity: Function '{}' (branch distance={})",
function_name,
complexity.max_branch_distance
);
}
}
}
}
let replaced = io_ext::read(file_path)?;
let before_hash = compute_hash(&replaced);
if start > end || end > replaced.len() {
return Err(SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start,
end,
file_size: replaced.len(),
});
}
std::str::from_utf8(&replaced[start..end]).map_err(|_| SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start,
end,
file_size: replaced.len(),
})?;
let mut rope = Rope::from_str(std::str::from_utf8(&replaced)?);
let start_char = rope.byte_to_char(start);
let end_char = rope.byte_to_char(end);
rope.remove(start_char..end_char);
rope.insert(start_char, new_content);
let patched_content = rope.to_string();
let patched_bytes = patched_content.into_bytes();
write_atomic(file_path, &patched_bytes, "patch")?;
match gates::run_validation_gates(file_path, workspace_dir, language, analyzer_mode.clone()) {
Ok(_) => {}
Err(e) => {
log::warn!("Validation failed, rolling back patch: {:?}", e);
if let Err(rollback_err) = write_atomic(file_path, &replaced, "rollback") {
log::error!(
"Failed to restore {} during rollback: {}",
file_path.display(),
rollback_err
);
}
return Err(e);
}
}
let refreshed_bytes = io_ext::read(file_path)?;
let after_hash = compute_hash(&refreshed_bytes);
let mut post_verify =
verify::verify_after_patch(file_path, workspace_dir, &before_hash, analyzer_mode)?;
let localized = verify::verify_localized_change(file_path, &replaced, (start, end));
match &localized {
Ok(true) => {
log::info!("Localized change verification passed");
}
Ok(false) => {
log::warn!("Localized change verification detected modifications outside target span");
post_verify.add_warning("File modified outside target span");
}
Err(e) => {
log::warn!("Localized change verification failed: {}", e);
post_verify.add_warning(format!("Could not verify localized change: {}", e));
}
}
for warning in &post_verify.warnings {
log::warn!("Post-verification warning: {}", warning);
}
for error in &post_verify.errors {
log::error!("Post-verification error: {}", error);
}
log::info!(
"Post-verification: syntax={}, compiler={}, semantic={}, changed={}",
post_verify.syntax_ok,
post_verify.compiler_ok,
post_verify.semantic_ok,
post_verify.file_changed(),
);
Ok((before_hash, after_hash))
}
pub fn apply_batch_with_validation(
batches: &[SpanBatch],
workspace_dir: &Path,
language: SymbolLanguage,
analyzer_mode: AnalyzerMode,
) -> Result<Vec<FilePatchSummary>> {
if batches.is_empty() {
return Ok(Vec::new());
}
let mut grouped: BTreeMap<PathBuf, Vec<SpanReplacement>> = BTreeMap::new();
for batch in batches {
for replacement in batch.replacements() {
grouped
.entry(replacement.file.clone())
.or_default()
.push(replacement.clone());
}
}
let mut applied = Vec::new();
for (file_path, mut replacements) in grouped {
if replacements.is_empty() {
continue;
}
let pre_check = verify::verify_file_ready(&file_path, None, workspace_dir);
if pre_check.is_blocking() {
log::warn!(
"Skipping {:?}: pre-verification failed: {:?}",
file_path,
pre_check
);
continue;
}
replacements.sort_by_key(|r| std::cmp::Reverse(r.start));
let (replaced, before_hash) = read_with_hash(&file_path)?;
validate_replacements(&file_path, &replacements, &replaced)?;
let patched_bytes = apply_replacements(&replaced, &replacements)?;
let after_hash = compute_hash(&patched_bytes);
if let Err(write_err) = write_atomic(&file_path, &patched_bytes, "batch") {
rollback_files(&applied);
return Err(write_err);
}
applied.push(AppliedFile {
file: file_path,
replaced,
before_hash,
after_hash,
});
}
let validation = run_batch_validations(&applied, workspace_dir, language, analyzer_mode);
if let Err(err) = validation {
rollback_files(&applied);
return Err(err);
}
Ok(applied
.into_iter()
.map(|file| FilePatchSummary {
file: file.file,
before_hash: file.before_hash,
after_hash: file.after_hash,
})
.collect())
}
pub fn preview_patch(
file_path: &Path,
start: usize,
end: usize,
new_content: &str,
workspace_root: &Path,
language: SymbolLanguage,
analyzer_mode: AnalyzerMode,
) -> Result<(FilePatchSummary, PreviewReport)> {
let preview_workspace = preview::clone_workspace_for_preview(workspace_root)?;
let relative = file_path
.strip_prefix(workspace_root)
.map_err(|_| SpliceError::Other("File not under workspace root".to_string()))?;
let preview_file = preview_workspace.path().join(relative);
let (before_hash, after_hash) = apply_patch_with_validation(
&preview_file,
start,
end,
new_content,
preview_workspace.path(),
language,
analyzer_mode,
false, true, )?;
let preview_report = compute_preview_report(file_path, start, end, new_content)?;
Ok((
FilePatchSummary {
file: file_path.to_path_buf(),
before_hash,
after_hash,
},
preview_report,
))
}
pub fn preview_patch_with_content(
file_path: &Path,
start: usize,
end: usize,
new_content: &str,
workspace_root: &Path,
language: SymbolLanguage,
analyzer_mode: AnalyzerMode,
) -> Result<(FilePatchSummary, PreviewReport, String, String)> {
let file_path = std::fs::canonicalize(file_path).map_err(|e| SpliceError::Io {
path: file_path.to_path_buf(),
source: e,
})?;
let workspace_root = std::fs::canonicalize(workspace_root).map_err(|e| SpliceError::Io {
path: workspace_root.to_path_buf(),
source: e,
})?;
let preview_workspace = preview::clone_workspace_for_preview(&workspace_root)?;
let relative = file_path.strip_prefix(&workspace_root).map_err(|_| {
SpliceError::Other(format!(
"File {} not under workspace root {}",
file_path.display(),
workspace_root.display()
))
})?;
let preview_file = preview_workspace.path().join(relative);
let before_content = io_ext::read_to_string(&preview_file)?;
let (before_hash, after_hash) = apply_patch_with_validation(
&preview_file,
start,
end,
new_content,
preview_workspace.path(),
language,
analyzer_mode,
false, true, )?;
let after_content = io_ext::read_to_string(&preview_file)?;
let preview_report = compute_preview_report(&file_path, start, end, new_content)?;
Ok((
FilePatchSummary {
file: file_path.to_path_buf(),
before_hash,
after_hash,
},
preview_report,
before_content,
after_content,
))
}
fn compute_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let result = hasher.finalize();
format!("{:x}", result)
}
pub fn replace_span(file_path: &Path, start: usize, end: usize, new_content: &str) -> Result<()> {
let replaced = io_ext::read_to_string(file_path)?;
let file_size = replaced.len();
if start > end || end > file_size {
return Err(SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start,
end,
file_size,
});
}
if end > file_size || start > end {
return Err(SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start,
end,
file_size,
});
}
let mut rope = Rope::from_str(&replaced);
let start_char = rope.byte_to_char(start);
let end_char = rope.byte_to_char(end);
rope.remove(start_char..end_char);
rope.insert(start_char, new_content);
io_ext::write(file_path, rope.to_string())?;
Ok(())
}
fn run_batch_validations(
files: &[AppliedFile],
workspace_dir: &Path,
language: SymbolLanguage,
analyzer_mode: AnalyzerMode,
) -> Result<()> {
if files.is_empty() {
return Ok(());
}
let mut requires_rust_validation = false;
for file in files {
gates::gate_tree_sitter_reparse(&file.file, language)?;
if language == SymbolLanguage::Rust {
requires_rust_validation = true;
} else {
gates::gate_compiler_validation(&file.file, workspace_dir, language)?;
}
}
if requires_rust_validation {
gates::gate_cargo_check(workspace_dir)?;
if language == SymbolLanguage::Rust && analyzer_mode != AnalyzerMode::Off {
use crate::validate::gate_rust_analyzer;
gate_rust_analyzer(workspace_dir, analyzer_mode)?;
}
}
Ok(())
}
fn validate_replacements(
file_path: &Path,
replacements: &[SpanReplacement],
replaced: &[u8],
) -> Result<()> {
if replacements.is_empty() {
return Ok(());
}
let file_len = replaced.len();
let mut sorted = replacements.to_vec();
sorted.sort_by_key(|r| r.start);
let mut previous_end: Option<usize> = None;
for replacement in &sorted {
if replacement.start > replacement.end || replacement.end > file_len {
return Err(SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start: replacement.start,
end: replacement.end,
file_size: file_len,
});
}
std::str::from_utf8(&replaced[replacement.start..replacement.end]).map_err(|_| {
SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start: replacement.start,
end: replacement.end,
file_size: file_len,
}
})?;
if let Some(prev_end) = previous_end {
if replacement.start < prev_end {
return Err(SpliceError::Other(format!(
"Overlapping replacements detected in {}",
file_path.display()
)));
}
}
previous_end = Some(replacement.end);
}
Ok(())
}
fn apply_replacements(replaced: &[u8], replacements: &[SpanReplacement]) -> Result<Vec<u8>> {
let content = std::str::from_utf8(replaced)?;
let mut rope = Rope::from_str(content);
for replacement in replacements {
let start_char = rope.byte_to_char(replacement.start);
let end_char = rope.byte_to_char(replacement.end);
rope.remove(start_char..end_char);
rope.insert(start_char, &replacement.content);
}
Ok(rope.to_string().into_bytes())
}
fn read_with_hash(path: &Path) -> Result<(Vec<u8>, String)> {
let data = io_ext::read(path)?;
let hash = compute_hash(&data);
Ok((data, hash))
}
fn rollback_files(files: &[AppliedFile]) {
for file in files.iter().rev() {
if let Err(err) = write_atomic(&file.file, &file.replaced, "rollback") {
log::error!("Rollback failed for {}: {}", file.file.display(), err);
}
}
}
fn write_atomic(file_path: &Path, content: &[u8], suffix: &str) -> Result<()> {
let temp_path = temp_path_for(file_path, suffix)?;
let mut temp_file = File::create(&temp_path).map_err(|source| SpliceError::Io {
path: temp_path.clone(),
source,
})?;
temp_file
.write_all(content)
.map_err(|source| SpliceError::Io {
path: temp_path.clone(),
source,
})?;
temp_file.sync_all().map_err(|source| SpliceError::Io {
path: temp_path.clone(),
source,
})?;
std::fs::rename(&temp_path, file_path).map_err(|source| SpliceError::Io {
path: file_path.to_path_buf(),
source,
})?;
Ok(())
}
fn temp_path_for(file_path: &Path, suffix: &str) -> Result<PathBuf> {
let file_dir = file_path
.parent()
.ok_or_else(|| SpliceError::Other("File has no parent directory".to_string()))?;
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("tmp");
Ok(file_dir.join(format!(".{}.{}.tmp", file_name, suffix)))
}
struct AppliedFile {
file: PathBuf,
replaced: Vec<u8>,
before_hash: String,
after_hash: String,
}
pub fn compute_preview_report(
file_path: &Path,
start: usize,
end: usize,
new_content: &str,
) -> Result<PreviewReport> {
let replaced = io_ext::read(file_path)?;
let source = std::str::from_utf8(&replaced)?;
let rope = Rope::from_str(source);
let start_line = rope.byte_to_line(start);
let end_line = if end == start {
start_line
} else if end == replaced.len() {
rope.len_lines().saturating_sub(1)
} else {
rope.byte_to_line(end)
};
let lines_removed = if end > start {
source[start..end].lines().count()
} else {
0
};
let lines_added = if new_content.is_empty() {
0
} else {
new_content.lines().count()
};
let bytes_removed = end.saturating_sub(start);
let bytes_added = new_content.len();
Ok(PreviewReport {
file: file_path.to_string_lossy().into_owned(),
line_start: start_line + 1,
line_end: if lines_removed == 0 {
start_line + 1
} else {
end_line + 1
},
lines_added,
lines_removed,
bytes_added,
bytes_removed,
})
}
pub fn validate_utf8_span(file_path: &Path, source: &str, start: usize, end: usize) -> Result<()> {
let file_size = source.len();
if end > file_size || start > end {
return Err(SpliceError::InvalidSpan {
file: file_path.to_path_buf(),
start,
end,
file_size,
});
}
let _ = &source[start..end];
Ok(())
}
fn extract_function_name_from_patch(patch_content: &str) -> Option<String> {
use regex::Regex;
let fn_regex =
Regex::new(r"(?m)^(?:pub\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)\s*\(").ok()?;
fn_regex
.captures(patch_content)
.map(|caps| caps[1].to_string())
}
#[cfg(test)]
#[path = "patch_tests.rs"]
mod tests;