use std::fmt;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, error, info, warn};
use super::file_safety::{AtomicWriter, AtomicWriteError};
use super::fix_validator::{validate_fix as validate_fix_content, FixValidation};
use super::rules::{Diagnostic, Edit, Fix, FixConfidence, RuleCode};
const DEFAULT_MAX_FILES: usize = 50;
const DEFAULT_MAX_LINES_PER_FILE: usize = 100;
const DEFAULT_MIN_CONFIDENCE: f64 = 0.8;
#[derive(Debug, Clone)]
pub struct FixApplicatorConfig {
pub dry_run: bool,
pub backup_dir: Option<PathBuf>,
pub max_files: usize,
pub max_lines_per_file: usize,
pub force: bool,
pub interactive: bool,
pub min_confidence: f64,
pub verbose: bool,
}
impl Default for FixApplicatorConfig {
fn default() -> Self {
Self {
dry_run: true,
backup_dir: None,
max_files: DEFAULT_MAX_FILES,
max_lines_per_file: DEFAULT_MAX_LINES_PER_FILE,
force: false,
interactive: false,
min_confidence: DEFAULT_MIN_CONFIDENCE,
verbose: false,
}
}
}
impl FixApplicatorConfig {
pub fn dry_run() -> Self {
Self {
dry_run: true,
..Default::default()
}
}
pub fn apply() -> Self {
Self {
dry_run: false,
..Default::default()
}
}
pub fn with_interactive(mut self, interactive: bool) -> Self {
self.interactive = interactive;
self
}
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
pub fn with_backup_dir(mut self, dir: PathBuf) -> Self {
self.backup_dir = Some(dir);
self
}
pub fn with_max_files(mut self, max: usize) -> Self {
self.max_files = max;
self
}
pub fn with_max_lines_per_file(mut self, max: usize) -> Self {
self.max_lines_per_file = max;
self
}
pub fn with_min_confidence(mut self, threshold: f64) -> Self {
self.min_confidence = threshold;
self
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
}
#[derive(Debug)]
pub enum FixError {
ValidationFailed {
file: PathBuf,
reason: String,
validation: FixValidation,
},
UnsafeFix {
file: PathBuf,
reason: String,
},
LowConfidence {
file: PathBuf,
confidence: FixConfidence,
required: f64,
},
TooManyFiles {
count: usize,
limit: usize,
},
TooManyLines {
file: PathBuf,
count: usize,
limit: usize,
},
FileRead {
file: PathBuf,
source: io::Error,
},
AtomicWrite {
file: PathBuf,
source: AtomicWriteError,
},
UserAborted,
UserSkipped {
file: PathBuf,
},
TempFileCreation {
file: PathBuf,
source: io::Error,
},
PreparationFailed {
reason: String,
failures: Vec<(PathBuf, String)>,
},
CommitFailed {
reason: String,
successful_rollbacks: Vec<PathBuf>,
failed_rollbacks: Vec<(PathBuf, String)>,
},
}
impl fmt::Display for FixError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FixError::ValidationFailed { file, reason, validation } => {
write!(
f,
"Fix validation failed for {}: {} (confidence: {:.2})",
file.display(),
reason,
validation.confidence
)
}
FixError::UnsafeFix { file, reason } => {
write!(f, "Fix marked as unsafe for {}: {}", file.display(), reason)
}
FixError::LowConfidence { file, confidence, required } => {
write!(
f,
"Fix confidence too low for {}: {} < {:.2}",
file.display(),
confidence,
required
)
}
FixError::TooManyFiles { count, limit } => {
write!(
f,
"Safety limit exceeded: {} files to modify, limit is {} (use --force to override)",
count, limit
)
}
FixError::TooManyLines { file, count, limit } => {
write!(
f,
"Safety limit exceeded for {}: {} lines to change, limit is {} (use --force to override)",
file.display(),
count,
limit
)
}
FixError::FileRead { file, source } => {
write!(f, "Failed to read file {}: {}", file.display(), source)
}
FixError::AtomicWrite { file, source } => {
write!(f, "Failed to write file {}: {}", file.display(), source)
}
FixError::UserAborted => {
write!(f, "Operation aborted by user")
}
FixError::UserSkipped { file } => {
write!(f, "Fix skipped by user for {}", file.display())
}
FixError::TempFileCreation { file, source } => {
write!(
f,
"Failed to create temp file for {}: {}",
file.display(),
source
)
}
FixError::PreparationFailed { reason, failures } => {
write!(f, "Preparation phase failed: {}. ", reason)?;
write!(f, "{} file(s) failed validation", failures.len())
}
FixError::CommitFailed {
reason,
successful_rollbacks,
failed_rollbacks,
} => {
write!(f, "Commit phase failed: {}. ", reason)?;
write!(
f,
"Rolled back {} file(s), {} rollback(s) failed",
successful_rollbacks.len(),
failed_rollbacks.len()
)
}
}
}
}
impl std::error::Error for FixError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FixError::FileRead { source, .. } => Some(source),
FixError::TempFileCreation { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug)]
pub struct RollbackError {
pub successful: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
}
impl fmt::Display for RollbackError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Rollback completed with {} success(es) and {} failure(s)",
self.successful.len(),
self.failed.len()
)?;
if !self.failed.is_empty() {
write!(f, ". Failed files: ")?;
for (i, (path, err)) in self.failed.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{} ({})", path.display(), err)?;
}
}
Ok(())
}
}
impl std::error::Error for RollbackError {}
#[derive(Debug)]
pub struct CommitError {
pub reason: String,
pub committed: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
}
impl fmt::Display for CommitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Commit failed: {}. {} committed, {} failed",
self.reason,
self.committed.len(),
self.failed.len()
)
}
}
impl std::error::Error for CommitError {}
#[derive(Debug, Clone)]
pub struct AppliedFix {
pub file: PathBuf,
pub backup_path: Option<PathBuf>,
pub temp_path: Option<PathBuf>,
pub rule: RuleCode,
pub message: String,
pub lines_changed: usize,
pub validation: FixValidation,
pub original_content: String,
pub new_content: String,
pub applied_at: SystemTime,
}
impl AppliedFix {
pub fn can_rollback(&self) -> bool {
self.backup_path
.as_ref()
.map(|p| p.exists())
.unwrap_or(false)
|| !self.original_content.is_empty()
}
}
#[derive(Debug)]
struct PreparedFix {
file: PathBuf,
temp_path: PathBuf,
original_content: String,
new_content: String,
validation: FixValidation,
rule: RuleCode,
message: String,
lines_changed: usize,
}
pub trait ProgressReporter: Send + Sync {
fn on_file_start(&self, file: &Path, index: usize, total: usize);
fn on_file_success(&self, file: &Path, fix_message: &str);
fn on_file_failure(&self, file: &Path, error: &str);
fn on_file_skipped(&self, file: &Path, reason: &str);
fn on_preparation_complete(&self, prepared: usize, failed: usize, skipped: usize);
fn on_commit_start(&self, files_count: usize);
fn on_commit_complete(&self, committed: usize, failed: usize);
fn on_rollback(&self, rolled_back: usize, failed: usize);
fn on_summary(&self, summary: &FixSummary);
}
pub struct ConsoleProgressReporter {
verbose: bool,
}
impl ConsoleProgressReporter {
pub fn new(verbose: bool) -> Self {
Self { verbose }
}
}
impl ProgressReporter for ConsoleProgressReporter {
fn on_file_start(&self, file: &Path, index: usize, total: usize) {
if self.verbose {
eprintln!(
"\x1b[36m[{}/{}]\x1b[0m Processing: {}",
index + 1,
total,
file.display()
);
}
}
fn on_file_success(&self, file: &Path, fix_message: &str) {
if self.verbose {
eprintln!(
" \x1b[32m[OK]\x1b[0m {} - {}",
file.file_name().unwrap_or_default().to_string_lossy(),
fix_message
);
}
}
fn on_file_failure(&self, file: &Path, error: &str) {
eprintln!(
" \x1b[31m[FAIL]\x1b[0m {} - {}",
file.file_name().unwrap_or_default().to_string_lossy(),
error
);
}
fn on_file_skipped(&self, file: &Path, reason: &str) {
if self.verbose {
eprintln!(
" \x1b[33m[SKIP]\x1b[0m {} - {}",
file.file_name().unwrap_or_default().to_string_lossy(),
reason
);
}
}
fn on_preparation_complete(&self, prepared: usize, failed: usize, skipped: usize) {
eprintln!();
eprintln!(
"\x1b[1mPreparation complete:\x1b[0m {} ready, {} failed, {} skipped",
prepared, failed, skipped
);
}
fn on_commit_start(&self, files_count: usize) {
if self.verbose {
eprintln!();
eprintln!("\x1b[1mCommitting {} file(s)...\x1b[0m", files_count);
}
}
fn on_commit_complete(&self, committed: usize, failed: usize) {
if failed == 0 {
eprintln!(
"\x1b[1;32mCommit successful:\x1b[0m {} file(s) modified",
committed
);
} else {
eprintln!(
"\x1b[1;31mCommit partially failed:\x1b[0m {} committed, {} failed",
committed, failed
);
}
}
fn on_rollback(&self, rolled_back: usize, failed: usize) {
if failed == 0 {
eprintln!(
"\x1b[1;33mRollback complete:\x1b[0m {} file(s) restored",
rolled_back
);
} else {
eprintln!(
"\x1b[1;31mRollback partially failed:\x1b[0m {} restored, {} failed",
rolled_back, failed
);
}
}
fn on_summary(&self, summary: &FixSummary) {
eprintln!();
eprintln!("\x1b[1m=== Fix Summary ===\x1b[0m");
eprintln!(" Files processed: {}", summary.files_processed);
eprintln!(" Fixes applied: {}", summary.fixes_applied);
eprintln!(" Fixes skipped: {}", summary.fixes_skipped);
eprintln!(" Fixes failed: {}", summary.fixes_failed);
eprintln!(" Total lines: {}", summary.total_lines_changed);
if !summary.skipped_reasons.is_empty() && self.verbose {
eprintln!();
eprintln!("\x1b[1mSkipped fixes:\x1b[0m");
for (file, reason) in &summary.skipped_reasons {
eprintln!(" - {}: {}", file.display(), reason);
}
}
if !summary.failed_reasons.is_empty() {
eprintln!();
eprintln!("\x1b[1;31mFailed fixes:\x1b[0m");
for (file, reason) in &summary.failed_reasons {
eprintln!(" - {}: {}", file.display(), reason);
}
}
}
}
#[derive(Debug, Default)]
pub struct FixSummary {
pub files_processed: usize,
pub fixes_applied: usize,
pub fixes_skipped: usize,
pub fixes_failed: usize,
pub total_lines_changed: usize,
pub skipped_reasons: Vec<(PathBuf, String)>,
pub failed_reasons: Vec<(PathBuf, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractiveChoice {
Yes,
No,
Quit,
All,
}
pub trait InteractivePrompt: Send + Sync {
fn prompt(&self, diagnostic: &Diagnostic, fix: &Fix) -> InteractiveChoice;
}
pub struct StdinInteractivePrompt;
impl InteractivePrompt for StdinInteractivePrompt {
fn prompt(&self, diagnostic: &Diagnostic, fix: &Fix) -> InteractiveChoice {
println!();
println!("\x1b[1;36m=== Fix Proposal ===\x1b[0m");
println!(" File: {}", diagnostic.file.display());
println!(" Rule: {} ({})", diagnostic.rule, diagnostic.rule.name());
println!(
" Location: lines {}-{}",
diagnostic.range.start_line, diagnostic.range.end_line
);
println!(" Issue: {}", diagnostic.message);
println!(" Fix: {}", fix.message);
println!(" Confidence: {}", fix.confidence);
for edit in &fix.edits {
if !edit.new_text.is_empty() {
let preview: String = edit
.new_text
.lines()
.take(5)
.collect::<Vec<_>>()
.join("\n ");
println!();
println!("\x1b[32m+++ New content:\x1b[0m");
println!(" {}", preview);
if edit.new_text.lines().count() > 5 {
println!(" ... ({} more lines)", edit.new_text.lines().count() - 5);
}
} else {
println!();
println!("\x1b[31m--- Lines to delete\x1b[0m");
}
}
println!();
print!("Apply this fix? [\x1b[32my\x1b[0mes/\x1b[31mn\x1b[0mo/\x1b[33mq\x1b[0muit/\x1b[36ma\x1b[0mll]: ");
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_err() {
return InteractiveChoice::Quit;
}
match input.trim().to_lowercase().as_str() {
"y" | "yes" => InteractiveChoice::Yes,
"n" | "no" => InteractiveChoice::No,
"q" | "quit" => InteractiveChoice::Quit,
"a" | "all" => InteractiveChoice::All,
_ => {
println!("Invalid choice, treating as 'no'");
InteractiveChoice::No
}
}
}
}
pub struct FixApplicator {
config: FixApplicatorConfig,
backup_dir: PathBuf,
prepared_fixes: Vec<PreparedFix>,
applied_fixes: Vec<AppliedFix>,
progress: Box<dyn ProgressReporter>,
interactive_prompt: Option<Box<dyn InteractivePrompt>>,
auto_apply_remaining: bool,
summary: FixSummary,
}
impl FixApplicator {
pub fn new(config: FixApplicatorConfig) -> Self {
let backup_dir = config
.backup_dir
.clone()
.unwrap_or_else(|| std::env::temp_dir().join("fstar-lint-backups"));
let progress: Box<dyn ProgressReporter> =
Box::new(ConsoleProgressReporter::new(config.verbose));
let interactive_prompt: Option<Box<dyn InteractivePrompt>> = if config.interactive {
Some(Box::new(StdinInteractivePrompt))
} else {
None
};
Self {
config,
backup_dir,
prepared_fixes: Vec::new(),
applied_fixes: Vec::new(),
progress,
interactive_prompt,
auto_apply_remaining: false,
summary: FixSummary::default(),
}
}
pub fn with_progress_reporter(mut self, reporter: Box<dyn ProgressReporter>) -> Self {
self.progress = reporter;
self
}
pub fn with_interactive_prompt(mut self, prompt: Box<dyn InteractivePrompt>) -> Self {
self.interactive_prompt = Some(prompt);
self
}
pub fn summary(&self) -> &FixSummary {
&self.summary
}
pub fn applied_fixes(&self) -> &[AppliedFix] {
&self.applied_fixes
}
pub fn check_safety_limits(&self, files_count: usize) -> Result<(), FixError> {
if !self.config.force && files_count > self.config.max_files {
return Err(FixError::TooManyFiles {
count: files_count,
limit: self.config.max_files,
});
}
Ok(())
}
pub fn apply_batch(
&mut self,
diagnostics: &[Diagnostic],
) -> Result<Vec<AppliedFix>, FixError> {
let fixable: Vec<&Diagnostic> = diagnostics.iter().filter(|d| d.fix.is_some()).collect();
if fixable.is_empty() {
info!("No fixable diagnostics found");
return Ok(Vec::new());
}
let unique_files: std::collections::HashSet<&PathBuf> =
fixable.iter().map(|d| &d.file).collect();
self.check_safety_limits(unique_files.len())?;
let total = fixable.len();
let mut prepared_count = 0;
let mut failed_count = 0;
let mut skipped_count = 0;
for (index, diag) in fixable.iter().enumerate() {
self.summary.files_processed += 1;
self.progress.on_file_start(&diag.file, index, total);
match self.prepare_fix(diag) {
Ok(true) => {
prepared_count += 1;
}
Ok(false) => {
skipped_count += 1;
}
Err(FixError::UserAborted) => {
self.progress.on_preparation_complete(prepared_count, failed_count, skipped_count);
return Err(FixError::UserAborted);
}
Err(e) => {
failed_count += 1;
self.summary.fixes_failed += 1;
self.summary.failed_reasons.push((diag.file.clone(), e.to_string()));
self.progress.on_file_failure(&diag.file, &e.to_string());
}
}
}
self.progress
.on_preparation_complete(prepared_count, failed_count, skipped_count);
if self.config.dry_run {
self.cleanup_temp_files();
self.summary.fixes_applied = prepared_count;
self.summary.fixes_skipped = skipped_count;
self.progress.on_summary(&self.summary);
return Ok(self.prepared_fixes.drain(..).map(|pf| AppliedFix {
file: pf.file,
backup_path: None,
temp_path: Some(pf.temp_path),
rule: pf.rule,
message: pf.message,
lines_changed: pf.lines_changed,
validation: pf.validation,
original_content: pf.original_content,
new_content: pf.new_content,
applied_at: SystemTime::now(),
}).collect());
}
if prepared_count == 0 {
info!("No fixes prepared, nothing to commit");
self.progress.on_summary(&self.summary);
return Ok(Vec::new());
}
self.progress.on_commit_start(prepared_count);
match self.commit() {
Ok(()) => {
self.summary.fixes_applied = self.applied_fixes.len();
let total_lines: usize = self.applied_fixes.iter().map(|f| f.lines_changed).sum();
self.summary.total_lines_changed = total_lines;
self.progress
.on_commit_complete(self.applied_fixes.len(), 0);
self.progress.on_summary(&self.summary);
Ok(self.applied_fixes.clone())
}
Err(commit_err) => {
let rollback_result = self.rollback_all();
self.progress.on_summary(&self.summary);
let (successful_rollbacks, failed_rollbacks) = match rollback_result {
Ok(()) => (self.prepared_fixes.iter().map(|pf| pf.file.clone()).collect(), Vec::new()),
Err(rollback_err) => {
error!(
"Commit failed and rollback had errors: {}",
rollback_err
);
(rollback_err.successful, rollback_err.failed)
}
};
Err(FixError::CommitFailed {
reason: commit_err.reason,
successful_rollbacks,
failed_rollbacks,
})
}
}
}
fn prepare_fix(&mut self, diagnostic: &Diagnostic) -> Result<bool, FixError> {
let fix = match &diagnostic.fix {
Some(f) => f,
None => return Ok(false),
};
if !fix.is_safe {
let reason = fix
.unsafe_reason
.clone()
.unwrap_or_else(|| "marked as unsafe".to_string());
self.summary.fixes_skipped += 1;
self.summary
.skipped_reasons
.push((diagnostic.file.clone(), reason.clone()));
self.progress.on_file_skipped(&diagnostic.file, &reason);
return Ok(false);
}
if fix.confidence != FixConfidence::High {
let reason = format!("confidence too low ({})", fix.confidence);
self.summary.fixes_skipped += 1;
self.summary
.skipped_reasons
.push((diagnostic.file.clone(), reason.clone()));
self.progress.on_file_skipped(&diagnostic.file, &reason);
return Ok(false);
}
if let Some(prompt) = &self.interactive_prompt {
if !self.auto_apply_remaining {
match prompt.prompt(diagnostic, fix) {
InteractiveChoice::Yes => {}
InteractiveChoice::No => {
self.summary.fixes_skipped += 1;
self.summary
.skipped_reasons
.push((diagnostic.file.clone(), "user skipped".to_string()));
self.progress
.on_file_skipped(&diagnostic.file, "user skipped");
return Ok(false);
}
InteractiveChoice::Quit => {
return Err(FixError::UserAborted);
}
InteractiveChoice::All => {
self.auto_apply_remaining = true;
}
}
}
}
let original_content = fs::read_to_string(&diagnostic.file).map_err(|e| {
FixError::FileRead {
file: diagnostic.file.clone(),
source: e,
}
})?;
let new_content = self.build_fixed_content(&original_content, &fix.edits)?;
let lines_changed = count_lines_changed(&original_content, &new_content);
if !self.config.force && lines_changed > self.config.max_lines_per_file {
return Err(FixError::TooManyLines {
file: diagnostic.file.clone(),
count: lines_changed,
limit: self.config.max_lines_per_file,
});
}
let validation = validate_fix_content(&original_content, &new_content, &diagnostic.file);
if !validation.can_auto_apply(self.config.min_confidence) {
let reason = if !validation.is_safe {
"validation marked fix as unsafe".to_string()
} else {
format!(
"confidence {:.2} below threshold {:.2}",
validation.confidence, self.config.min_confidence
)
};
return Err(FixError::ValidationFailed {
file: diagnostic.file.clone(),
reason,
validation,
});
}
let temp_path = self.create_temp_file(&diagnostic.file, &new_content)?;
let prepared = PreparedFix {
file: diagnostic.file.clone(),
temp_path,
original_content,
new_content,
validation,
rule: diagnostic.rule,
message: fix.message.clone(),
lines_changed,
};
self.progress
.on_file_success(&diagnostic.file, &fix.message);
self.prepared_fixes.push(prepared);
Ok(true)
}
fn build_fixed_content(&self, original: &str, edits: &[Edit]) -> Result<String, FixError> {
if edits.is_empty() {
return Ok(original.to_string());
}
let edit = &edits[0];
let lines: Vec<&str> = original.lines().collect();
let start_line = edit.range.start_line.saturating_sub(1);
let end_line = edit.range.end_line.saturating_sub(1);
let mut new_content = String::new();
for line in lines.iter().take(start_line) {
new_content.push_str(line);
new_content.push('\n');
}
if !edit.new_text.is_empty() {
new_content.push_str(&edit.new_text);
if !edit.new_text.ends_with('\n') {
new_content.push('\n');
}
}
for line in lines.iter().skip(end_line + 1) {
new_content.push_str(line);
new_content.push('\n');
}
if original.ends_with('\n') && !new_content.ends_with('\n') {
new_content.push('\n');
} else if !original.ends_with('\n') && new_content.ends_with('\n') {
new_content.pop();
}
Ok(new_content)
}
fn create_temp_file(&self, target: &Path, content: &str) -> Result<PathBuf, FixError> {
let parent = target.parent().unwrap_or(Path::new("."));
let filename = target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_path = parent.join(format!(".{}.{}.fstar-lint-tmp", filename, timestamp));
let mut temp_file = File::create(&temp_path).map_err(|e| FixError::TempFileCreation {
file: target.to_path_buf(),
source: e,
})?;
temp_file
.write_all(content.as_bytes())
.map_err(|e| FixError::TempFileCreation {
file: target.to_path_buf(),
source: e,
})?;
temp_file.sync_all().map_err(|e| FixError::TempFileCreation {
file: target.to_path_buf(),
source: e,
})?;
debug!("Created temp file: {}", temp_path.display());
Ok(temp_path)
}
pub fn commit(&mut self) -> Result<(), CommitError> {
if self.config.dry_run {
info!("Dry-run mode: skipping actual commit");
return Ok(());
}
if self.prepared_fixes.is_empty() {
info!("No prepared fixes to commit");
return Ok(());
}
let atomic_writer = AtomicWriter::new();
let mut committed: Vec<PathBuf> = Vec::new();
let mut failed: Vec<(PathBuf, String)> = Vec::new();
for prepared in self.prepared_fixes.drain(..) {
let backup_path = match atomic_writer.write_with_backup(&prepared.file, &prepared.new_content)
{
Ok(backup) => {
info!(
"Committed {} (backup: {})",
prepared.file.display(),
backup.display()
);
Some(backup)
}
Err(e) => {
error!(
"Failed to commit {}: {}",
prepared.file.display(),
e
);
failed.push((prepared.file.clone(), e.to_string()));
if !committed.is_empty() {
warn!(
"Commit failed, attempting rollback of {} file(s)",
committed.len()
);
}
continue;
}
};
if let Err(e) = fs::remove_file(&prepared.temp_path) {
warn!(
"Failed to remove temp file {}: {}",
prepared.temp_path.display(),
e
);
}
let applied = AppliedFix {
file: prepared.file.clone(),
backup_path,
temp_path: None,
rule: prepared.rule,
message: prepared.message,
lines_changed: prepared.lines_changed,
validation: prepared.validation,
original_content: prepared.original_content,
new_content: prepared.new_content,
applied_at: SystemTime::now(),
};
committed.push(prepared.file);
self.applied_fixes.push(applied);
}
if !failed.is_empty() {
return Err(CommitError {
reason: "Some files failed to commit".to_string(),
committed,
failed,
});
}
Ok(())
}
pub fn rollback_all(&mut self) -> Result<(), RollbackError> {
let atomic_writer = AtomicWriter::new();
let mut successful: Vec<PathBuf> = Vec::new();
let mut failed: Vec<(PathBuf, String)> = Vec::new();
for applied in self.applied_fixes.drain(..) {
if let Some(backup) = &applied.backup_path {
if backup.exists() {
match atomic_writer.rollback(&applied.file, backup) {
Ok(()) => {
info!("Rolled back {} from backup", applied.file.display());
successful.push(applied.file.clone());
continue;
}
Err(e) => {
warn!(
"Failed to rollback {} from backup: {}",
applied.file.display(),
e
);
}
}
}
}
if !applied.original_content.is_empty() {
match atomic_writer.write(&applied.file, &applied.original_content) {
Ok(()) => {
info!(
"Rolled back {} from original content",
applied.file.display()
);
successful.push(applied.file);
}
Err(e) => {
error!(
"Failed to rollback {} from original content: {}",
applied.file.display(),
e
);
failed.push((applied.file, e.to_string()));
}
}
} else {
failed.push((applied.file, "No backup or original content available".to_string()));
}
}
let rolled_back = successful.len();
let failed_count = failed.len();
self.progress.on_rollback(rolled_back, failed_count);
if failed.is_empty() {
Ok(())
} else {
Err(RollbackError { successful, failed })
}
}
fn cleanup_temp_files(&mut self) {
for prepared in &self.prepared_fixes {
if prepared.temp_path.exists() {
if let Err(e) = fs::remove_file(&prepared.temp_path) {
warn!(
"Failed to remove temp file {}: {}",
prepared.temp_path.display(),
e
);
}
}
}
}
}
impl Drop for FixApplicator {
fn drop(&mut self) {
self.cleanup_temp_files();
}
}
fn count_lines_changed(original: &str, new: &str) -> usize {
let orig_lines: Vec<&str> = original.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut changed = 0;
let max_len = orig_lines.len().max(new_lines.len());
for i in 0..max_len {
let orig_line = orig_lines.get(i).copied();
let new_line = new_lines.get(i).copied();
if orig_line != new_line {
changed += 1;
}
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
fs::write(&path, content).expect("Failed to write test file");
path
}
fn create_test_diagnostic(file: &Path, fix_message: &str, new_text: &str) -> Diagnostic {
Diagnostic {
rule: RuleCode::FST004,
severity: super::super::rules::DiagnosticSeverity::Warning,
file: file.to_path_buf(),
range: super::super::rules::Range::new(1, 1, 1, 10),
message: "Test issue".to_string(),
fix: Some(Fix::safe(
fix_message,
vec![Edit {
file: file.to_path_buf(),
range: super::super::rules::Range::new(1, 1, 1, 10),
new_text: new_text.to_string(),
}],
)),
}
}
#[test]
fn test_config_default() {
let config = FixApplicatorConfig::default();
assert!(config.dry_run);
assert_eq!(config.max_files, DEFAULT_MAX_FILES);
assert_eq!(config.max_lines_per_file, DEFAULT_MAX_LINES_PER_FILE);
assert!(!config.force);
assert!(!config.interactive);
}
#[test]
fn test_config_builder() {
let config = FixApplicatorConfig::apply()
.with_force(true)
.with_max_files(100)
.with_max_lines_per_file(200)
.with_interactive(true)
.with_verbose(true);
assert!(!config.dry_run);
assert!(config.force);
assert_eq!(config.max_files, 100);
assert_eq!(config.max_lines_per_file, 200);
assert!(config.interactive);
assert!(config.verbose);
}
#[test]
fn test_safety_limit_files() {
let config = FixApplicatorConfig::default().with_max_files(2);
let applicator = FixApplicator::new(config);
let result = applicator.check_safety_limits(5);
assert!(result.is_err());
match result.unwrap_err() {
FixError::TooManyFiles { count, limit } => {
assert_eq!(count, 5);
assert_eq!(limit, 2);
}
_ => panic!("Expected TooManyFiles error"),
}
}
#[test]
fn test_safety_limit_bypass_with_force() {
let config = FixApplicatorConfig::default()
.with_max_files(2)
.with_force(true);
let applicator = FixApplicator::new(config);
let result = applicator.check_safety_limits(5);
assert!(result.is_ok());
}
#[test]
fn test_count_lines_changed() {
let original = "line1\nline2\nline3\n";
let new = "line1\nmodified\nline3\n";
assert_eq!(count_lines_changed(original, new), 1);
let original = "line1\nline2\n";
let new = "line1\nline2\nline3\n";
assert_eq!(count_lines_changed(original, new), 1);
let original = "line1\n";
let new = "line1\n";
assert_eq!(count_lines_changed(original, new), 0);
}
#[test]
fn test_dry_run_does_not_modify() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = "module Test\n\nlet x = 1\n";
let file = create_test_file(&temp_dir, "Test.fst", original_content);
let config = FixApplicatorConfig::dry_run();
let mut applicator = FixApplicator::new(config);
let diagnostic = create_test_diagnostic(&file, "Remove line", "module Test\n\nlet y = 2\n");
let _result = applicator.apply_batch(&[diagnostic]);
let content_after = fs::read_to_string(&file).expect("Failed to read file");
assert_eq!(
original_content, content_after,
"Dry-run should NOT modify files"
);
}
#[test]
fn test_apply_mode_modifies() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = "module Test\n\nlet x = 1\n";
let file = create_test_file(&temp_dir, "Test.fst", original_content);
let config = FixApplicatorConfig::apply();
let mut applicator = FixApplicator::new(config);
let new_content = "module Test\n\nlet y = 2\n";
let diagnostic = create_test_diagnostic(&file, "Change variable", new_content);
let result = applicator.apply_batch(&[diagnostic]);
assert!(result.is_ok());
let content_after = fs::read_to_string(&file).expect("Failed to read file");
assert_ne!(
original_content, content_after,
"Apply mode should modify files"
);
}
#[test]
fn test_rollback() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = "module Test\n\nlet x = 1\n";
let file = create_test_file(&temp_dir, "Test.fst", original_content);
let config = FixApplicatorConfig::apply();
let mut applicator = FixApplicator::new(config);
let new_content = "module Test\n\nlet y = 2\n";
let diagnostic = create_test_diagnostic(&file, "Change variable", new_content);
let result = applicator.apply_batch(&[diagnostic]);
assert!(result.is_ok());
let content_after_apply = fs::read_to_string(&file).expect("Failed to read file");
assert_ne!(original_content, content_after_apply);
let rollback_result = applicator.rollback_all();
assert!(rollback_result.is_ok());
let content_after_rollback = fs::read_to_string(&file).expect("Failed to read file");
assert_eq!(
original_content, content_after_rollback,
"Rollback should restore original content"
);
}
#[test]
fn test_temp_file_cleanup_on_drop() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = "module Test\nlet x = 1\n";
let file = create_test_file(&temp_dir, "Test.fst", original_content);
{
let config = FixApplicatorConfig::dry_run();
let mut applicator = FixApplicator::new(config);
let diagnostic = create_test_diagnostic(&file, "Test fix", "module Test\nlet y = 2\n");
let _ = applicator.apply_batch(&[diagnostic]);
}
let temp_files: Vec<_> = fs::read_dir(temp_dir.path())
.expect("Failed to read dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.contains(".fstar-lint-tmp"))
.unwrap_or(false)
})
.collect();
assert!(
temp_files.is_empty(),
"Temp files should be cleaned up on drop"
);
}
#[test]
fn test_fix_validation_failure() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let original_content = "module Test\n\nval foo : int -> int\nlet foo x = x + 1\n";
let file = create_test_file(&temp_dir, "Test.fst", original_content);
let config = FixApplicatorConfig::apply().with_min_confidence(0.99);
let mut applicator = FixApplicator::new(config);
let new_content = "module Test\n";
let diagnostic = Diagnostic {
rule: RuleCode::FST004,
severity: super::super::rules::DiagnosticSeverity::Warning,
file: file.clone(),
range: super::super::rules::Range::new(1, 1, 4, 20),
message: "Test removal".to_string(),
fix: Some(Fix::safe("Remove everything", vec![Edit {
file: file.clone(),
range: super::super::rules::Range::new(1, 1, 4, 20),
new_text: new_content.to_string(),
}])),
};
let result = applicator.apply_batch(&[diagnostic]);
assert!(result.is_ok());
let summary = applicator.summary();
assert!(
summary.fixes_failed > 0 || summary.fixes_skipped > 0,
"Should have validation failures or skips"
);
}
#[test]
fn test_unsafe_fix_skipped() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let file = create_test_file(&temp_dir, "Test.fst", "module Test\nlet x = 1\n");
let config = FixApplicatorConfig::apply();
let mut applicator = FixApplicator::new(config);
let diagnostic = Diagnostic {
rule: RuleCode::FST005,
severity: super::super::rules::DiagnosticSeverity::Warning,
file: file.clone(),
range: super::super::rules::Range::new(2, 1, 2, 10),
message: "Unused binding".to_string(),
fix: Some(Fix::unsafe_fix(
"Remove binding",
vec![Edit {
file: file.clone(),
range: super::super::rules::Range::new(2, 1, 2, 10),
new_text: String::new(),
}],
FixConfidence::Low,
"May be used via SMTPat",
)),
};
let result = applicator.apply_batch(&[diagnostic]);
assert!(result.is_ok());
let summary = applicator.summary();
assert_eq!(summary.fixes_skipped, 1);
}
#[test]
fn test_low_confidence_fix_skipped() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let file = create_test_file(&temp_dir, "Test.fst", "module Test\nlet x = 1\n");
let config = FixApplicatorConfig::apply();
let mut applicator = FixApplicator::new(config);
let mut fix = Fix::new("Low confidence fix", vec![Edit {
file: file.clone(),
range: super::super::rules::Range::new(2, 1, 2, 10),
new_text: "let y = 2\n".to_string(),
}]);
fix.confidence = FixConfidence::Medium;
let diagnostic = Diagnostic {
rule: RuleCode::FST006,
severity: super::super::rules::DiagnosticSeverity::Warning,
file: file.clone(),
range: super::super::rules::Range::new(2, 1, 2, 10),
message: "Naming issue".to_string(),
fix: Some(fix),
};
let result = applicator.apply_batch(&[diagnostic]);
assert!(result.is_ok());
let summary = applicator.summary();
assert_eq!(summary.fixes_skipped, 1);
}
#[test]
fn test_interactive_choice() {
assert_eq!(InteractiveChoice::Yes, InteractiveChoice::Yes);
assert_ne!(InteractiveChoice::Yes, InteractiveChoice::No);
}
#[test]
fn test_fix_summary_default() {
let summary = FixSummary::default();
assert_eq!(summary.files_processed, 0);
assert_eq!(summary.fixes_applied, 0);
assert_eq!(summary.fixes_skipped, 0);
assert_eq!(summary.fixes_failed, 0);
}
#[test]
fn test_applied_fix_can_rollback() {
let applied = AppliedFix {
file: PathBuf::from("/test.fst"),
backup_path: None,
temp_path: None,
rule: RuleCode::FST004,
message: "Test".to_string(),
lines_changed: 1,
validation: FixValidation::default(),
original_content: "original".to_string(),
new_content: "new".to_string(),
applied_at: SystemTime::now(),
};
assert!(applied.can_rollback());
let applied_empty = AppliedFix {
file: PathBuf::from("/test.fst"),
backup_path: Some(PathBuf::from("/nonexistent.bak")),
temp_path: None,
rule: RuleCode::FST004,
message: "Test".to_string(),
lines_changed: 1,
validation: FixValidation::default(),
original_content: String::new(),
new_content: "new".to_string(),
applied_at: SystemTime::now(),
};
assert!(!applied_empty.can_rollback());
}
}